golang

Прощай, терминальный хаос: пишем свой TUI-менеджер port-forward для Kubernetes на Go

  • суббота, 24 января 2026 г. в 00:00:15
https://habr.com/ru/articles/988300/

Каждый, кто работает с Kubernetes, знает эту боль. Утро начинается с того, что нужно подключиться к базе данных в production для дебага, потом к Redis в staging для проверки кэша, затем к RabbitMQ для мониторинга очередей, и наконец к API-сервису для тестирования нового эндпоинта.

И вот уже восемь открытых терминалов, в каждом — свой kubectl port-forward. Окна перемешиваются, названия похожи, и найти нужный терминал становится квестом.

Ну да да, можно использовать Tmux, но это не сильно облегчает процесс.

С какими проблемами я столкнулся

  • Потеря контекста — когда одно из соединений падает, приходится перебирать все терминалы, чтобы найти нужный. На это уходит драгоценное время.

  • Ручная работа при смене контекста — переключил kubectl context на другой кластер, и всё нужно перенастраивать заново. Каждый раз одни и те же команды.

  • Отсутствие истории — вернулся после обеда, половина соединений отвалилась. Какие именно порты были нужны? Приходится вспоминать или искать в истории bash.

  • Сложность передачи знаний — коллега просит команду для подключения к сервису. Диктуешь по буквам namespace, имя пода, номера портов. Это неэффективно и приводит к ошибкам.

Я решил, что пора автоматизировать этот процесс и написать специализированный инструмент, исходник на GitHub PortFwd

Анализ существующих решений

Прежде чем писать своё, я изучил, что уже есть на рынке. Оказалось, что решения существуют, но каждое имеет свои плюсы и минусы.

kubectl port-forward

Встроенный инструмент, который работает надёжно и стабильно. Но у него принципиальное ограничение: один терминал — одно соединение. При работе с микросервисной архитектурой, где нужно одновременно держать открытыми 5-10 соединений, это превращается в хаос.

kubefwd

Интересный проект, который автоматически форвардит все сервисы из namespace. Звучит удобно, но есть серьёзные проблемы. Во-первых, он требует права sudo, потому что модифицирует /etc/hosts. Во-вторых, это слишком инвазивно — я хочу контролировать, какие именно сервисы форвардить, а не получать всё скопом.

Lens

Красивый графический интерфейс с множеством функций для работы с Kubernetes. Но это Electron-приложение, которое потребляет более 500 МБ оперативной памяти и 2% CPU даже в режиме простоя. Для такой простой задачи, как управление port-forward, это явный overkill. Плюс Lens — это комбайн, а мне нужен специализированный инструмент.

k9s

Отличный TUI для работы с Kubernetes, который я сам активно использую. Но port-forward там — не основная функция. Управлять множеством одновременных соединений неудобно, нет сохранения сессий, нет группировки.

После этого анализа я понял, что нужно писать своё решение. Лёгкое, специализированное, без Electron'а.

kftRay

Отдельно рассмотрю и сравню проект https://kftray.app/

Сравнение этих двух инструментов интересно тем, что они решают одну и ту же задачу — управление пробросом портов (port-forwarding) в Kubernetes — но делают это с совершенно разных позиций.

Характеристика

kftray (Rust)

portFwd (Go)

Язык программирования

Rust (фреймворк Tauri)

Go

Интерфейс

Полноценный GUI (графическое окно в трее)

CLI (командная строка) + кастомный UI

Сложность

Высокая (визуальное управление множеством конфигов)

Минималистичная (быстрый запуск из терминала)

Кроссплатформенность

Отличная (Windows, macOS, Linux)

Отличная (нативный бинарник)

Целевая аудитория

Те, кто хочет «настроить и забыть» через интерфейс

Те, кто предпочитает терминал и скорость

Почему для такого проекта Go может быть лучше?

Несмотря на то, что kftray на Rust выглядит очень современно, использование Go для portFwd дает несколько фундаментальных преимуществ в контексте Kubernetes:

  1. Нативная экосистема Kubernetes: Весь Kubernetes (и kubectl) написан на Go. Используя Go, разработчик portFwd применяет те же самые библиотеки (client-go), которые используют создатели K8s. Это гарантирует 100% совместимость с конфигами kubeconfig, контекстами и методами аутентификации.

  2. Стабильность сетевых соединений: В Go работа с сетью и многопоточностью (Goroutines) реализована «из коробки» очень эффективно. Проброс портов — это постоянное ожидание трафика и пересылка пакетов. Горутины позволяют обрабатывать сотни таких соединений с минимальным потреблением памяти и без риска «уронить» поток.

  3. Скорость компиляции и деплоя: Если вам нужно быстро внести правку в логику проброса портов, Go скомпилирует бинарник за секунды. Rust (особенно с тяжелым Tauri/GUI) собирается значительно дольше.

  4. Размер и простота: Для системной утилиты, которая должна просто «перекидывать байты» из кластера на локальную машину, Go предлагает более простой и читаемый код. В Rust-проекте (как kftray) много времени уходит на управление памятью и согласование типов интерфейса, в то время как в Go-проекте фокус остается на сетевой логике.

Плюсы portFwd (на Go)

  • Минимализм: Идеально подходит для автоматизации и скриптов.

  • Низкий порог входа: Если вы захотите доработать инструмент под себя, разобраться в коде на Go будет в разы проще, чем в Rust-коде с GUI-обвязкой.

  • Работа с контекстами: Go-библиотеки лучше всего справляются с переключением между десятками кластеров и сложными методами SSO-логина в облака (AWS/GCP/Azure).

Плюсы kftray (на Rust)

  • Визуальный комфорт: Если у вас 20+ портов, управлять ими через иконку в трее удобнее, чем держать открытыми 5 вкладок терминала.

  • Безопасность памяти: Rust исключает целый класс ошибок сегментации, что важно для долгоживущих фоновых процессов.

Итог

Если вам нужен инструмент для ежедневной рутины с красивыми кнопками — выбирайте kftray. Если вам нужна надежная, быстрая и легкая утилита, которая работает так же, как сам Kubernetes — portFwd на Go будет более естественным и предсказуемым выбором.

Теория: как работает port-forward в Kubernetes

Прежде чем писать реализацию, важно понять, как port-forward работает под капотом. Без этого понимания легко написать код, который будет падать в неожиданных местах.

Архитектура соединения

Когда вы выполняете kubectl port-forward pod/nginx 8080:80, данные проходят через несколько компонентов (см. рис. 1). Клиент устанавливает соединение с API Server, который проксирует запрос на Kubelet ноды, где запущен под. Kubelet, в свою очередь, использует nsenter для доступа к network namespace контейнера и пересылает данные процессу внутри.

рис. 1: Архитектура port-forward в Kubernetes
рис. 1: Архитектура port-forward в Kubernetes

Протокол SPDY

Для мультиплексирования потоков Kubernetes использует протокол SPDY — предшественник HTTP/2. Это позволяет передавать несколько потоков данных через одно TCP-соединение. Процесс установки соединения выглядит так:

  1. Клиент отправляет HTTP POST запрос на эндпоинт /api/v1/namespaces/{ns}/pods/{pod}/portforward

  2. В заголовке указывается Upgrade: SPDY/3.1, что инициирует переключение протокола

  3. API Server устанавливает SPDY-соединение и создаёт потоки для данных и ошибок

  4. Kubelet получает запрос и проксирует данные в контейнер

Критический нюанс: Service vs Pod

Это один из самых важных моментов, который я понял не сразу. Хотя Kubernetes API формально поддерживает эндпоинт /services/{service}/portforward, на практике kubectl резолвит сервис в под и подключается напрямую к поду.

Почему так? Потому что port-forward работает на уровне конкретного контейнера через Kubelet, а не через механизм балансировки kube-proxy. Это значит, что нам тоже придётся находить backing pod для сервиса и резолвить targetPort.

Архитектура приложения

После изучения теории я спроектировал архитектуру PortFwd. Приложение разделено на несколько слоёв, каждый из которых отвечает за свою область.

Слой Kubernetes (k8s/client.go)

Этот компонент инкапсулирует всю работу с Kubernetes API. Он отвечает за получение списка namespace'ов, подов и сервисов, а также за резолвинг сервисов в поды. Использует официальную библиотеку client-go.

Менеджер соединений (portforward/manager.go)

Центральный компонент, который управляет жизненным циклом всех port-forward соединений. Он создаёт SPDY-транспорт, отслеживает статус каждого соединения, обрабатывает ошибки и уведомляет UI об изменениях через callback.

Слой UI (ui/app.go, views.go, styles.go)

Терминальный интерфейс построен на фреймворке Bubble Tea с использованием библиотеки Lipgloss для стилизации. Bubble Tea реализует Elm Architecture: Model (состояние), Update (обработка событий), View (рендеринг).

Конфигурация (config/state.go)

Компонент для сохранения и восстановления сессий. При выходе из приложения текущие соединения сохраняются в YAML-файл, а при следующем запуске — восстанавливаются.

Реализация: работа с Kubernetes API

Инициализация клиента

Для работы с кластером нужно инициализировать клиент. Я использовал паттерн из официальных примеров client-go: сначала пробуем получить in-cluster конфиг (если приложение запущено внутри пода), а если не получается — читаем kubeconfig файл.

Ключевой момент — использование clientcmd.BuildConfigFromFlags, которая автоматически учитывает текущий context из kubeconfig. Это позволяет приложению работать с тем же кластером, что и kubectl.

Резолвинг Service в Pod

Когда пользователь выбирает сервис для port-forward, нам нужно найти backing pod. Алгоритм следующий:

  1. Получаем сервис — делаем GET-запрос к API для получения спецификации сервиса

  2. Резолвим targetPort — сервис может иметь port: 80, но контейнер слушает на targetPort: 8000. Нужно использовать именно targetPort

  3. Строим label selector — из поля spec.selector сервиса формируем строку для поиска подов

  4. Находим Running под — получаем список подов по selector и выбираем первый со статусом Running

Обработка Named Ports

Отдельная сложность — именованные порты. Сервис может указывать targetPort: http вместо числа. В этом случае нужно найти порт с таким именем в спецификации контейнера пода. Без этой обработки приложение будет пытаться подключиться к неправильному порту.

Это была одна из главных граблей в разработке. Я потратил несколько часов, разбираясь, почему соединение отказывает, пока не понял, что приложение стучится в порт 80, а процесс слушает на 8000.

Реализация: SPDY port-forward

Создание транспорта

Для установки port-forward соединения используется несколько компонентов из client-go:

  • spdy.RoundTripperFor — создаёт HTTP transport с поддержкой SPDY upgrade

  • spdy.NewDialer — создаёт dialer, который умеет устанавливать SPDY-соединения

  • portforward.NewOnAddresses — собственно создаёт port-forwarder

Важный момент: я использую NewOnAddresses с параметром []string{"127.0.0.1"}, а не стандартный New. Это заставляет слушать только на IPv4. По умолчанию forwarder слушает и на IPv4, и на IPv6, что на некоторых системах (особенно macOS) вызывает проблемы с dual-stack.

Управление жизненным циклом

Каждое соединение имеет несколько каналов для управления:

  • stopChan — сигнал остановки, закрытие этого канала прерывает ForwardPorts

  • readyChan — сигнал готовности, сообщает что туннель установлен

  • context с cancelFunc — для graceful shutdown

При запуске соединения мы ждём либо сигнала готовности (соединение установлено), либо ошибки, либо таймаута (30 секунд). После установки соединения переходим в режим ожидания завершения.

Реализация: TUI на Bubble Tea

Почему Bubble Tea?

Bubble Tea от Charm — это фреймворк для построения терминальных интерфейсов на Go, вдохновлённый Elm Architecture. Он предоставляет чистую модель программирования:

  • Model — иммутабельное состояние приложения (текущий view, список соединений, выбранный элемент)

  • Update — чистая функция, которая принимает сообщение и возвращает новое состояние

  • View — чистая функция, которая рендерит состояние в строку для терминала

Этот подход делает код предсказуемым и легко тестируемым. Состояние всегда консистентно, потому что изменяется только через Update.

Стилизация с Lipgloss

Для визуального оформления я использую Lipgloss — библиотеку для стилизации терминального вывода. Она позволяет задавать цвета, отступы, границы, выравнивание — всё, что нужно для красивого интерфейса.

Я выбрал цветовую схему в стиле киберпанка: неоновый зелёный для активных соединений, красный для ошибок, жёлтый для предупреждений. Это делает статусы соединений мгновенно различимыми.

Связь UI и Manager

Менеджер соединений работает асинхронно — соединения устанавливаются и падают в фоне. Чтобы UI узнавал об изменениях, я использую callback-паттерн. При инициализации UI устанавливает callback в менеджере, и при любом изменении состояния соединения менеджер вызывает этот callback, который отправляет сообщение в Bubble Tea.

Реализация: сохранение состояния

Зачем это нужно

Одна из главных фич PortFwd — сохранение сессий между запусками. Вы настроили 5 port-forward соединений, закрыли приложение, а при следующем запуске — все соединения автоматически восстанавливаются.

Формат хранения

Состояние сохраняется в YAML-файл по пути ~/.config/portfwd/state.yaml. Для каждого соединения сохраняются: namespace, тип ресурса (pod или service), имя ресурса, локальный и удалённый порты, и флаг wasActive — было ли соединение активно при сохранении.

Флаг wasActive важен: если пользователь вручную остановил соединение перед выходом, при следующем запуске оно должно остаться остановленным, а не переподключаться автоматически.

Логика восстановления

При запуске приложение читает файл состояния и для каждого сохранённого соединения проверяет:

  1. Если wasActive: false — добавляем как остановленное, не подключаемся

  2. Если wasActive: true — проверяем доступность ресурса (существует ли под/сервис)

  3. Если ресурс доступен — автоматически переподключаемся

  4. Если ресурс недоступен — добавляем как остановленное с ошибкой

Грабли, на которые я наступил

Эта секция — самая ценная часть статьи. Здесь собраны реальные проблемы, на которые я потратил часы отладки.

Проблема 1: Зависание при выходе

Симптом: после нажатия q приложение не завершается, висит бесконечно.

Причина: callback onChange вызывает program.Send() в Bubble Tea. После вызова tea.Quit программа закрывает канал сообщений, и Send() блокируется навечно, ожидая возможности отправить сообщение.

Решение: отключать callback до остановки соединений. Сначала делаем m.onChange = nil, потом останавливаем соединения. Тогда при изменении состояния соединения callback не вызывается и deadlock не возникает.

Проблема 2: connection refused при подключении к сервису

Симптом: при port-forward к сервису получаем ошибку connection refused внутри контейнера.

Причина: я использовал port сервиса (например, 80) вместо targetPort (например, 8000). Приложение в контейнере слушало на 8000, а мы стучались в 80.

Решение: полный резолвинг targetPort, включая обработку named ports. Нужно всегда использовать targetPort, а не port сервиса.

Проблема 3: panic при повторном закрытии канала

Симптом: panic: close of closed channel при быстром двойном нажатии на «Stop».

Причина: канал stopChan закрывается дважды. В Go закрытие уже закрытого канала вызывает panic.

Решение: использовать sync.Once для защиты операции закрытия. Once гарантирует, что функция выполнится только один раз, даже при конкурентных вызовах.

Проблема 4: IPv6 connection refused

Симптом: на некоторых системах получаем ошибку IPv6 dial tcp6 [::1]:80: connection refused.

Причина: по умолчанию port-forwarder слушает и на IPv4, и на IPv6. На системах с определёнными сетевыми настройками это вызывает проблемы.

Решение: явно указывать только IPv4 через NewOnAddresses с параметром []string{"127.0.0.1"}.

Проблема 5: соединения не восстанавливаются при рестарте

Симптом: при запуске приложения список соединений пустой, хотя файл состояния существует.

Причина: я вызывал saveSessionState() после StopAll(). К моменту сохранения все соединения уже были удалены из менеджера.

Решение: изменить порядок: сначала сохраняем состояние (когда соединения ещё существуют), потом останавливаем их.

Альтернативы, от которых я отказался

WebSocket вместо SPDY: Kubernetes поддерживает оба протокола, но client-go имеет готовую и протестированную реализацию именно для SPDY. Писать свой WebSocket-клиент — лишняя работа без явных преимуществ.

Прямое подключение к Kubelet: Теоретически можно обойти API Server. Но это требует отдельной аутентификации, обхода сетевых ограничений и не даёт никаких преимуществ для нашей задачи.

Тестирование и результаты

Производительность

Я измерил потребление ресурсов PortFwd в сравнении с альтернативами при 5 активных соединениях в режиме простоя:

  • PortFwd: ~30 МБ RAM, ~0.1% CPU

  • Lens: ~500 МБ RAM, ~2% CPU

  • 5 отдельных kubectl: ~100 МБ RAM, ~0.5% CPU

PortFwd потребляет в 15 раз меньше памяти, чем Lens, и запускается мгновенно (менее 100 мс против ~5 секунд у Lens).

Функциональность

  • ✅ Единое окно для всех соединений

  • ✅ Интерактивный выбор: namespace → тип ресурса → pod/service → порты

  • ✅ Автоматический резолвинг targetPort для сервисов

  • ✅ Сохранение и восстановление сессий между запусками

  • ✅ Отдельные логи для каждого соединения

  • ✅ Graceful shutdown без zombie-процессов

  • ✅ Справка по горячим клавишам

Горячие клавиши

  • ↑/↓ или j/k — навигация по списку

  • n — создать новое соединение

  • d — отключить выбранное соединение

  • D — отключить все соединения

  • r — переподключить выбранное

  • l — показать логи соединения

  • x — удалить остановленное соединение

  • ? — показать справку

  • q — выход

Заключение

Что получилось

Я создал специализированный инструмент, который решает конкретную проблему — управление множеством port-forward соединений в Kubernetes. PortFwd легковесный, быстрый и делает одну вещь хорошо.

Что можно улучшить

  • Профили — сохранение наборов соединений для разных окружений (dev, staging, prod) с быстрым переключением

  • Multi-cluster — работа с несколькими кластерами одновременно в одном окне

  • Auto-reconnect — автоматическое переподключение при потере связи без участия пользователя

  • Import — импорт соединений из kubectl команд или YAML-манифестов

Уроки, которые я извлёк

  1. Читай исходники kubectl — это лучшая документация по работе с Kubernetes API. Официальная документация не покрывает многие нюансы.

  2. Тестируй на разных системах — dual-stack IPv4/IPv6 ведёт себя по-разному на Linux и macOS. То, что работает у тебя, может сломаться у пользователя.

  3. sync.Once — твой друг — каналы в Go закрываются только один раз, и это легко забыть при конкурентном доступе.

  4. Порядок операций критичен — особенно при graceful shutdown. Сначала сохраняй состояние, потом останавливай процессы.

Полезные ссылки


Буду рад звёздам на GitHub, issue с багами и pull request'ам с улучшениями. Спасибо за внимание!