The Jerusalem PostTV TIME: Best intense thrillers while awaiting ‘Fauda’וואלהרוסיה דורשת הבהרות מארה"ב וטורקיה על העברת נשק אפשרית לאוקראינהRTP DesportoEuropeus de atletismo. Vitória Oliveira bate recorde na meia maratona em marchaESPN DeportesDiana Flores dedica el Mundial a Julio Ocaña, el coach que considera su mentorInquirer‘Parents responsible for protecting kids online, not just government’RTL BoulevardTurnster Veerman mist nipt brugmedaille bij EK, Visser laatsteMint‘Biggest scam ever in the world’: Nithin Kamath asks startup founders not to brag about AI; social media reactsMundo DeportivoCity 0-2 Barça: Vicky marca el segundoSportstarHockey World Cup: Skipper Harmanpreet hails India’s 3-1 win over Wales in openerRai NewsI servizi russi arrestano una donna: "Ha messo la bomba che ha ucciso un ufficiale a Sebastopoli"MyJoyOnlinePhotos: Police engage religious leaders at National Police HeadquartersBBC News BrasilTerremoto de magnitude 7,7 mata ao menos 47 pessoas na Indonésia
The Daily Newsstand · Free, Always
Saturday, August 15, 2026

Как мы писали Kubernetes-клиент, который старается не врать

Translate

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

Я устал от 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

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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.