С чего всё началось, и почему мне до сих пор немного неловко

Я устал от Lens. Не от какой-то одной его черты — просто от накопившегося раздражения, помноженного на то, что в какой-то момент за него начали просить деньги.

Дальше был обычный круг по альтернативам. k9s прекрасен, но это TUI: он живёт в терминале и разговаривает горячими клавишами, а мне хотелось окно, куда можно ткнуть мышью и показать коллеге через демонстрацию экрана. Headlamp — ближайший по духу и честно бесплатный, но на тот момент он показался мне слишком молодым и слишком «про расширения»: базовые вещи приходилось достраивать плагинами. Octant к тому времени уже был архивирован. Остальное — либо веб-морды, которые надо где-то поднимать и куда-то пускать, либо kubectl с алиасами, что в общем-то и есть исходная точка.

Ни один из них не врал мне меньше, чем Lens. А это, как выяснилось позже, и было настоящей претензией — просто я тогда ещё не умел её сформулировать.

А теперь честная часть, ради которой стоит дочитать абзац. Разозлившись на платный клиент, я сел писать свой платный клиент. Закрытый репозиторий, код лицензирования, приём платежей, версии от 1.0.0 до 1.7.12 — двадцать пять тегов, которые видел только я.

В апреле я это закрыл. Схлопнул историю в один коммит, выпилил весь платёжный код, снёс все двадцать пять тегов и выложил под MIT. Не потому, что прозрел — просто пользователей было ноль, а поддерживать биллинг ради нуля пользователей глупо. Ирония дошла до меня чуть позже, чем следовало бы.

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

Правило, из которого выросло всё остальное

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

Звучит как лозунг, но из него следуют совершенно конкретные баги — те, которые есть почти во всех клиентах, включая тот, что был у нас самих.

Под Running может лежать мёртвый контейнер. .status.phase у пода — это Running, даже когда контейнер внутри крутится в CrashLoopBackOff. Клиент честно показывает поле — и честно врёт. Лечится тем, что статус надо не читать, а выводить: kubectl делает это в функции printPod, и мы её портировали в resources/types/pod_display.rs, вместе с подсчётом readiness ровно так, как считает kubectl get pod (сайдкары там считаются не так, как кажется).

Под init-demo: статус Init:CrashLoopBackOff, «0 of 1 ready», четыре контейнера как упорядоченная последовательность
Под init-demo: статус Init:CrashLoopBackOff, «0 of 1 ready», четыре контейнера как упорядоченная последовательность

Тот же под в списке — Init:CrashLoopBackOff, а не Running. Init-контейнеры доезжают до фронта последовательностью, а не множеством: видно, что migrate — второй из четырёх.

Service с исправным селектором может не отдавать трафик. Здоровые поды, живой селектор, зелёная строчка в списке — и ноль запросов, потому что в targetPort опечатка в имени порта. Пока readiness выводится логикой, это невидимо. Стоит начать читать EndpointSlices — то, что кластер реально публикует, — и расхождение вылезает само.

Цепочка Traefik: entry point → rule → middleware → service → published, где published горит красным «0 published, running, none ready»
Цепочка Traefik: entry point → rule → middleware → service → published, где published горит красным «0 published, running, none ready»

Цепочка не просто рисуется, а называет место обрыва. Внизу — та самая фраза: «which is why every list page in the app draws this as healthy».

Истёкший токен выглядит как пустой кластер. Это мой любимый и самый неприятный. Токены GKE живут около часа, никто их здесь не продлевает, а список, вернувшийся с 401, рисовал своё пустое состояние. То есть приложение с уверенным видом сообщало: подов у вас нет. Ни одного. На всех экранах сразу.

Это худший вид отказа. Ошибка выглядит как ошибка, а вот исправно работающий интерфейс, показывающий неправду, не выглядит никак. Теперь 401 — отдельная ошибка, а не отсутствующий результат: список читает ошибку своего запроса, и отказавшая сессия заменяет страницу экраном, который называет кластер, цитирует API-сервер и предлагает войти заново.

Логи открываются там, где ответ

Забегу вперёд: это уже не моя работа, а того самого друга, про которого ниже. Но пользуюсь я этим чаще всего остального, так что пусть стоит здесь, рядом с правилом, из которого оно выросло.

Если под застрял в Init:CrashLoopBackOff, открывать логи «текущего контейнера» бессмысленно: текущий — это тот, который ещё не стартовал, и он не написал ни строки.

Поэтому вьюер сам выбирает упавший init-контейнер, сам переключается на прошлый запуск и говорит, что сделал именно это.

Логи init-demo: открыто на контейнере migrate, на прошлом запуске, в логе «ERROR: relation orders does not exist»
Логи init-demo: открыто на контейнере migrate, на прошлом запуске, в логе «ERROR: relation orders does not exist»

«Opened on migrate alone — the pod is stuck in init, so nothing after it has written a line. These are the lines of the run that failed, not of the current one.» Четыре строки лога, и в них ответ: миграция упала на несуществующей таблице.

Разница между «показать логи» и «показать те логи, ради которых ты сюда пришёл» — примерно четыре клика и одно переключение контекста. Каждый раз.

Поллинг, который стоил 895 запросов в минуту

Первая версия жила на refetchInterval — 45 руками написанных интервалов по всему фронту. Из них ровно два проверяли, смотрит ли кто-нибудь на экран, которому они принадлежат.

Простаивающее приложение давало 895 запросов в минуту к API-серверу. Сейчас — 205, минус 77%.

Чинилось в два приёма. Сначала списки переехали с поллинга на kube::runtime::watcher — 16 страниц, все через один WatchManager. Потом появился хук useLiveQuery, который принимает не число, а «скорость»: refresh: "resourceList". Интервал он выводит сам — из того, видна ли поверхность, в фокусе ли окно, перестал ли меняться ответ и жив ли watch.

И главное — правило линтера, которое запрещает писать refetchInterval где-либо, кроме самого хука:

selector: "Property[key.name=/^refetchInterval(InBackground)?$/], ...",
message: "Do not set refetchInterval. Use useLiveQuery({ refresh: '<rate>' }) — ...",

Мысль тут не в экономии запросов, а в том, что число, написанное руками, не умеет ничего из перечисленного и не научится потом. Запрет на число — это способ сделать так, чтобы правильное поведение наследовалось само, а не держалось на памяти того, кто пишет следующий экран.

Заодно всплыло, что бэкенд слал по одному Tauri-событию на строку лога, и болтливый под в сотню строк в секунду ронял UI в заметный джанк. Появился флаш раз в 50 мс:

/// Flush interval. 50ms keeps perceived latency low (~one frame at
/// 60fps) while collapsing a chatty pod into a handful of events.
const FLUSH_INTERVAL: Duration = Duration::from_millis(50);

Запомните эту константу, она ещё выстрелит.

Гонка, которую я ловил трижды

Отдельная история, стоившая мне больше всего времени. У Tauri-событий нет реплея. Если бэкенд начал слать байты раньше, чем фронт успел зарезолвить listen(), эти байты просто исчезают.

Первый раз я поймал это на интерактивной аутентификации: модалка OIDC рендерилась пустой, потому что первый промпт улетал в пустоту. Второй раз — на логах. Третий — на watch-событиях.

Лечение одинаковое во всех трёх местах: бэкенд не начинает работу сразу, а блокируется на oneshot::Receiver, и фронт отпускает его отдельной командой *_subscribed — но только после того, как все listen() зарезолвились. Плюс страховочный таймаут на 60 секунд, если фронт не отзовётся.

Из той же серии — RAII-гарды на каждый долгоживущий спавн. Раньше запись в мапе состояния удалялась в конце функции, и любая паника оставляла зомби. Теперь Drop срабатывает на любом выходе, включая раскрутку паники.

Ещё одна оттуда же: пайпы вместо PTY. Инструменты вроде kubelogin зовут getpass/term.ReadPassword, а те проверяют isatty(stdin) и через пайп промпт не печатают вообще. Никакой ошибки — просто тишина. Пришлось переписать exec-адаптер на portable-pty с настоящей парой PTY. Тест на это гоняет sh -c 'tty' и проверяет, что в выводе /dev/ttys…, а не not a tty.

Потом пришёл друг и переписал половину

Где-то здесь в проект пришёл belliel — и дальнейшее лучше показать цифрами, потому что словами звучит неправдоподобно.

Один pull request: 231 коммит, +105 522 / −27 946. Все гейты зелёные на каждом из 231. Второй, через два дня, — ещё +9991.

Что он принёс: платформу интеграций (cert-manager, Traefik, ingress-nginx, Istio, Argo CD, Flux, Prometheus, Loki, k3s, minikube, Karpenter и CRD трёх облаков), карту маршрутизации, цепочку трафика, которая называет место обрыва, дизайн-систему на role-токенах с линт-гвардами против дрейфа, поиск по кластеру, мультинеймспейсный скоуп и то самое правило про «не утверждать» — оно его.

Интеграция стоит одной папки и одной строки, а линт-правило держит шов: снаружи src/integrations/ никто не имеет права назвать вендора по имени — только спросить фасет. Появилось это правило не из любви к архитектуре, а потому что cert-manager однажды приехал в проект дважды, в двух местах, — и ничто не отказало второму.

Страница cert-manager: «6 of 6 broken», у каждого сертификата never issued и «does not exist yet — nothing can serve TLS from it»
Страница cert-manager: «6 of 6 broken», у каждого сертификата never issued и «does not exist yet — nothing can serve TLS from it»

Страница цепочки выдачи. У каждого сертификата — не «ошибка», а конкретный объект, на котором всё встало, и предложение, объясняющее почему.

Дальше — про разделение труда, потому что оно вышло чётким, и я его посчитал через git blame по текущему дереву.

Бэкенд — 22 553 строки мои против 14 743 его. По подсистемам:

Подсистема

Я

Друг

auth/ — EKS/GKE/AKS/OIDC, PTY-адаптер

2750

36

terminal/

1359

237

metrics/

283

2

logs/

840

635

state/ — события, RAII-гарды

706

505

watch/

537

245

resources/

2526

4188

integrations/

0

2079

search/

0

1361

Фронтенд — наоборот: 93 653 строки его против моих 28 029.

Получилось так: я собрал машину, он собрал то, что видит пользователь. Аутентификация, терминал, watch, логи, метрики, состояние, мост событий — моё. Интеграции, маршрутизация, поиск, вывод правдивых статусов и три четверти интерфейса — его.

А теперь та константа, про которую я просил запомнить. Главный перф-выигрыш его последнего релиза звучит так: всплеск на 1000 объектов — это 5 событий и один рендер вместо 1000 и 1000. Достигнут он тем, что watch-события поехали через тот самый пятидесятимиллисекундный флаш из лог-стримера. Он не стал писать свой батчер — переиспользовал мой.

Мне это нравится больше, чем любая цифра в таблице выше. Хорошая инфраструктура — это когда следующий человек делает свою фичу быстрее, чем если бы писал с нуля, и даже не обязан спрашивать разрешения.

Ещё одна деталь про его подход, которую стоит украсть. В последнем PR есть раздел «нашлось во втором проходе»: он перечитывает собственную ветку враждебно, специально ища, что сломал. Так нашлись две худшие вещи в ветке — table-fixed резолвит width:100% как max(100%, сумма ширин), из-за чего список подов оказался на 378 px шире окна с уехавшими за экран кнопками; и слияние обзора рисовало каждую кластерную проблему по разу на каждый выбранный неймспейс. Обе починены до мержа.

Первый же релиз показал, что год сборок был не подписан

Дальше — история про то, как я выкатил релиз, скачал собственный dmg и получил от macOS:

«Rubick» is damaged and can’t be opened. You should move it to the Trash.

Файл был целый. Просто все сборки — начиная с самой первой публичной — несли только ad-hoc-подпись, которую ставит линкер:

Signature=adhoc
TeamIdentifier=not set
Sealed Resources=none

Тонкость, которую стоит знать заранее: на неподписанное приложение macOS ругается привычным «не удалось проверить разработчика» и даёт открыть через контекстное меню. А на ad-hoc-подписанное с карантином — говорит «damaged» и предлагает только корзину. Это одно и то же по сути, но выглядит как испорченная закачка, и пользователь её просто удалит.

Дальше начались открытия:

Developer ID нельзя выпустить через API. App Store Connect API умеет всё — кроме этого. На оба типа сертификата приходит Permission denied: This operation can only be performed by the Account Holder. Только браузер, только руками.

Нотаризация залипает. Не падает — именно висит. Из четырёх заявок за два дня две ушли в Accepted за минуты, а две остались в In Progress; одна из них висела пятнадцать часов при том, что статус-страница Apple рапортовала «инцидентов нет». Джоба на GitHub, которая её ждала, упёрлась в дефолтный шестичасовой потолок и умерла, сожгнув шесть часов раннера впустую.

Лечится не умом, а timeout-minutes: 90 — чтобы зависание становилось быстрым и понятным отказом.

Что до самой сборки — Apple к ней претензий не имела вообще:

"status": "Accepted",
"statusSummary": "Ready for distribution",
"issues": null

Отдельно советую проверять результат не глазами, а воспроизведением: я вешаю на скачанную копию атрибут карантина ровно так, как это делает браузер, и спрашиваю Gatekeeper.

xattr -w com.apple.quarantine "0081;$(printf %x $(date +%s));Safari;" Rubick.app
spctl -a -vvv Rubick.app
# → accepted, source=Notarized Developer ID
xcrun stapler validate Rubick.app
# → The validate action worked!

Степлер тут не формальность: без пристёпленного тикета на машине без интернета Gatekeeper всё равно ругнётся, потому что спросить Apple ему будет негде.

И последнее, что я оттуда вынес. Идентификатор бандла com.k8s-gui.app мы не трогали, хотя продукт по дороге переименовался в Rubick. Сменишь его — и каждая установленная копия отвалится от апдейтера, а рядом встанет вторая. Имя в меню поменять можно, идентификатор — нет.

Что мы намеренно не сделали

Раздел, который в проектах обычно опускают, а зря.

Оценку стоимости. Committed use, sustained use, спот, договорные цены — всё это делает число неверным чаще, чем верным. А неправильное число про деньги отравляет доверие ко всем остальным числам на экране.

Граф топологии всего кластера. Маршрутизация — это цепочка в фиксированном порядке, а не произвольный граф. Force-directed клубок выглядит как инсайт и не отвечает ни на один вопрос.

Редактирование маршрутов и перевыпуск сертификатов. Читать их хорошо — это одна фича, писать — совсем другая, с другим радиусом поражения. Лимит ACME — пять неудач в час, и кнопка, которая их сжигает, кладёт кластер в худший из моментов.

Облачный третий тир — потолки пулов, здоровье балансировщиков. Для этого нужен живой GKE/EKS/AKS, чтобы сверяться. Писать вслепую — значит показывать поля, которых мы ни разу не видели в ответе API.

Что в итоге

Обзор кластера: «Needs attention 8 · worst first», scheduler headroom, warning events
Обзор кластера: «Needs attention 8 · worst first», scheduler headroom, warning events

Стартовый экран отсортирован по тому, насколько всё плохо, а не по алфавиту. Внизу — события с причинами, а не со словом «Warning».

38 810 строк Rust и 121 807 TypeScript. 344 теста в cargo и 1577 в vitest. Семь релизов с 24 апреля, последний — 4.0.0.

Приложение бесплатное, без аккаунта и телеметрии. Собирается под macOS (обе архитектуры), Windows и Linux.

Про лицензию — решение свежее, буквально этих дней. Всё по 3.1.0 включительно выходило под MIT и остаётся под MIT навсегда: этот грант не отзывается. Начиная с 4.0.0 — GPLv3.

Причина, надеюсь, из статьи уже читается. Lens смог стать тем, что меня разозлило, ровно потому, что был разрешительно лицензирован: открытая база, проприетарный слой сверху, деньги. Оставлять ту же дверь открытой в проекте, который вырос из этого раздражения, оказалось глупо.

Форкать и продавать по-прежнему можно — только с исходниками. А запуск приложения распространением не является, так что поставить его себе, хоть на все машины в компании, не обязывает вообще ни к чему.

Код: https://github.com/Dudude-bit/rubick Скачать: https://github.com/Dudude-bit/rubick/releases/latest

Если у вас есть кластер и десять минут — поставьте и скажите, где оно врёт. Это единственный баг-репорт, который мы считаем срочным.