[Перевод] Чтобы понять Kubernetes, я написал свой Kubernetes

Я не раз разворачивал Kubernetes: поднимал кластеры в домашней лаборатории, делал на работе proof of concept, который отправился на полку, как только появились более приоритетные задачи. Я потратил немало времени на то, чтобы научиться работать с Kubernetes. Но если бы меня попросили честно объяснить, что именно происходит у него под капотом, я бы не смог.
Я умел писать YAML. Умел делать kubectl get pods, читать события, разбираться с CrashLoopBackOff, масштабировать Deployment и отлаживать Ingress, который отвечал 404. Я знал все основные сущности. Но если бы меня остановили и спросили: «Что именно происходит, на уровне ядер ОС, когда под на узле A обращается к Service, за которым стоит под на узле B?» – я бы начал невнятно рассказывать, что «kube-proxy там что-то делает с iptables», и надеялся бы, что дополнительных вопросов не будет.
Этот пробел в знаниях беспокоил меня сильнее, чем мне хотелось признавать. Я научился пользоваться системой, так и не поняв, как она устроена: внутренности Kubernetes по-прежнему оставались для меня магией. Документация и развёртывание кластеров давали взгляд оператора, но не показывали механизм изнутри. Поэтому я решил собрать собственную систему. Не чтобы заменить Kubernetes или что-то в нём исправить, а чтобы действительно разобраться в его устройстве, на собственной шкуре прочувствовав каждое архитектурное решение. Я назвал её Smith.
Самопроверка по теме: небольшой тест покажет, насколько уверенно вы понимаете, что происходит в Kubernetes под капотом.
Почему бы просто не прочитать исходники
Очевидное возражение: Kubernetes – open source. Так иди и читай код.
Я пробовал. По исходникам можно понять, что делает код. Но из них не становится понятно, почему все остальные варианты архитектуры оказались хуже. Смотришь на режим iptables в kube-proxy – и всё кажется довольно произвольным: куча цепочек, флаг --masquerade-all, который выглядит избыточной мерой. Читаешь, киваешь: вроде всё логично. Но на самом деле не понимаешь.
Понимание приходит в тот день, когда твой собственный балансировщик трафика для Service молча начинает терять все запросы, если выбранный бэкенд случайно оказывается на том же узле, что и вызывающий под. Ты проводишь вечер с tcpdump, пытаясь выяснить, почему внезапно понадобилось делать маскарадинг трафика, который, как тебе казалось, вообще не было причин трогать. А потом возвращаешься к тому самому флагу и понимаешь: никакой избыточности там не было. Просто кто-то до тебя провёл точно такой же вечер – и закодировал его результат в этом решении.
Чтение позволяет получить чужие выводы. Собственная реализация заставляет прийти к ним самому. Мне нужны были эти шрамы.
Что такое Smith
Smith – многоузловой оркестратор контейнеров, написанный на Go. В нём около 30 исходных файлов – достаточно мало, чтобы всю систему можно было держать в голове. В этом, собственно, и была главная идея.
Если вы знакомы с Kubernetes, архитектура покажется знакомой:
Управляющий контур хранит желаемое состояние в SQLite и выполняет цикл согласования.
На каждом узле работает агент, который управляет локальным жизненным циклом контейнеров поверх containerd.
Между всеми узлами используется mTLS: TLS 1.3,
RequireAndVerifyClientCert, все сертификаты подписаны CA Smith. Агенты аутентифицируются в управляющем контуре, а управляющий контур – на агентах. Во внутреннем контуре вообще нет соединений без аутентификации.Сеть построена на CNI: на каждом узле работает мост
smith0с IPAM-плагином host-local. Управляющий контур разбивает пул10.22.0.0/16, выделяя каждому узлу по подсети/24, и добавляет статические маршруты, чтобы контейнеры на разных узлах могли обращаться друг к другу по своим реальным IP-адресам.Балансировка трафика Service реализована через iptables: поддерживаются ClusterIP и NodePort. По сути, это компактная реализация режима iptables из kube-proxy: цепочка диспетчеризации передаёт управление в отдельную цепочку конкретного Service, а та выполняет DNAT на разные бэкенды через модуль statistic с вероятностью
1/(N-i), чтобы распределение оставалось равномерным.Ingress с терминацией TLS: агенты запускают reverse proxy на
:443, терминируют TLS с wildcard-сертификатом (Let's Encrypt через ACME DNS-01 challenge в Route 53), маршрутизируют запросы по Host на нужный ClusterIP и перенаправляют трафик с:80. При продлении сертификаты заменяются на лету, без остановки listener.Постепенные обновления: контроллер согласования обнаруживает расхождение со спецификацией, хешируя поля, которые определяют контейнер, и заменяет реплики, не выходя за лимит MaxUnavailable.
Сейчас Smith работает на Proxmox: одна VM управляющего контура и пять узлов с агентами. На него указывают реальные DNS-записи в Route 53. Система действительно обслуживает трафик, а не просто успешно проходит тесты.


Моменты, на которых я действительно чему-то научился
1. Контейнеры на разных узлах не могут общаться, и никто об этом не предупреждает
Когда у меня впервые заработал CNI, я был в восторге. Контейнер запустился, получил адрес 10.22.1.7 и мог выходить в интернет. Два контейнера на одном узле спокойно общались через мост. Я решил, что с сетью разобрался.
Потом я запустил два контейнера на разных узлах – и всё перестало работать, причем без единой ошибки. CNI создаёт на каждом узле изолированный мост — и на этом всё. Между этими мостами ничего само по себе не маршрутизируется: это явно находится за пределами ответственности плагина. Я раньше просто не обращал на это внимания, потому что в Kubernetes эту задачу берёт на себя CNI-плагин – тот самый пункт в инструкции по установке, напротив которого я всегда мысленно ставил галочку и шёл дальше.
Поэтому слой маршрутизации я написал сам: компонент, который добавляет статический маршрут до /24 каждого соседнего узла через IP этого узла, периодически сверяет маршруты с текущим состоянием и удаляет те, что относятся к исчезнувшим узлам.
А потом система быстро поставила меня на место. В конфигурации моста я оставил включённым ipMasq – на первый взгляд вполне разумное значение по умолчанию. В результате весь трафик контейнеров проходил через маскарадинг и получал исходный IP узла. Все межузловые соединения выглядели так, будто исходят от самого узла, а не от пода. Антиаффинность, любые механизмы, завязанные на адрес источника, отладка – всё ломалось.
Решение оказалось в том, чтобы отключить маскарадинг в CNI и выполнять его выборочно на уровне firewall, только там, где он действительно нужен. И это напрямую привело меня к следующей проблеме.
2. Hairpin-пакет, который испортил мне выходные
Под обращается к Service. Балансировщик случайно выбирает бэкенд, и этим бэкендом оказывается под на том же узле, что и вызывающий под. Запрос уходит – и обратно уже не приходит. Во всех остальных случаях всё работает. А этот просто зависает без единой ошибки.
Происходит следующее: пакет проходит DNAT, адрес назначения меняется на адрес локального бэкенда, после чего пакет напрямую доставляется через мост. Бэкенд отвечает непосредственно вызывающему поду – он тоже локальный и тоже доступен через тот же мост. Поэтому ответ уже не проходит обратно через conntrack узла, DNAT не разворачивается обратно, и вызывающий под получает пакет с адреса, которому он ничего не отправлял. Под его отбрасывает. И правильно делает.
Решение – помечать трафик Service и применять к нему MASQUERADE (fwmark 0x4000), чтобы ответ гарантированно прошёл обратно через conntrack и на выходе его адреса были переписаны как нужно. Цена этого решения в том, что бэкенд видит IP узла вместо IP клиентского пода.
Именно это делает --masquerade-all в kube-proxy. Я десятки раз видел этот флаг. Но понял, зачем он нужен, только после того, как собственноручно воспроизвёл баг, от которого он защищает.
3. «Я назначил его на узел» ещё не значит «он работает»
Мой первый цикл согласования по сути работал по событию (edge-triggered): определить, куда должен попасть workload, отправить его агенту через POST, записать, что задача выполнена, и идти дальше. Просто и аккуратно. И неправильно.
Узлы перезагружаются. Агенты перезапускаются. Запрос может успешно пройти, а через тридцать секунд контейнер умрёт. В памяти управляющего контура всё ещё записано «размещён», а в реальности контейнера уже нет – и система больше никак к нужному состоянию не возвращается.
Всё начинает работать только после того, как принимаешь неприятный факт: одноразовая отправка состояния ничего не гарантирует. Цикл должен постоянно сравнивать желаемое состояние с фактическим состоянием кластера, каждый раз заново запрашивая его у агентов, и повторно отправлять всё, что находится не там или работает не так, как должно. Плюс нужен период ожидания, чтобы не прибить контейнер, который просто ещё не успел запуститься.
Вот какой смысл на самом деле скрывается за словом «согласование». Это не «применить изменения». Это «считать всё, что ты знаешь о системе, уже устаревшим, и снова доказывать, что оно соответствует реальности. Постоянно».
Согласование по текущему состоянию (level-triggered), а не по событию (edge-triggered). Я сотню раз встречал эту формулировку, и она ничего для меня не значила, пока моя собственная наивная реализация не начала разваливаться у меня на глазах.
4. Постепенные обновления без единого координатора
При замене реплика на короткое время становится недоступной. Если заменить слишком много реплик одновременно, можно получить простой. В Kubernetes всё это выглядит почти тривиально. Реализовать то же самое без центральной блокировки – уже нет.
На каждом проходе мой контроллер согласования заново вычисляет, сколько реплик workload'а сейчас не запущено, и переводит реплику на новую спецификацию только в том случае, если после этого число недоступных реплик не превысит MaxUnavailable. Никакого координатора, lease или lock – всё то же согласование по текущему состоянию, только теперь применённое к доступности. Частично завершённое обновление здесь вполне нормальное состояние: на следующем проходе его можно обнаружить и продолжить с того же места.
Настоящим прорывом для меня стало спокойное отношение к мысли: «Ничего страшного, если процесс прервётся на полпути. Когда я снова запущусь, посмотрю на наблюдаемое состояние и разберусь, что делать дальше». По сути, это ровно та же идея, что и в пункте № 3, только в другом виде.
Что Kubernetes сделал правильно, а я раньше этого не ценил
Собственноручно собрать худшую версию чужой системы – пожалуй, самый честный способ проникнуться уважением к оригиналу. Теперь в нескольких вещах я убеждён не потому, что у Kubernetes такая репутация, а потому что сам понял, зачем они нужны:
Согласование по текущему состоянию (level-triggered) – основа всей системы. Это не вопрос стиля. Только такая модель выдерживает перезагрузки узлов и сетевые разделения. Практически вся устойчивость Kubernetes вытекает из одного этого решения, и мне пришлось сначала построить сломанную версию, работающую по событию (edge-triggered), чтобы это увидеть.
Граница ответственности CNI – это подарок. Разделение между «выдать контейнеру IP-адрес на мосту» и «сделать так, чтобы разные мосты могли общаться друг с другом» проведено именно там, где нужно. Сначала меня это раздражало, пока я не понял: так плагин просто не приходится нагружать моими проблемами с маршрутизацией.
etcd оправдывает свою сложность. У Smith весь «мозг» хранится в одном SQLite-файле на единственном узле управляющего контура. Это настоящая единая точка отказа, и я сознательно с ней живу. Реплицируемое хранилище на основе консенсуса – не корпоративная избыточность, а решение проблемы, которая теперь есть и у меня, просто я пока решил отложить её на потом.
Проверки состояния не зря управляют rollout'ами. Smith проверяет состояние workload'ов через HTTP и exec, учитывает пороги неудачных проверок и передаёт результат обратно в цикл согласования. «Процесс запущен?» и «Он готов принимать трафик?» – два разных вопроса. Если их смешать, можно выкатить обновление прямо в простой.
--masquerade-allникогда не был признаком лени. См. выше. Пожалуй, я должен извиниться перед этим флагом.
Итог получился отрезвляющим: почти всё в Kubernetes, что я раньше мысленно относил к категории «переусложнили», оказалось критически важным. То, что выглядит как исторически накопившийся хлам, обычно оказывается шрамами от чужих ошибок – такими же, как мои, только более старыми.
Куда дальше движется Smith
Smith уже умеет делать всю ту скучную эксплуатационную работу, благодаря которой я начинаю ему доверять. Распределение подсетей сохраняется в SQLite и привязано к ID узла, поэтому после перезагрузки узел получает обратно ту же /24, а не новую подсеть, которую внезапно подменили под уже запущенными контейнерами.
Желаемое состояние хранится в Git-репозитории и декларативно применяется через небольшой CLI, а секреты шифруются при хранении. Всё это намеренно скучно.

Следующий шаг – перенести Smith на реальное железо и дать ему настоящую нагрузку. Управляющий контур и агенты переедут с VM в Proxmox на два сервера Dell PowerEdge R815, а рабочие узлы будут распределены между ними. Вместо тестовых подов там будут работать настоящие сервисы моей домашней лаборатории.
Вот это и станет реальной проверкой. Не «проходит ли система CI», а «доверю ли я ей то, что мне будет жалко потерять». Git и CI/CD я намеренно оставляю за пределами Smith: задача оркестратора – запускать workload'ы, а не превращаться в мою систему сборки.
В заключение
Я создавал Smith не ради продукта. Меня не отпускала мысль, что я потратил столько времени на изучение инструмента и научился им пользоваться, но всё ещё не мог толком объяснить, как он устроен изнутри. А единственный способ избавиться от такого пробела, который у меня действительно работает, – самому собрать плохую версию системы и ломать её до тех пор, пока решения хорошей версии не станут очевидными.
Это сработало. Теперь я могу подробно ответить на вопрос о том, что происходит между узлами A и B, и объяснить, зачем нужен каждый слой, потому что каждый из них я успел сломать собственными руками. Это совсем другой уровень понимания по сравнению с тем, что у меня был раньше.

Разобраться в Kubernetes на уровне пользователя можно через манифесты и команды kubectl, но многие архитектурные решения становятся понятнее только тогда, когда начинаешь смотреть, что происходит внутри системы. На бесплатных уроках можно продолжить эту тему на практике: посмотреть, как устроен подход GitOps, и разобраться с инструментами, которые помогают поднимать и поддерживать инфраструктуру. Приходите:
7 октября, 20:00. «GitOps-практики: развертываем сервис через ArgoCD». Записаться
15 октября, 20:00. «Поднимаем кластер Kubernetes с помощью Terraform и Ansible». Записаться
Больше полезных материалов по ИТ-инфраструктуре смотрите в дайджесте.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.