
Представьте себе баг-репорт: все шесть подов в статусе 1/1 Running, Readiness зелёный, kafka-agent на брокерах отдаёт 204. А кластер мёртв: оператор бесконечно крутит реконсайл и валится в TimeoutException: Timed out waiting for a node assignment. Call: describeMetadataQuorum. Если зайти на том контроллера, можно увидеть, что __cluster_metadata-0/quorum-state: leaderId выставлен, а appliedOffset в 0. Ни одна запись метаданных не применена. JVM живые, raft-лог на диске растёт, и при этом ни один под не написал в stdout ни строки следующие восемь часов. Это не выдумка, а реальный баг-репорт Strimzi от 25 мая 2026, и к нему мы ещё вернёмся.
В Kafka 4.x больше нет привычного «запасного выходa» в виде ZooKeeper: метаданные переехали внутрь самой Kafka (KRaft), и если кворум контроллеров теряет большинство, чинить уже приходится сам кластер Kafka, а не внешний сервис.
Раньше чтобы с этим справиться в Kafka существовал ZooKeeper, отдельная система со своими инструментами, ансамблем и культурой восстановления. В версии 4.x его нет, и в документации от процедуры остался только линк на 3.9. Метаданные переехали внутрь самой Kafka (KRaft), и если кворум контроллеров теряет большинство, чинить уже приходится сам кластер Kafka, а не внешний сервис.
В этой статье расскажем, что делать в такой ситуации и как восстанавливать этот кворум, чтобы не случилось ситуации, описанной выше.
Важное о терминах
Сначала нужно разделить несколько сущностей, чтобы случайно их не перепутать.
KRaft — встроенный механизм хранения и репликации метаданных Apache Kafka, пришедший на смену ZooKeeper. Это реализация KIP-500: метаданные кластера (топики, партиции, ACL, конфиги) хранятся в самой Kafka, на Raft-логе, а не во внешнем ZooKeeper. Появился в 2.8 как early access, в 3.3 помечен production-ready — причём только для новых кластеров, миграция существующих появилась позже. С 4.0 это единственный режим работы. Всё, что дальше сказано про кворум, voter, epoch и metadata log, — это апстримный Apache Kafka.
Инструменты восстановления всегда вендорские. Команды log-length и force-standalone, на которых построен runbook, отсутствуют в upstream Apache Kafka. Они доступны в составе kafka-metadata-recovery от Confluent Platform.
Kubernetes выступает только средой выполнения. К KRaft он отношения не имеет, но в примерах будет мелькать kubectl, потому что процедура вендора написана под их оператор. При использовании Kubernetes-операторов (Strimzi, Confluent for Kubernetes) появляется дополнительный слой ограничений, которого нет в стандартной Apache Kafka — именно он объясняет зелёные, но неработающие поды из вступления.
То есть механика кворума одна для всех, а инструменты и проблемы зависят от того, как именно у вас развёрнута Kafka.
Как именно падает кворум
В KRaft каждый сервер объявляет свою роль через process.roles: broker, controller или broker,controller. Ещё существует combined-режим. Он удобен для стендов, но не рекомендуется для критичных сред: контроллер не изолирован от остальной системы, поэтому роллить и масштабировать контроллеры отдельно от брокеров уже нельзя.
Контроллеры образуют кворум метаданных. Кворум выбирает лидера и принимает записи метаданных, пока доступно большинство участников голосования — voter'ов. Потеряли большинство — лидера нет: описания топиков, ACL, конфиги, состав ISR перестают меняться.
Кворум метаданных и репликация данных топиков — два независимых слоя. У них разные правила подтверждения записи и разные аварии. Кластер с мёртвым кворумом контроллеров продолжает обслуживать продюсеров и консьюмеров по последнему известному состоянию метаданных: партиции живы, лидеры партиций назначены, ISR тот, что был. Ломается всё, что требует изменения метаданных: создание топиков, переназначение лидера при отказе брокера, любые административные операции. То есть кластер не упал, он застыл. И это опаснее обычного падения, ведь мониторинг остаётся зелёным.
Первое, что нужно посмотреть, — состояние кворума. Вывод ниже взят из опубликованного прогона на пяти контроллерах с портами 9001–9005, не с моего стенда: у харнеса в конце статьи четыре контроллера и порт 9093, структура вывода та же, отличаются только идентификаторы.
$ bin/kafka-metadata-quorum.sh --bootstrap-controller localhost:9093 describe --status ClusterId: pU1NPGv-SKuqpZB2-APC4w LeaderId: 9003 LeaderEpoch: 43 HighWatermark: 36991 MaxFollowerLag: 0 MaxFollowerLagTimeMs: 112 CurrentVoters: [{"id": 9001, "directoryId": "3ec4MdQKRpWC7sFXXk5gGg", "endpoints": ["CONTROLLER://localhost:9001"]}, {"id": 9002, "directoryId": "rKfquKvGGqZxbvFfrmnZrw", "endpoints": ["CONTROLLER://localhost:9002"]}, {"id": 9003, "directoryId": "AuKSkIIjefhbmVvMg5k37w", "endpoints": ["CONTROLLER://localhost:9003"]}, {"id": 9004, "directoryId": "OAInJaBeBbU0fuQ7M_q5xA", "endpoints": ["CONTROLLER://localhost:9004"]}, {"id": 9005, "directoryId": "LvhtJEVYuBkniTM3-dk5VA", "endpoints": ["CONTROLLER://localhost:9005"]}] CurrentObservers: []
Здесь всё, что нужно для диагностики: кто лидер, какая у него epoch, где high watermark, кто в наборе voter и кто просто наблюдает. Ключевую роль здесь играет пара id + directoryId — она объясняет, почему перезапущенная нода не теряет место в кворуме.
Теперь то же самое на кластере, где выбыла одна нода:
$ bin/kafka-metadata-quorum.sh --bootstrap-controller localhost:9001 describe --replication NodeId DirectoryId LogEndOffset Lag LastFetchTimestamp LastCaughtUpTimestamp Status 9001 3ec4MdQKRpWC7sFXXk5gGg 39488 0 1767450887786 1767450887786 Leader 9002 rKfquKvGGqZxbvFfrmnZrw 39488 0 1767450887521 1767450887521 Follower 9003 AuKSkIIjefhbmVvMg5k37w 39488 0 1767450887521 1767450887521 Follower 9004 OAInJaBeBbU0fuQ7M_q5xA 39488 0 1767450887521 1767450887521 Follower 9005 LvhtJEVYuBkniTM3-dk5VA 39390 98 1767450838609 1767450838115 Follower
Нода 9005 остановлена. Её лаг растёт, но она по-прежнему в списке voter'ов, и кворум работает, потому что большинство живо. Упавший контроллер не выпадает из набора voter'ов сам. Пока вы его явно не удалили, знаменатель в расчёте большинства остаётся прежним. Автор разбора формулирует это прямо:
«Отказавший контроллер не выбывает из кворума автоматически — его нужно явно убрать, чтобы вернуть кворуму стабильную и осознанно сконфигурированную структуру»,
Prakash Nagaraj, Hands-On KRaft (Part 4), 2 февраля 2026
Кластер из пяти контроллеров, в котором две ноды вышли из строя месяц назад, но их никто не удалил, не переживет больше ни одного отказа. А мониторинг при этом показывает три здоровых контроллера.
Теперь вернёмся к зелёным подам из вступления — это как раз тот слой, который добавляет оператор, а не Kafka. Readiness-проба контроллера в Strimzi сводится к проверке слушающего порта, netstat -lnt | grep ':9090.*LISTEN'. Порт открыт — под готов. Проба не спрашивает, участвует ли этот контроллер в кворуме и применяет ли записи метаданных. Поэтому получается, что все зелёное, но ничего при этом не работает. Диагностически это можно понять по appliedOffset: 0 в quorum-state при выставленном leaderId.
Причина описанного во вступлении инцидента — гонка публикации A-записей в CoreDNS относительно старта подов Kafka.Автор отчёта отмечает, что воспроизводится это нестабильно, а детерминированный сценарий (заблокировать UDP/53 на минуту, потом снять) он предполагает, но не проверял. Вылечили всё ручным роллом: контроллеры по одному, лидер последним.
Контроллер относительно нетребователен к ресурсам. Согласно рекомендациям Apache Kafka, для типичного кластера достаточно 5 ГБ оперативной памяти и 5 ГБ дискового пространства под журнал метаданных. Из этого следует, что экономить на ресурсах контроллеров особого смысла нет, а вот их количество — вопрос, у которого есть точный ответ.
Два порога отказоустойчивости
Чтобы выдержать N одновременных отказов, кворум контроллеров должен состоять из 2N+1 контроллеров. Три контроллера переживают отказ одного, пять — отказ двух. Здесь всё верно, но эта формулировка недостаточная.
Разложим то же самое в общем виде. Для кворума из N voter'ов:
• большинство (число подтверждений на запись) = floor(N/2) + 1
• переносимые отказы при сохранении доступности = N − большинство = floor((N−1)/2)
Voter'ов (N) |
Большинство |
Переносимые отказы (доступность) |
Порог восстановления без потери данных |
1 |
1 |
0 |
0 |
2 |
2 |
0 |
1 |
3 |
2 |
1 |
1 |
4 |
3 |
1 |
2 |
5 |
3 |
2 |
2 |
6 |
4 |
2 |
3 |
7 |
4 |
3 |
3 |
8 |
5 |
3 |
4 |
9 |
5 |
4 |
4 |
Первые три колонки — обычная арифметика Raft, а вот четвёртая самая важная.
На первый взгляд может показаться, что порог всего один: сколько voter'ов можно потерять, ровно столько отказов кластер и выдержит. На практике порогов два, и они отвечают на разные вопросы:
1. Доступность. Сколько voter'ов можно потерять, чтобы кластер продолжал работать сам, без вмешательства.
2. Восстановление без потери данных. Сколько voter'ов можно потерять одновременно, чтобы пересборка кворума из выживших не отбросила ни одной подтверждённой записи.
Для нечётного N порог доступности и порог восстановления без потери данных совпадают. N=5: доступность держится при двух упавших, восстановление без потерь — тоже при двух. Для чётного N расходятся. N=6: кластер остаётся работоспособным, пока упало не больше двух, но восстановиться без потери метаданных можно и после трёх.
Так происходит, потому что запись метаданных считается подтверждённой, когда её приняло большинство. Если мёртвое множество само является большинством, значит запись могла быть подтверждена целиком внутри него, и ни один выживший её никогда не видел. Пересборка кворума из выживших не восстановит то, чего у выживших нет физически. Отсюда порог: потерять можно не больше половины. Для N=6 половина — это ровно 3, они меньше большинства (4), и каждая подтверждённая запись гарантированно дошла хотя бы до одного выжившего. Для N=5 половина — это 2,5, то есть максимум 2, что совпадает с порогом доступности, и зазора между порогами не возникает.
Из той же арифметики следует вывод для планирования кластера. Четыре контроллера интуитивно надёжнее трёх, а на деле оба конфига переносят один отказ, только у N=4 большинство равно трём, и каждая запись метаданных ждёт три подтверждения вместо двух. Вы платите лишним ack за ту же устойчивость. Это худший случай в таблице, и именно поэтому для чистого Raft обычно берут нечётное N.
Когда чётное N всё-таки правильный выбор
Типовые топологии ориентируются не на ноды, а на домены: зоны доступности, ЦОДы, регионы. Для доменов работает своё правило раскладки: в каждом домене отказа D число voter'ов должно быть меньше большинства. Иначе домен способен подтверждать записи внутри себя, ни с кем не советуясь.
«Если какая‑то одна зона отказа сама по себе содержит commit‑большинство, она может подтверждать записи внутри этой зоны. Но если эта зона целиком выйдет из строя, такие записи могут потеряться»,
Источник: KRaft Fault Tolerance for Dynamic-Quorum Clusters, Confluent, 2026
Возьмём два ЦОДа с асимметричной раскладкой 2-3: пять voter'ов, большинство — три. Большой ЦОД сам содержит большинство voter'ов. Лидер подтверждает запись после трёх подтверждений, и все три могут быть внутри большого ЦОДа — меньший про эту запись не знает. Дальше сеть между ЦОДами рвётся: большой продолжает писать, маленький отстаёт. Если после этого большой ЦОД умирает целиком, маленький остаётся единственным выжившим кворумом — но без части подтверждённых записей. Причём если диски большого ЦОДа целы и он просто недоступен, у вас остаётся выбор между ожиданием и доступностью. Если он потерян физически, выбора нет — эти записи потеряны.
Вот как правило раскладывается по типовым топологиям:
Топология |
Voter'ов |
Большинство |
Voter'ов в домене |
Правило |
1 ЦОД, 3 зоны, 1-1-1 |
3 |
2 |
1 |
выполняется |
2 ЦОДа, 3-3 |
6 |
4 |
3 |
выполняется |
2 ЦОДа, 2-3 |
5 |
3 |
3 |
нарушается |
2.5 ЦОДа, 2-2-1 |
5 |
3 |
2 |
выполняется |
3 ЦОДа, 2-2-2 |
6 |
4 |
2 |
выполняется |
3 ЦОДа, 3-3-3 |
9 |
5 |
3 |
формально выполняется, но нарушается на паре ЦОДов |
Отсюда видно, почему для двух ЦОДов рекомендуют именно 3-3, шесть voter'ов, хотя нечётное N выглядит экономнее: три voter'а в ЦОДе меньше большинства из четырёх, и каждая запись обязана пересечь границу ЦОДов.
Про последнюю строку поговорим отдельно. Для одного домена правило соблюдено: 3 voter'а меньше большинства из пяти. Но большинство целиком укладывается в любые два ЦОДа из трёх, потому что 3+2=5. Значит третий ЦОД может штатно отставать, ничего не подтверждая, а одновременная потеря двух ЦОДов попадает ровно в описанную выше дыру. Поэтому для трёх ЦОДов рекомендуют 2-2-2: шесть voter'ов вместо девяти, большинство четыре, и любое большинство обязано затронуть минимум два ЦОДа.
Каждая запись метаданных блокируется до подтверждения большинства, а ack гейтит самый медленный из подтверждающих. Выше примерно пяти voter'ов стоит замерить commit latency на своей нагрузке и своих межрегиональных задержках, а не полагаться на принцип «больше — значит надёжнее».
Три сценария потери кворума
Прежде чем что-то запускать, ответьте на один вопрос: сколько voter'ов мертво относительно большинства. От этого ответа зависит не то, как проводить процедуру, а нужна ли она вообще.

Сценарий 1: мёртвых меньше большинства. Кластер работает, восстанавливать нечего. Верните ноды и уберите из набора voter'ов те, что не вернутся. Здесь полезно знать, почему перезапуск voter'а безопасен: его идентичность в кворуме — это пара (node.id, directory.id), где directory.id лежит в meta.properties на томе данных и переживает рестарт, а сам набор voter'ов хранится в записи VotersRecord на логе метаданных, а не в состоянии пода. После перезапуска под возвращается тем же voter'ом. Распространённое мнение о том, что после рестарта надо заново добавлять его в кворум, на самом деле миф.
Сценарий 2: мёртвых ровно половина (только при чётном N). Кворум лидера не выбирает, метаданные не пишутся, но каждая подтверждённая запись есть хотя бы у одного выжившего. Классический пример — два региона по три voter'а, большинство четыре, падение региона оставляет три против четырёх. Кластер недоступен, восстановление возможно с RPO = 0, то есть без потери метаданных.
Сценарий 3: мёртвых больше половины. Процедура та же, гарантий нет. Запись могла существовать только на упавших контроллерах. Дальше развилка, и она не техническая, а управленческая. Если упавший домен может вернуться и его диски целы, лучше подождать его. Если он потерян окончательно или срок возврата неизвестен, поднимайте кластер, понимая, что метаданные, закоммиченные только там, будут потеряны. Вендор в этом месте прямо советует не решать в одиночку, а привлекать поддержку
Двойник по симптомам. Тот самый случай из кейса , когда voter'ы формально живы, а кворум застыл. Это не потеря большинства, и запускать в нём процедуру восстановления — значит ломать работающий кластер. Лечится это всё роллом контроллеров по одному, лидер последним. Двойник отличается по признакам: контроллеры отвечают на сетевом уровне, в quorum-state appliedOffset не двигается, describe --status таймаутит не потому, что лидера нет, а потому что запрос не обрабатывается.
Runbook: пересборка кворума в выжившем домене
Рассмотрим процедуру по сценариям 2 и 3. Логика одна для обоих, отличаются только гарантии по данным. Источник всей информации — документация Confluent for Kubernetes и пошаговый пример в их репозитории.
Команды приведены в варианте для Kubernetes, потому что исходная процедура вендора написана под него. Если у вас Kafka вне K8s, механика та же: вместо аннотации режима обслуживания просто остановленный процесс контроллера, вместо kubectl exec прямой вызов на хосте.
Шаг 0. Снимите снапшот тома каждого выжившего контроллера. До всего остального. Это единственный надёжный способ отката, если пересборка в итоге пойдёт не с той ноды. И заранее, ещё на этапе развёртывания, тома KRaft и Kafka должны быть с reclaimPolicy: Retain — об этом бессмысленно вспоминать в момент аварии.
Шаг 1. Упавший домен обязан оставаться недоступным до конца процедуры. Пересборка создаёт новую временную линию метаданных. Если контроллеры упавшего домена вернутся посреди процесса со своим старым логом и смогут дотянуться друг до друга, они образуют второй, конкурирующий кворум — split-brain, две группы с расходящимися метаданными. Документация формулирует это так:
«Recovery создает новую временную шкалу метаданных. Если контроллеры сбойного региона возвращаются во время выполнения восстановления и могут установить связь друг с другом, они могут сформировать второй, конкурирующий кворум, что приводит к ситуации «раздвоения мозга» (split-brain)… Это поведение заложено в принципах работы KRaft и не является особенностью именно CFK»,
Disaster Recovery for Multi-Region KRaft Clusters, Confluent for Kubernetes 3.3
Обратите внимание на последнюю фразу: это свойство протокола KRaft, а не особенность оператора. Значит cordon, scale to zero, отключение сети — способ любой, но домен должен лежать.
Шаг 2. Припаркуйте уцелевшие контроллеры. Принцип простой: процесс не должен изменять лог метаданных, пока инструмент его читает. В K8s это делается режимом обслуживания — под перезапускается, основной контейнер засыпает перед запуском KRaft-процесса, и в логе появляется маркер готовности. Вне K8s это просто остановленный процесс контроллера при сохранённом каталоге данных.
Инструменту измерения нужен файл .lock в каталоге лога, а припаркованный контроллер его не создаёт, потому что не стартовал. Создайте файл вручную:
LOGDIR=/mnt/data/data0/logs # родитель __cluster_metadata-0, или значение metadata.log.dir [ -f "$LOGDIR/.lock" ] || : > "$LOGDIR/.lock"
Шаг 3. Измерьте позицию каждого выжившего. Нужны две величины: epoch метаданных и log end offset.
for p in kraftcontroller-0 kraftcontroller-1 kraftcontroller-2; do echo "== $p" kubectl exec "$p" -n "$NS" -c kraftcontroller -- bash -c \ '[ -f '"$LOGDIR"'/.lock ] || : > '"$LOGDIR"'/.lock; \ kafka-metadata-recovery reconfig log-length --metadata-log-dir '"$LOGDIR" done
Шаг 4. Выберите seed. Seed — это контроллер, на основе которого пересобирается кворум. Правило: сначала наибольший epoch, и только при равных epoch — наибольший log end offset.
Может показаться, что seed — это нода с самым длинным логом. На практике более длинный лог с меньшим epoch может быть устаревшей ветвью, в которой нет закоммиченных записей, и пересборка из него теряет их безвозвратно:
«Выбор сида ранжируется сначала по эпохе, затем по смещению. Выбор по сырому смещению может привести к тому, что будет взята устаревшая ветка с более низкой эпохой, и в результате будут потеряны уже зафиксированные метаданные», DR: KRaft Quorum Loss Recovery, CFK 3.3
Вывод команды log-length подсказывает контроллер с наибольшим log end offset, не учитывая epoch. Confluent документирует это как известное поведение своего же инструмента, то есть подсказке доверять нельзя, epoch сравнивайте сами. Использование аварийного инструмента без проверки epoch может привести к ошибке. Об этом написано в документации.
Шаг 5. Пересоберите кворум из seed. Операция необратима.
kubectl exec "$SEED" -n "$NS" -c kraftcontroller -- bash -c \ '[ -f '"$LOGDIR"'/.lock ] || : > '"$LOGDIR"'/.lock; \ kafka-metadata-recovery reconfig force-standalone --config '"$CFG"
Запускается строго один раз. Если команда упала, остановитесь и оставьте кластер припаркованным, не перезапускайте наугад: «force-standalone is irreversible. Run it exactly once; on failure, halt and leave the cluster parked». По смыслукоманда делает то же, что kafka-storage.sh format --standalone делает на чистом узле, то есть пишет снапшот с записямиKRaftVersionRecord и VotersRecord, где единственный voter — этот узел.
Шаг 6. Расконсервируйте seed и проверьте, что он поднялся лидером.
kubectl exec "$SEED" -n "$NS" -c kraftcontroller -- kafka-metadata-quorum \ --command-config "$CFG" --bootstrap-controller "$BOOTSTRAP" describe --replication
Ожидаемая картина: один voter, он же Leader, лаг нулевой.
Шаг 7. Верните остальных выживших как обозревателей и добавьте в кворум. У каждого из них лог метаданных теперь принадлежит старой временной линии, поэтому его удаляют (снапшот тома с шага 0 остаётся вашим откатом). Нода поднимается без метаданных, приходит Observer'ом, подтягивается с нового лидера, и только после этого добавляется в набор voter'ов:
for ord in 0 2; do kubectl exec "kraftcontroller-$ord" -n "$NS" -c kraftcontroller -- \ bash -c 'rm -rf '"$LOGDIR"'/__cluster_metadata-0' done # снять режим обслуживания, дать подам подняться, затем: for ord in 0 2; do kubectl exec "kraftcontroller-$ord" -n "$NS" -- kafka-metadata-quorum \ --command-config "$CFG" --bootstrap-controller "$BOOTSTRAP" add-controller done
Порядок здесь не формальность. Апстрим требует, чтобы новый контроллер сначала синхронизировался с активным: репликацию смотрят через describe --replication, и только потом выполняют add-controller. Команда идемпотентна, повторный запуск не ломает набор voter'ов.
А вот надеяться на Auto-join (controller.quorum.auto.join.enable, включён по умолчанию в Confluent Platform 8.2+) не надо, в этой процедуре он не поможет. Промоушен Observer → Voter требует живого кворума, который примет соответствующий RPC. При потерянном большинстве принимать его некому. Auto-join работает после того, как кворум восстановлен, а не вместо восстановления.
По времени у автоматизированного вендорского плагина таймаут на измерение — 5 минут, на пересборку региона — 15 минут, а прогресс пишется в ConfigMap, чтобы прерванное восстановление можно было продолжить повторным запуском. Это границы, заложенные вендором, а не ваш RTO. Измеряйте время на своём стенде.
Фаза |
Что делается |
Порядок величины |
Парковка выживших |
перезапуск подов в режим обслуживания |
десятки секунд |
Измерение позиций |
log-length на каждом контроллере |
секунды на ноду |
Выбор seed |
сравнение epoch, затем offset |
минуты, вручную |
Пересборка из seed |
force-standalone, необратимо |
секунды |
Подъём seed |
старт, проверка лидерства |
десятки секунд |
Возврат остальных |
очистка лога, старт, add-controller |
по минуте на ноду |
Числа в таблице — усреднённые и приведены для иллюстрации, это порядок величины, а не точный замер.
Возврат упавшего домена и судьба данных
После пересборки кластер уже работает, пусть и на уменьшенном кворуме и с уменьшенной устойчивостью. Возврат упавшего домена восстанавливает исходный запас прочности, но торопиться с этим сразу после восстановления кворума не нужно.
Процедура для каждой возвращающейся ноды та же, что для выживших на шаге 7. Запускаете её в режиме обслуживания, старый __cluster_metadata-0 отправляете в backup или удаляете, расконсервируете — нода стартует пустой, подтягивается с восстановленного лидера как Observer, затем add-controller.
В сценарии 2 метаданные, отложенные на возвращающихся подах, заведомо больше не используются, потому что у выжившего домена есть все подтверждённые записи. Но шаги остаются одинаковыми для обоих сценариев сознательно, чтобы у команды был один runbook, а не два похожих. Во время аварии наличие двух почти одинаковых процедур становится дополнительным источником ошибок.
Данные топиков при этом не затрагиваются. Брокеры упавшего домена переподключаются к своим сохранённым томам и возвращаются в ISR обычным механизмом Kafka. Восстановление кворума контроллеров не трогает партиции, не переливает данные и не требует MirrorMaker. Если в вашем плане DR эти два слоя смешаны — это хороший повод их разделить.
Чего нет в апстриме и на чём это воспроизводится
Всё, что описано в runbook, опирается на инструмент kafka-metadata-recovery из Confluent Platform. В чистом Apache Kafka такого примитива нет. У kafka-metadata-quorum.sh в апстриме три операции: describe, add-controller, remove-controller — и ничего похожего на force-standalone.
В апстриме работают операции над живым кворумом, включая деградированный. Разбор с прогоном на чистой Apache Kafka показывает: remove-controller --controller-id --controller-directory-id отрабатывает, новый набор voter'ов фиксируется записью KRaftVoters в логе метаданных, и это видно инструментами:
$ bin/kafka-metadata-quorum.sh --bootstrap-controller localhost:9001 remove-controller \ --controller-id 9005 --controller-directory-id LvhtJEVYuBkniTM3-dk5VA Removed KRaft controller 9005 with directory id LvhtJEVYuBkniTM3-dk5VA $ bin/kafka-dump-log.sh --cluster-metadata-decoder --files 00000000000000036964.log | grep KRaftVoters | offset: 39835 KRaftVoters {"version":0,"voters":[{"voterId":9001,...},{"voterId":9002,...}, {"voterId":9003,...},{"voterId":9004,...}]}
Смена набора voter'ов попадает и в логи контроллеров строкой Latest set of voters is VoterSet(...) at offset 39835. Это лучший способ понять, какой сейчас реальный кворум.
Стоит отдельно развести два разных «unclean recovery», потому что их путают. KIP-1275 вводит инструмент kafka-unclean-recovery.sh, но он про offline-партиции данных: при unclean-выборах лидером становится случайная реплика вне ISR, и если реплику только что добавили и данных на ней нет, потеря выходит крупной. Задача полезная, но к кворуму контроллеров отношения не имеет, да и пока она только обсуждается, но ещё не попала в релиз.
Есть и второе ограничение, о котором лучше знать до того, как вы начнёте примерять runbook на свой контур. Strimzi 0.49.0 использует статические кворумы контроллеров для всех развёртываний, включая новые установки, а существующие кластеры на статическом кворуме обязаны на нём и оставаться. Динамическое членство — это KIP-853, и add-controller с remove-controller доступны только на динамическом кворуме. То есть в популярном K8s-операторе половина команд из этой статьи просто не сработает.
Как проверить, что у вас: посмотрите kraft.version.
$ bin/kafka-features.sh --bootstrap-controller localhost:9093 describe Feature: kraft.version SupportedMinVersion: 0 SupportedMaxVersion: 1 FinalizedVersionLevel: 1 Epoch: 5 Feature: metadata.version SupportedMinVersion: 3.3-IV3 SupportedMaxVersion: 3.9-IV0 FinalizedVersionLevel: 3.9-IV0 Epoch: 5
FinalizedVersionLevel для kraft.version равен 1 или выше — кворум динамический. Ноль или строка отсутствует — статический. Определяется это в момент форматирования: кворум будет динамическим, если controller.quorum.voters незадан и указан один из флагов --standalone, --initial-controllers или --no-initial-controllers.
KIP-853 вышел в Apache Kafka 3.9.0 6 ноября 2024 года. Апгрейд со статического кворума на динамический без пересоздания кластера появился только в 4.1, там же controller.quorum.voters объявлен устаревшим в пользу controller.quorum.bootstrap.servers. Так что кластеры, поднятые на 3.x и обновлённые до 4.x, вполне могут до сих пор жить на статическом кворуме.
Между 4.1 и 4.3 официальная рекомендация по remove-controller развернулась на противоположную. В документации 4.1 советовали сначала выключить удаляемый контроллер, а потом выполнять команду. В 4.3 — наоборот: выполнить команду, чтобы контроллер сначала вышел из кворума, и только потом выключать. Если у вас в runbook записана версия из 4.1, стоит свериться с документацией своей версии.
Ещё одна особенность для мультирегиональных контуров на динамическом кворуме: advertised listeners на контроллерах становятся обязательными, потому что адреса больше не берутся из controller.quorum.voters. При этом объявление их с самого создания кластера триггерит известный баг таймаута регистрации в Confluent Platform — KAFKA-20247. Это специфика CP, а не дефект апстрима, но если вы на CP, проверьте свою версию.
Что проверить до аварии
Восстановление кворума — процедура, которую нельзя изучить в момент, когда она нужна. Confluent прямо рекомендует прогонять её в непродакшн-среде заранее: прогон вскрывает специфику окружения, о которой в документации не написано. Полностью согласен, и по опыту добавлю: чаще всего вскрывается не сама механика, а сопутствующие детали — права на exec в поды, отсутствие .lock, забытый reclaimPolicy, невозможность быстро изолировать упавший домен.
Короткий чек-лист:
• Знать своё N и оба порога. Не «у нас три контроллера», а «у нас N=3, большинство 2, переносим один отказ, восстанавливаемся без потерь после одного».
• Проверить тип кворума. kafka-features.sh describe, поле kraft.version. На статическом кворуме команды членства недоступны.
• Пересчитать реальный набор voter'ов. По логу метаданных, а не по конфигу: мёртвые ноды, которых никто не удалил, продолжают считаться в знаменателе.
• Проверить раскладку по доменам отказа. Для каждого домена voter'ов в нём должно быть меньше большинства.
• Тома с reclaimPolicy: Retain, и снапшоты, которые вы умеете снимать быстро.
• Readiness-проба, которая проверяет участие в кворуме, а не открытый порт.
• Отработать процедуру. Хотя бы один раз, руками, до аварии.
Если Kafka у вас в управляемом сервисе — например, в составе VK Data Platform — часть этих пунктов закрывает платформа: снапшоты томов, раскладка контроллеров по доменам отказа, мониторинг состояния кворума, а не только живости процессов.
Прежде чем трогать кворум, посчитайте, сколько voter'ов мертво относительно большинства. Этот счёт определяет, нужна ли вам процедура вообще, будет ли она без потерь и придётся ли выбирать между доступностью и целостностью метаданных. А если процедура нужна — помните, что seed выбирают по epoch, и что force-standalone запускается один раз.