Logo Craft Homelab Docs Нейросети Хостинг Обучение Контакты
Kubernetes на трёх площадках: что ломается при частичном разрыве сети Трендовые github проекты в нашем телеграм канале. Подпишись →
23 октября 2026 г.

Все поды Ready, сервис лежит: частичный разрыв сети в растянутом Kubernetes

Конфигурация типовая для тех, кому нужна устойчивость к потере площадки: два датацентра в разных городах с рабочей нагрузкой и третья площадка с одной control-plane нодой — witness. Связность только L3, канал между ДЦ 100 Мбит/с, задержка до witness 23 и 43 мс. Talos Linux, Cilium с VXLAN и kube-proxy replacement, по одному члену etcd в каждой зоне.

Полная потеря датацентра в такой схеме отрабатывает штатно: два оставшихся члена etcd выбирают лидера, кластер живёт. Проблемы начинаются в другом сценарии — связь между ДЦ A и B рвётся, но обе стороны продолжают видеть witness. Кворум формально есть, все процессы живы, пробы зелёные, а запросы висят или падают по таймауту.

Зачем witness и сколько он стоит

С двумя площадками кворум etcd всегда привязан к одной из них. Раскладка 2+1 умирает вместе с первым ДЦ, раскладка 1+1 теряет запись при любом разрыве. Третий голос на отдельной площадке решает это: при потере любого ДЦ или линка между ними у одной из сторон остаётся два голоса из трёх. Рабочей нагрузки на witness нет, только etcd и apiserver.

Цена — латентность. Witness участвует в подтверждении каждой записи, и межзональный RTT попадает в каждый commit и каждое линеаризуемое чтение. На тестовом кластере чтение из etcd занимало 139–166 мс. Собрать все три члена в одной зоне быстрее, но тогда etcd пропадает вместе с этой зоной.

Чтобы клиенты не ходили на дальний apiserver, на witness стоит флаг --endpoint-reconciler-type=none: apiserver перестаёт публиковать свой адрес в сервис kubernetes, продолжая голосовать в кворуме. Для остальных сервисов включается зональная маршрутизация:

apiVersion: v1
kind: Service
spec:
  trafficDistribution: PreferClose

В Kubernetes 1.35 PreferClose объявлен устаревшим в пользу PreferSameZone, но Cilium 1.19.5 понимает только PreferClose: с новым значением зональная маршрутизация молча не работает.

Почему Ready врёт

Readiness-проба проверяет, что процесс жив и отвечает локально. Пробиться через частично разорванную сеть она не пытается. Под, который принимает TCP и зависает на каждом запросе, остаётся в эндпоинтах.

Хороший пример — VictoriaLogs. Логи шардируются по узлам vlstorage без реплик, vlselect опрашивает все узлы. Если один узел за разрывом, запрос ждёт дедлайна в 30 секунд и обрывается целиком: пользователь не видит ни одной строки. Помогла рекомендованная HA-схема: отдельный кластер VictoriaLogs на каждую зону, коллектор пишет во все, читатели идут в свой vlselect через сервис с PreferClose. Объём хранения удваивается, запрос отвечает быстрее секунды.

Gateway API в Cilium игнорирует зоны

Envoy в Cilium Gateway API строит upstream из эндпоинтов всех зон и PreferClose не учитывает (cilium/cilium#48738, #48134). При одностороннем блоке VXLAN около половины запросов через каждую точку входа уходили в таймаут.

Обход: рядом с сервисом создаётся <svc>-zonal без селектора, единственный эндпоинт которого — ClusterIP настоящего сервиса. Envoy видит один адрес, под выбирает datapath Cilium с учётом зон. Цена: под выбирается на соединение, и outlier detection с ретраями Envoy теряют смысл.

Доступ к Kubernetes API

KubePrism выбирает самый быстрый неработающий бэкенд

KubePrism в Talos — локальный балансировщик на localhost:7445 для kubelet. Список бэкендов он берёт из endpoint в machine config и из Talos discovery. Kubernetes-реестр discovery с версии 1.32 не работает из-за feature gate AuthorizeNodeWithSelectors, сервисный реестр требует внешнего сервиса Sidero Labs. Если внешние зависимости запрещены, KubePrism знает ровно один адрес, и при изоляции его площадки воркеры на стороне большинства уходят в NotReady.

Замена — extractedprism со статическим списком apiserver, запущенный static pod’ом на всех нодах. Сразу всплыла вторая проблема: выбор upstream по минимальному времени TCP connect. Локальный apiserver без кворума принимает соединения быстрее любого удалённого. Решение — проверка /readyz с клиентским сертификатом kubelet раз в 5 секунд с таймаутом 3 секунды. Kubelet уходит с неготового apiserver примерно за 15 секунд; в четырёх сценариях разрыва воркеры отрезанной стороны оставались Ready.

EndpointSlice сервиса kubernetes

Поставить PreferClose на сервис kubernetes (тот самый 10.96.0.1) бесполезно: EndpointSlice для него пишет lease reconciler apiserver’а без топологии и стирает ручные правки за 2–4 секунды.

Рабочая схема: --endpoint-reconciler-type=none на всех apiserver плюс DaemonSet apiserver-endpoints на control-plane нодах. Каждый под раз в 5 секунд проверяет /readyz только своего apiserver через localhost и публикует слайс kubernetes-<node> с зоной. Соседей по сети он не проверяет: при разрыве нельзя отличить упавший apiserver от недоступного, а ошибка в EndpointSlice бьёт по всему кластеру.

Дальше нашлись ещё две ловушки:

  • Осиротевшая зона. Если у стороны без кворума нет готового apiserver, Cilium раскидывает её поды по всем бэкендам, включая недоступные: около половины проб к 10.96.0.1 терялись. Зону подхватывает witness, но в Cilium 1.19 один неготовый эндпоинт без топологии отключает зональную фильтрацию для всего сервиса, поэтому неготовые эндпоинты должны сохранять свою зону.
  • Talos читает слайс с именем kubernetes. Оттуда берутся адреса trustd. После отключения reconciler на воркерах перестали обновляться сертификаты apid, и talosctl потерял к ним доступ. Теперь публикуется и слайс kubernetes со всеми адресами, скрытый от datapath: без метки kubernetes.io/service-name его игнорирует kube-proxy, а метка service.kubernetes.io/service-proxy-name прячет его от Cilium.

Отдельно стоит перевести apiserver с headless-сервиса etcd на обычный сервис с PreferClose. Получив список всех членов, apiserver не знает о зонах и при разрыве может держаться за член без лидера на другой стороне.

etcd сам не выходит из частичного разрыва

Лидер в A сохраняет кворум с witness C. Член в B теряет лидера и начинает выборы, но C отказывает ему, потому что слышит живого лидера, — так работают PreVote и CheckQuorum. Член в B остаётся без лидера на весь разрыв, его клиенты получают ошибки, apiserver поверх него для Kubernetes выглядит живым. При нескольких etcd-кластерах на тех же площадках результат случаен: каждый зависит от того, где оказался лидер.

Вручную это лечится etcdctl move-leader на член, который видит всех. Готового механизма в upstream нет: приоритет лидера (etcd-io/etcd#10201) закрыт как stale, ForgetLeader в raft наружу не выставлен.

threek

threek автоматизирует это правило: если лидер не видит какого-то члена, а другой член видит всех, лидерство переходит к нему.

  • Агент рядом с каждым членом раз в секунду шлёт GET /raft/probing на peer-порт с peer-сертификатом — тем же запросом, что и сам etcd. Голый TCP не подходит: etcd логирует каждое соединение, закрытое до TLS.
  • Картина видимости пишется в etcd под /threek/members/ с lease 10 секунд.
  • Решение принимает текущий лидер через штатный MoveLeader, который etcd выполняет только на настоящем лидере. Консенсуса между агентами и форка etcd нет.
  • Гистерезис: линк падает после трёх неудачных проб, поднимается после десяти удачных, перенос только при простое линка дольше 15 секунд, новый лидер две минуты не отдаёт роль.

Готовую метрику etcd_network_active_peers использовать нельзя: таймаут чтения peer-стримов задан на dialer, который net/http игнорирует, и мёртвый пир остаётся «активным» минутами (исправление — etcd-io/etcd#22639, в релизах пока нет).

На проде без трафика etcd без агента вёл себя по описанной схеме, вплоть до NotReady ноды. С threek лидер переезжал на witness через 17 секунд, передача занимала около 100 мс, все три apiserver оставались ready. Агент ставится native sidecar’ом через MutatingAdmissionPolicy (etcd-operator) или static pod’ом (Talos); вариант с подменой бинаря etcd ломал штатное обновление etcd в Talos.

Две площадки и внешний арбитр: twok

twok — исследовательский проект для двух членов etcd без третьей площадки. Если лидера нет дольше 20 секунд, каждый агент пытается через compare-and-swap увеличить поколение в арбитре и записать выжившим себя; бывший лидер получает фору 5 секунд. Арбитр — запись в несколько сотен байт в любом хранилище с атомарным CAS: S3 с условным PUT по ETag, другой etcd, ConfigMap по resourceVersion, HTTP с If-Match.

Победитель останавливает etcd, дописывает в WAL удаление второго члена в следующем term и запускается кластером из одного члена за 1–2 секунды. --force-new-cluster для этого непригоден: он отбрасывает записи выше commit index, а фолловер узнаёт commit index из следующего сообщения лидера. На etcd 3.5.34 и 3.6.15 после SIGKILL фолловера --force-new-cluster терял от 1 до 4 подтверждённых записей за прогон, правка WAL — ни одной.

Ограничения: при недоступном арбитре не пишет никто; двойной отказ требует twok arbiter claim --accept-data-loss; проигравший до возвращения отдаёт serializable-чтения со старых данных. В прод его ставить рано.

Что проверить в своём кластере

Если кластер растянут по площадкам, к тесту с выключением площадки добавьте разрыв линка между ДЦ при живом witness. Проверьте, кто пишет EndpointSlice сервиса kubernetes, по какому критерию локальный балансировщик выбирает apiserver, учитывает ли ваш ingress trafficDistribution и где окажется лидер каждого etcd-кластера после разреза. Каждый из этих пунктов по отдельности оставлял сервис недоступным при зелёных пробах.