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

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

Схема кластера: control plane в офисе, WireGuard/MikroTik, разнородные agent-узлы во флоте
Схема кластера: control plane в офисе, WireGuard/MikroTik, разнородные agent‑узлы во флоте

Почему 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