Трендовые github проекты в нашем телеграм канале. Подпишись → Все поды 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-кластера после разреза. Каждый из этих пунктов по отдельности оставлял сервис недоступным при зелёных пробах.