Большинство статей про Kubernetes на границе (edge) описывают более‑менее стабильную топологию: несколько узлов в одном или нескольких дата‑центрах, предсказуемая сеть между ними, редкие сетевые партиции как исключение, а не норма. У нас всё наоборот: control plane живёт в офисе/датацентре, а agent‑узлы физически стоят в транспортных средствах автопарка — едут по городу, паркуются в подземных гаражах без связи, теряют VPN на десятки минут за смену. Разрыв связи с control plane — это не инцидент, это штатный режим работы системы, который нужно было спроектировать с самого начала, а не «подкрутить» постфактум.
В этой статье — то, как устроен наш K3s‑кластер для автопарка: HA control plane на kube‑vip, транспорт через WireGuard поверх MikroTik, разнородный флот из amd64- и ARM‑агентов (включая Jetson), и отдельно — самая интересная часть: как мы подступаемся к вопросу «может ли под на границе продолжать работать, когда control plane временно недостижим».

Почему K3s, а не ванильный Kubernetes
Ключевое ограничение — железо на бортах: часть агентов — это одноплатники и Jetson‑модули, а не серверные amd64-хосты с запасом по памяти. K3s в первую очередь выигрывает не «простотой установки» (это приятный бонус), а тем, что это один статический бинарник с embedded containerd, без отдельного etcd на каждом agent‑узле и с существенно меньшим footprint по памяти — на слабом ARM‑борту это разница между «работает» и «OOM killer убивает kubelet раз в час».
Архитектура control plane: три узла и один VIP
Control plane — три узла в HA‑конфигурации, embedded etcd (собственно поэтому три, а не два: кворуму нужно нечётное число). Проблема любого HA control plane на bare‑metal или в частном облаке — единая точка входа: агентам и kubectl нужен один адрес, а не список из трёх IP, между которыми надо вручную переключаться при отказе узла.
Для этого перед control plane стоит kube‑vip — он поднимает виртуальный IP и через leader election (на базе той же логики, что использует сам Kubernetes для controller‑manager) назначает, какой из трёх узлов сейчас отвечает на этот VIP. Падает узел‑лидер — kube‑vip на оставшихся двух почти мгновенно перевыбирает нового держателя VIP, и с точки зрения агентов адрес control plane не менялся вообще.
# фрагмент манифеста kube-vip (ARP-режим) apiVersion: v1 kind: Pod metadata: name: kube-vip namespace: kube-system spec: containers: - name: kube-vip image: ghcr.io/kube-vip/kube-vip:v0.7.2 args: ["manager"] env: - name: vip_interface value: eth0 - name: address value: "10.10.0.1" - name: vip_arp value: "true" - name: vip_leaderelection value: "true"
Развёртывание control‑plane и agent‑узлов — не руками и не через k3sup, а через собственные Ansible‑роли k3s_server и k3s_agent. Это принципиально: у нас гетерогенный флот, и один и тот же плейбук должен корректно накатываться и на amd64-сервер в офисе, и на ARM‑плату в машине — с разными версиями бинарника k3s, разными systemd‑юнитами по ресурсам и разной сетевой конфигурацией на входе.
Транспорт: WireGuard поверх MikroTik
Агенты не сидят в одной L2-сети с control plane — они разбросаны географически и подключаются через WireGuard‑туннели, которые терминируются на MikroTik‑роутерах. Выбор MikroTik не случаен: RouterOS начиная с 7 версии умеет WireGuard нативно, без отдельного Linux‑бокса рядом как VPN‑концентратора — это на порядок упрощает эксплуатацию, когда точек присутствия много, а держать в каждой полноценный сервер накладно.
Из этого же стека вытекает любопытная возможность на будущее: RouterOS 7 умеет запускать Linux‑контейнеры нативно (не Docker CLI, а свой container‑механизм поверх того же ядра) — то есть лёгкий node_exporter или прокси‑шим прямо на пограничном роутере, без отдельного железа рядом, вполне реализуемо. Пока не задействовано в проде, но architecturally это ложится ровно в ту же модель «минимум лишних сущностей на границе».
Практическое следствие WireGuard‑транспорта — сеть между agent и control plane не просто «иногда недоступна», у неё систематически выше и более рваная задержка, чем у выделенного канала в одном ДЦ. Это напрямую бьёт по настройке проб в Kubernetes.
Readiness и liveness на нестабильном канале
Классическая ошибка — повесить liveness‑пробу на что‑то тяжелее, чем «жив ли сам процесс», и получить бесконечный рестарт‑луп из‑за сетевых лагов, а не из‑за реальной поломки. У нас это не гипотетический риск, а гарантированный сценарий: если liveness‑проверка ходит куда‑то через VPN‑канал с непредсказуемой задержкой, кратковременный лаг WireGuard превращается в убийство и пересоздание совершенно здорового пода.
Разделение простое, но соблюдать его на границе критичнее, чем в датацентре: liveness проверяет исключительно локальное состояние процесса (без походов во внешние зависимости и уж тем более без похода через VPN‑туннель), а readiness может позволить себе более тяжёлую и более медленную проверку — её провал просто временно выводит под из балансировки, а не убивает его.
livenessProbe: httpGet: path: /healthz # только локальный процесс port: 8080 periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: path: /ready # можно проверять локальные зависимости port: 8080 periodSeconds: 5 failureThreshold: 2 timeoutSeconds: 3
Гонка с cgroups: Jetson, JetPack 5.3.1 и kubelet 1.36
Самый неприятный баг флота обнаружился не в сети, а в самих ARM‑узлах. Jetson‑модули идут с вендорским BSP (JetPack), который на версии 5.3.1 всё ещё тянет за собой окружение, ориентированное на cgroups v1 — тогда как современный kubelet (у нас — 1.36) и его ресурсный менеджмент рассчитаны на унифицированную иерархию cgroups v2, где вместо отдельного дерева групп на каждый контроллер (CPU, память, I/O по отдельности, как было в v1) один процесс живёт в одной группе с несколькими включёнными контроллерами внутри неё.
На практике это выглядело так: kubelet на Jetson либо не стартовал, либо стартовал и сразу же вёл себя непредсказуемо по лимитам ресурсов, потому что ожидал unified‑иерархию, а получал v1-раскладку от BSP. Проверяется это тривиально:
mount | grep cgroup2 # пусто или нет строки с cgroup2 — система всё ещё на v1-иерархии
Чинить BSP вендора — не наш путь: JetPack трогать нежелательно, это сорвёт сертификацию и стабильность остального стека на плате. Решение оказалось на стороне K3s — явно разрешить агенту работать поверх legacy‑иерархии, не требуя от системы миграции на v2:
# /etc/rancher/k3s/config.yaml на agent-узле (Jetson) kubelet-arg: - "fail-cgroupv1=false"
Без этого флага k3s‑agent на подобных платах просто отказывался подниматься, ссылаясь на несовместимость cgroup‑иерархии — а с ним кластер принимает узел таким, какой он есть, не выкручивая руки вендорскому образу.
Выкладка обновлений на разнородный и не всегда доступный флот
Rolling update на обычном кластере в ДЦ регулируется двумя параметрами стратегии: maxUnavailable — сколько подов старой версии можно снять одновременно, и maxSurge — сколько лишних подов новой версии можно поднять сверх нормы, пока идёт выкат. На флоте с нестабильной связью агрессивные значения не просто увеличивают blast‑radius при плохом релизе — они гарантированно упрутся в то, что часть агентов физически недоступна прямо сейчас (машина в рейсе без связи), и rollout будет висеть, ожидая узлы, которые появятся в сети только через несколько часов.
strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 0
Версионирование образов и веток идёт через GitLab CI/CD с чёткой логикой: семантическая версия (v1.4.2) — это то, что реально катится на прод‑флот, а всё, что собирается с веток разработки, помечается суффиксом -dev и в производственный rollout не попадает в принципе. Это разделение снимает целый класс инцидентов «а почему в машине оказался билд, который никто не должен был катить».
Главный открытый вопрос: работа пода, когда control plane недостижим
Технически kubelet и так продолжает поддерживать уже запущенные на узле поды, даже полностью потеряв связь с API‑сервером — control plane нужен для новых назначений, обновлений состояния и части служебной логики, а не для того, чтобы под продолжал физически исполняться. Но у нас проблема на уровень выше: сервисам на борту машины часто нужен не только «под жив», а конкретный ответ от внутреннего API, который в обычном режиме идёт через control plane или соседние сервисы кластера — а вот это уже недоступно, когда VPN‑туннель лежит.
Решение, которое сейчас на стадии прототипа — двухрежимный TCP‑прокси/шим на самом agent‑узле:
Passthrough‑режим — пока VIP control plane доступен через WireGuard, шим прозрачно проксирует трафик как обычно, никак не вмешиваясь в ответ.
Fallback‑режим — как только VIP перестаёт отвечать, шим переключается на отдачу последнего закэшированного валидного ответа вместо честного похода наружу, чтобы борт продолжал работать на «немного устаревших, но валидных» данных, а не падал в ошибку целиком.
Следующий шаг — довести этот прототип до продового качества: политика инвалидации кэша, метрики по доле fallback‑ответов (чтобы видеть деградацию флота по связности как метрику, а не узнавать постфактум), и explicit‑переключение состояния, которое можно будет отдавать в мониторинг отдельным сигналом «этот борт сейчас работает в offline‑режиме».
Что в сухом остатке
Кластер на границе с реальным, а не гипотетическим отсутствием связи меняет приоритеты по сравнению с обычным K8s в ДЦ: HA control plane и rolling‑стратегии здесь не про «отказоустойчивость на бумаге», а про то, что часть флота гарантированно будет недоступна в любой момент времени, и система обязана оставаться консистентной в этом состоянии, а не считать его аварией. Следующая итерация — довести offline‑шим до продакшена и вынести связность флота в отдельную метрику здоровья системы.
Буду рад, если подпишитесь на мой канал по администрированию и DevOps — в Telegram: @sys_admin_expert