Прощай, терминальный хаос: пишем свой TUI-менеджер port-forward для Kubernetes на Go
- суббота, 24 января 2026 г. в 00:00:15
Каждый, кто работает с Kubernetes, знает эту боль. Утро начинается с того, что нужно подключиться к базе данных в production для дебага, потом к Redis в staging для проверки кэша, затем к RabbitMQ для мониторинга очередей, и наконец к API-сервису для тестирования нового эндпоинта.
И вот уже восемь открытых терминалов, в каждом — свой kubectl port-forward. Окна перемешиваются, названия похожи, и найти нужный терминал становится квестом.
Ну да да, можно использовать Tmux, но это не сильно облегчает процесс.
Потеря контекста — когда одно из соединений падает, приходится перебирать все терминалы, чтобы найти нужный. На это уходит драгоценное время.
Ручная работа при смене контекста — переключил kubectl context на другой кластер, и всё нужно перенастраивать заново. Каждый раз одни и те же команды.
Отсутствие истории — вернулся после обеда, половина соединений отвалилась. Какие именно порты были нужны? Приходится вспоминать или искать в истории bash.
Сложность передачи знаний — коллега просит команду для подключения к сервису. Диктуешь по буквам namespace, имя пода, номера портов. Это неэффективно и приводит к ошибкам.
Я решил, что пора автоматизировать этот процесс и написать специализированный инструмент, исходник на GitHub PortFwd
Прежде чем писать своё, я изучил, что уже есть на рынке. Оказалось, что решения существуют, но каждое имеет свои плюсы и минусы.
Встроенный инструмент, который работает надёжно и стабильно. Но у него принципиальное ограничение: один терминал — одно соединение. При работе с микросервисной архитектурой, где нужно одновременно держать открытыми 5-10 соединений, это превращается в хаос.
Интересный проект, который автоматически форвардит все сервисы из namespace. Звучит удобно, но есть серьёзные проблемы. Во-первых, он требует права sudo, потому что модифицирует /etc/hosts. Во-вторых, это слишком инвазивно — я хочу контролировать, какие именно сервисы форвардить, а не получать всё скопом.
Красивый графический интерфейс с множеством функций для работы с Kubernetes. Но это Electron-приложение, которое потребляет более 500 МБ оперативной памяти и 2% CPU даже в режиме простоя. Для такой простой задачи, как управление port-forward, это явный overkill. Плюс Lens — это комбайн, а мне нужен специализированный инструмент.
Отличный 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) | Отличная (нативный бинарник) |
Целевая аудитория | Те, кто хочет «настроить и забыть» через интерфейс | Те, кто предпочитает терминал и скорость |
Несмотря на то, что kftray на Rust выглядит очень современно, использование Go для portFwd дает несколько фундаментальных преимуществ в контексте Kubernetes:
Нативная экосистема Kubernetes: Весь Kubernetes (и kubectl) написан на Go. Используя Go, разработчик portFwd применяет те же самые библиотеки (client-go), которые используют создатели K8s. Это гарантирует 100% совместимость с конфигами kubeconfig, контекстами и методами аутентификации.
Стабильность сетевых соединений: В Go работа с сетью и многопоточностью (Goroutines) реализована «из коробки» очень эффективно. Проброс портов — это постоянное ожидание трафика и пересылка пакетов. Горутины позволяют обрабатывать сотни таких соединений с минимальным потреблением памяти и без риска «уронить» поток.
Скорость компиляции и деплоя: Если вам нужно быстро внести правку в логику проброса портов, Go скомпилирует бинарник за секунды. Rust (особенно с тяжелым Tauri/GUI) собирается значительно дольше.
Размер и простота: Для системной утилиты, которая должна просто «перекидывать байты» из кластера на локальную машину, Go предлагает более простой и читаемый код. В Rust-проекте (как kftray) много времени уходит на управление памятью и согласование типов интерфейса, в то время как в Go-проекте фокус остается на сетевой логике.
Минимализм: Идеально подходит для автоматизации и скриптов.
Низкий порог входа: Если вы захотите доработать инструмент под себя, разобраться в коде на Go будет в разы проще, чем в Rust-коде с GUI-обвязкой.
Работа с контекстами: Go-библиотеки лучше всего справляются с переключением между десятками кластеров и сложными методами SSO-логина в облака (AWS/GCP/Azure).
Визуальный комфорт: Если у вас 20+ портов, управлять ими через иконку в трее удобнее, чем держать открытыми 5 вкладок терминала.
Безопасность памяти: Rust исключает целый класс ошибок сегментации, что важно для долгоживущих фоновых процессов.
Если вам нужен инструмент для ежедневной рутины с красивыми кнопками — выбирайте kftray. Если вам нужна надежная, быстрая и легкая утилита, которая работает так же, как сам Kubernetes — portFwd на Go будет более естественным и предсказуемым выбором.
Прежде чем писать реализацию, важно понять, как port-forward работает под капотом. Без этого понимания легко написать код, который будет падать в неожиданных местах.
Когда вы выполняете kubectl port-forward pod/nginx 8080:80, данные проходят через несколько компонентов (см. рис. 1). Клиент устанавливает соединение с API Server, который проксирует запрос на Kubelet ноды, где запущен под. Kubelet, в свою очередь, использует nsenter для доступа к network namespace контейнера и пересылает данные процессу внутри.

Для мультиплексирования потоков Kubernetes использует протокол SPDY — предшественник HTTP/2. Это позволяет передавать несколько потоков данных через одно TCP-соединение. Процесс установки соединения выглядит так:
Клиент отправляет HTTP POST запрос на эндпоинт /api/v1/namespaces/{ns}/pods/{pod}/portforward
В заголовке указывается Upgrade: SPDY/3.1, что инициирует переключение протокола
API Server устанавливает SPDY-соединение и создаёт потоки для данных и ошибок
Kubelet получает запрос и проксирует данные в контейнер
Это один из самых важных моментов, который я понял не сразу. Хотя Kubernetes API формально поддерживает эндпоинт /services/{service}/portforward, на практике kubectl резолвит сервис в под и подключается напрямую к поду.
Почему так? Потому что port-forward работает на уровне конкретного контейнера через Kubelet, а не через механизм балансировки kube-proxy. Это значит, что нам тоже придётся находить backing pod для сервиса и резолвить targetPort.
После изучения теории я спроектировал архитектуру PortFwd. Приложение разделено на несколько слоёв, каждый из которых отвечает за свою область.
Этот компонент инкапсулирует всю работу с Kubernetes API. Он отвечает за получение списка namespace'ов, подов и сервисов, а также за резолвинг сервисов в поды. Использует официальную библиотеку client-go.
Центральный компонент, который управляет жизненным циклом всех port-forward соединений. Он создаёт SPDY-транспорт, отслеживает статус каждого соединения, обрабатывает ошибки и уведомляет UI об изменениях через callback.
Терминальный интерфейс построен на фреймворке Bubble Tea с использованием библиотеки Lipgloss для стилизации. Bubble Tea реализует Elm Architecture: Model (состояние), Update (обработка событий), View (рендеринг).
Компонент для сохранения и восстановления сессий. При выходе из приложения текущие соединения сохраняются в YAML-файл, а при следующем запуске — восстанавливаются.
Для работы с кластером нужно инициализировать клиент. Я использовал паттерн из официальных примеров client-go: сначала пробуем получить in-cluster конфиг (если приложение запущено внутри пода), а если не получается — читаем kubeconfig файл.
Ключевой момент — использование clientcmd.BuildConfigFromFlags, которая автоматически учитывает текущий context из kubeconfig. Это позволяет приложению работать с тем же кластером, что и kubectl.
Когда пользователь выбирает сервис для port-forward, нам нужно найти backing pod. Алгоритм следующий:
Получаем сервис — делаем GET-запрос к API для получения спецификации сервиса
Резолвим targetPort — сервис может иметь port: 80, но контейнер слушает на targetPort: 8000. Нужно использовать именно targetPort
Строим label selector — из поля spec.selector сервиса формируем строку для поиска подов
Находим Running под — получаем список подов по selector и выбираем первый со статусом Running
Отдельная сложность — именованные порты. Сервис может указывать targetPort: http вместо числа. В этом случае нужно найти порт с таким именем в спецификации контейнера пода. Без этой обработки приложение будет пытаться подключиться к неправильному порту.
Это была одна из главных граблей в разработке. Я потратил несколько часов, разбираясь, почему соединение отказывает, пока не понял, что приложение стучится в порт 80, а процесс слушает на 8000.
Для установки 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 секунд). После установки соединения переходим в режим ожидания завершения.
Bubble Tea от Charm — это фреймворк для построения терминальных интерфейсов на Go, вдохновлённый Elm Architecture. Он предоставляет чистую модель программирования:
Model — иммутабельное состояние приложения (текущий view, список соединений, выбранный элемент)
Update — чистая функция, которая принимает сообщение и возвращает новое состояние
View — чистая функция, которая рендерит состояние в строку для терминала
Этот подход делает код предсказуемым и легко тестируемым. Состояние всегда консистентно, потому что изменяется только через Update.
Для визуального оформления я использую Lipgloss — библиотеку для стилизации терминального вывода. Она позволяет задавать цвета, отступы, границы, выравнивание — всё, что нужно для красивого интерфейса.
Я выбрал цветовую схему в стиле киберпанка: неоновый зелёный для активных соединений, красный для ошибок, жёлтый для предупреждений. Это делает статусы соединений мгновенно различимыми.
Менеджер соединений работает асинхронно — соединения устанавливаются и падают в фоне. Чтобы UI узнавал об изменениях, я использую callback-паттерн. При инициализации UI устанавливает callback в менеджере, и при любом изменении состояния соединения менеджер вызывает этот callback, который отправляет сообщение в Bubble Tea.
Одна из главных фич PortFwd — сохранение сессий между запусками. Вы настроили 5 port-forward соединений, закрыли приложение, а при следующем запуске — все соединения автоматически восстанавливаются.
Состояние сохраняется в YAML-файл по пути ~/.config/portfwd/state.yaml. Для каждого соединения сохраняются: namespace, тип ресурса (pod или service), имя ресурса, локальный и удалённый порты, и флаг wasActive — было ли соединение активно при сохранении.
Флаг wasActive важен: если пользователь вручную остановил соединение перед выходом, при следующем запуске оно должно остаться остановленным, а не переподключаться автоматически.
При запуске приложение читает файл состояния и для каждого сохранённого соединения проверяет:
Если wasActive: false — добавляем как остановленное, не подключаемся
Если wasActive: true — проверяем доступность ресурса (существует ли под/сервис)
Если ресурс доступен — автоматически переподключаемся
Если ресурс недоступен — добавляем как остановленное с ошибкой
Эта секция — самая ценная часть статьи. Здесь собраны реальные проблемы, на которые я потратил часы отладки.
Симптом: после нажатия q приложение не завершается, висит бесконечно.
Причина: callback onChange вызывает program.Send() в Bubble Tea. После вызова tea.Quit программа закрывает канал сообщений, и Send() блокируется навечно, ожидая возможности отправить сообщение.
Решение: отключать callback до остановки соединений. Сначала делаем m.onChange = nil, потом останавливаем соединения. Тогда при изменении состояния соединения callback не вызывается и deadlock не возникает.
Симптом: при port-forward к сервису получаем ошибку connection refused внутри контейнера.
Причина: я использовал port сервиса (например, 80) вместо targetPort (например, 8000). Приложение в контейнере слушало на 8000, а мы стучались в 80.
Решение: полный резолвинг targetPort, включая обработку named ports. Нужно всегда использовать targetPort, а не port сервиса.
Симптом: panic: close of closed channel при быстром двойном нажатии на «Stop».
Причина: канал stopChan закрывается дважды. В Go закрытие уже закрытого канала вызывает panic.
Решение: использовать sync.Once для защиты операции закрытия. Once гарантирует, что функция выполнится только один раз, даже при конкурентных вызовах.
Симптом: на некоторых системах получаем ошибку IPv6 dial tcp6 [::1]:80: connection refused.
Причина: по умолчанию port-forwarder слушает и на IPv4, и на IPv6. На системах с определёнными сетевыми настройками это вызывает проблемы.
Решение: явно указывать только IPv4 через NewOnAddresses с параметром []string{"127.0.0.1"}.
Симптом: при запуске приложения список соединений пустой, хотя файл состояния существует.
Причина: я вызывал 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-манифестов
Читай исходники kubectl — это лучшая документация по работе с Kubernetes API. Официальная документация не покрывает многие нюансы.
Тестируй на разных системах — dual-stack IPv4/IPv6 ведёт себя по-разному на Linux и macOS. То, что работает у тебя, может сломаться у пользователя.
sync.Once — твой друг — каналы в Go закрываются только один раз, и это легко забыть при конкурентном доступе.
Порядок операций критичен — особенно при graceful shutdown. Сначала сохраняй состояние, потом останавливай процессы.
Буду рад звёздам на GitHub, issue с багами и pull request'ам с улучшениями. Спасибо за внимание!