Архитектура и KRaft
Эта лекция знакомит с Kafka через сквозной кейс. Дальше через все шесть лекций модуля будем держать одну и ту же сцену: вымышленную сеть кофеен Brew, у которой бэкенд начался как монолит, дорос до пяти сервисов и упёрся в очередь. Если ты backend-разработчик, привыкший к HTTP и SQL, но Kafka видел только в README - это вход.
Brew - кофейня, попавшая в очередь
Brew - 80 кофеен в одном городе. Бэкенд начался как монолит на PostgreSQL и одном воркере. Когда запустили мобильное приложение и партнёрскую программу с курьерами, монолит развалился на пять сервисов:
catalog-service- меню, цены, остатки.order-service- приём заказов.kitchen-service- на стороне каждой кофейни, готовит напитки.courier-service- назначает курьеров и координирует доставку.analytics-service- отчёты руководству.
Сервисы общались по HTTP. Падает кухонный - падает заказ (некому подтвердить готовность). Падает оплата - падает заказ (некому списать деньги). Дежурный по очереди ходил по логам и руками гасил заявки. Поставили RabbitMQ в классической конфигурации (durable queues, competing consumers, без Streams и quorum queues - в 2018-м их ещё не было). Стало мягче. Заказы копились в очереди и ждали, пока кухня поднимется. Каскад прекратился.
Полгода жили спокойно. Потом боли вернулись, но другого характера.
Первое - аналитики попросили выгрузить кликстрим за последние три недели. В классической очереди RabbitMQ перечитать нечего: сообщение прочитано и удалено, истории не остаётся. Ребята предложили дублировать всё в S3 параллельно с записью в очередь. Получилось две системы вместо одной, и чужие баги синхронизации.
Второе - подняли второй инстанс notification-service, чтобы потянуть пиковую нагрузку. RabbitMQ распределил сообщения между ними по схеме competing consumers: каждая копия получала своё подмножество. Для рассылки писем годится. Для локального кеша или для задачи «каждой копии видеть весь поток» (fan-out нескольким независимым подписчикам) - нет.
Третье - очередь забивалась, если потребитель отставал. С default-настройками queue росла в RAM, при перезапуске брокера часть сообщений могла уйти в dead letter.
Четвёртое - подключить нового потребителя означало завести exchange, queue, binding и согласовать схему с командой продьюсера. Технически решаемо. Организационно - bottleneck.
Поняли: Brew нужен журнал событий, который можно перечитать с любого места, дать нескольким независимым потребителям и не согласовывать схему с продьюсером каждый раз. Это и есть Kafka. Дальше - как она устроена.
Сравнение тут с RabbitMQ в классической конфигурации (durable queues, competing consumers). У RabbitMQ Streams (с 3.9) и quorum queues свои сценарии, частично закрывающие пункты выше. Для целей курса фокус на классике, потому что именно с ней чаще сталкиваются команды, мигрирующие на Kafka.
Что такое Kafka
Kafka - это распределённый append-only лог. Слово «лог» сбивает: думают про текстовый файл с ошибками, типа /var/log/syslog. Тут другое.
Лог в Kafka - это упорядоченная по времени записи последовательность сообщений. Близкая аналогия из мира баз - WAL в PostgreSQL. Постгрес перед каждым изменением таблицы пишет запись в Write-Ahead Log: «вот сюда, вот такие байты». WAL append-only, читается строго последовательно, реплицируется на standby. Kafka устроена тем же образом, но с двумя отличиями. Запись доступна для чтения сразу (не нужно ждать восстановления), и её могут читать несколько независимых клиентов параллельно.
Поэтому Kafka работает как очередь, но необычная:
- сообщения не пропадают после прочтения, можно перечитать с любой точки;
- их видят несколько потребителей независимо, один не «забирает» сообщение у другого;
- порядок гарантируется внутри партиции (про партиции в Топиках и партициях);
- хранится столько, сколько настроишь - хоть навсегда, хоть три дня.
Из этого вытекают применения: интеграция микросервисов через события, CDC (Change Data Capture) с баз, сбор аналитики, audit log, очереди задач с возможностью replay. Везде, где «передать поток данных и иметь возможность его перечитать».
Brew будет использовать Kafka именно так. Через все шесть лекций модуля прорастёт следующий словарь:
- топики
brew.orders.v1,brew.payments.v1,brew.kitchen.v1,brew.shipments.v1; - события
OrderPlaced,PaymentReceived,KitchenStarted,OrderReady,OrderDelivered; - retention 30 дней для заказов и платежей, 7 дней для кухни и доставки;
- compliance-данные (годами) уходят в S3, а не в Kafka - это будет разъяснено в Offsets и Retention.
Чем Kafka отличается от RabbitMQ (для тех, у кого есть прошлый опыт)
| Аспект | RabbitMQ classic | Kafka |
|---|---|---|
| Модель | broker распределяет сообщения между потребителями | broker хранит лог, потребитель сам выбирает позицию |
| Что после прочтения | сообщение удаляется (ack) | сообщение остаётся, удаляется по retention |
| Несколько потребителей одной очереди | competing consumers, делят поток | consumer group делит поток; разные group видят весь поток независимо |
| Replay | требует особой настройки или внешнего хранилища | штатно, через смену offset |
| Подключение нового потребителя | заводи queue + binding | подпишись на топик, продьюсер не узнает |
| Throughput | десятки тысяч msg/s на типичном кластере | сотни тысяч и миллионы msg/s |
Это не означает, что Kafka «лучше». Лучше - под свой кейс. Очереди задач со сложным роутингом и приоритетами по-прежнему удобнее в RabbitMQ. Но как только появляется сценарий «надо иметь возможность перечитать историю» или «несколько независимых подписчиков на один поток» - Kafka подходит лучше.
Действующие лица
В Kafka четыре типа участников. Они мелькают во всех остальных лекциях, поэтому имена запомни сразу.
- Брокер - нода, которая хранит данные. Топики и партиции живут на брокерах. Если брокеров два - данные размазываются по двум; если пять - по пяти. На стенде курса их три (
kafka-1,kafka-2,kafka-3). Без брокера хранить нечего. - Контроллер - мозг кластера. Назначает leader партициям, следит за списком ISR (in-sync replicas, см. Репликацию и ISR), реассайнит партиции при падении нод, валидирует изменения схемы топиков. Контроллер один активный на кластер. Без контроллера никто не решит, кто сейчас leader.
- Продьюсер - клиент, который пишет. Это твой Go-код с
kgo.Client.Produce(...). Продьюсер выбирает топик, ключ, payload и отправляет в брокер. Без продьюсера лог пустой. - Консьюмер - клиент, который читает. Тоже Go-код, чаще через consumer-группу. Без консьюмера лог никем не прочитан, что для Kafka нормально - данные могут полежать.
Брокер и контроллер живут на сервере (в стенде - в docker compose). Продьюсер и консьюмер - в твоём Go-приложении. Один процесс может быть и продьюсером, и консьюмером одновременно (классический паттерн для consume-process-produce - см. Consume-Process-Produce).
Зачем кластер из трёх нод
Brew мог бы поднять одну ноду Kafka и закрыть вопрос. Не закроет.
Один брокер - это одна точка отказа. Брокер упал - кластер недоступен. Это не Kafka-специфика, это любой single-instance backend.
Два брокера - хуже одного. При разрыве сети между ними обе ноды считают живой себя и думают, что вторая мертва. Это split brain: каждая половина продолжает принимать запись, потом пытаешься их склеить, история разъехалась, неделю разбираешь конфликты.
Три брокера - кворум. Большинство (2 из 3) согласует решение. При падении одной ноды двое оставшихся продолжают работать, контроллер переизбирается за секунды, никто не замечает (если повезёт). Это базовая arithmetic консенсуса: чтобы пережить N сбоев, нужно 2N + 1 нод. Для одного сбоя - три. Для двух - пять.
Brew выбрал три. Деньги на пять не нашлись, мириться с downtime от падения одной ноды никто не хотел.
KRaft - метаданные внутри Kafka
До 2021 года Kafka не умела жить без ZooKeeper. ZK - отдельный кластер, который хранил всю метадату Kafka: список топиков, ACL, маппинг брокер → партиция, кто сейчас leader, кто в ISR. Каждый брокер открывал сессию в ZK, через znode-структуру обменивался состоянием с другими, при падении выбирал нового контроллера.
Боли у этой схемы были давно известны:
- два кластера вместо одного (Kafka и ZooKeeper) с двумя failure mode;
- метаданные через znodes плохо масштабировались (потолок где-то на 200к партиций);
- сложный bootstrap (надо сначала поднять ZK, потом Kafka, потом подождать sync);
- лишний навык в команде (DevOps должен уметь обе системы).
KRaft - это Kafka Raft. Метаданные переехали внутрь Kafka в специальный системный топик __cluster_metadata. Этот топик - обычный append-only лог (как все остальные), реплицируемый между нодами через Raft consensus. Ноды, которые участвуют в Raft и голосуют за leader-контроллера, называются voters. Активный leader среди voters - это и есть текущий контроллер кластера.
Что это даёт практически:
- одна система вместо двух (Kafka вместо Kafka + ZK);
- один формат метаданных (топик-лог вместо древа znodes);
- быстрее восстановление после падения контроллера (секунды вместо десятков секунд);
- проще масштабирование на миллионы партиций (лимит ZK снят).
Минус один: пока экосистема догоняет. Часть туториалов и Stack Overflow ответов всё ещё про ZooKeeper. KRaft объявлен production-ready в Kafka 3.3 (KIP-833, октябрь 2022), стал дефолтом начиная с Kafka 4.0. На стенде курса 4.2.0, ZooKeeper даже не упоминается.
Raft на пальцах (1 минута)
Если совсем не сталкивался: Raft - это алгоритм консенсуса. Несколько нод договариваются о порядке записей в лог так, чтобы при отказе меньшинства оставшееся большинство продолжало работать.
Раз в какое-то время voters проводят выборы. Один из них становится leader, остальные - followers. Любая запись в __cluster_metadata идёт через leader: он принимает запрос, реплицирует запись на followers, дожидается подтверждения от большинства, отвечает клиенту «записано». Если leader упал - оставшиеся voters запускают выборы заново, выбирают нового и продолжают.
Что важно для понимания KRaft: leader Raft и controller кластера - это одна и та же нода в данный момент. Когда говорим «упал контроллер» в KRaft-эпоху - значит упал leader Raft, идут перевыборы.
Combined vs dedicated mode
Voters могут жить двумя способами.
В combined mode каждая нода и брокер (хранит партиции пользовательских топиков), и потенциальный controller (участвует в Raft по __cluster_metadata). Минимум железа, годится для маленьких кластеров и стендов. У стенда курса именно этот режим.
В dedicated mode voter-ы и брокеры разнесены: 3-5 отдельных нод-controller'ов крутят только Raft, остальные ноды - чистые брокеры без участия в выборах. Так делают в проде с десятками брокеров, потому что нагрузка на controller изолируется от пользовательского трафика и можно масштабировать отдельно.
Топология стенда
Стенд курса собран в combined mode. Три ноды, каждая одновременно брокер и voter.
хост (твой Mac/Linux)
│
┌───────────────────┼───────────────────┐
│ │ │
localhost:19092 localhost:19093 localhost:19094
│ │ │
┌──────┴───────┐ ┌──────┴───────┐ ┌──────┴───────┐
│ kafka-1 │ │ kafka-2 │ │ kafka-3 │
│ broker + ctl │ │ broker + ctl │ │ broker + ctl │
│ node-id 1 │ │ node-id 2 │ │ node-id 3 │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└───────────────────┼───────────────────┘
│
Raft over :9093
(controller listener)
│
выбирают активного контроллера
реплицируют __cluster_metadataУ каждого брокера три listener'а на разных портах. Это бывает страшно с непривычки, поэтому разложу по слоям. EXTERNAL listener (:9094 внутри контейнера, замаплен на 19092/19093/19094 на хост) - вход для клиентов с твоей машины: сюда стучится kgo.Client из Go-кода в каждой лекции. INTERNAL listener (:9092) держит брокер-к-брокер трафик внутри docker-сети, реплика партиций гоняется именно тут (наружу её не светим). CONTROLLER listener (:9093) - это уже Raft: voters обмениваются голосами и реплицируют __cluster_metadata, клиенту туда ходить незачем.
ClusterID фиксированный (5nnS6DRtQnKwoMjkkVxxug), задан в docker-compose.yml. Это нужно, чтобы стенд переживал docker compose down без потери identity: при следующем подъёме брокеры узнают друг друга и не пересоздают метадату с нуля.
Min ISR = 2, default replication factor = 3. Это значит: данные есть на трёх нодах, и при записи нужно подтверждение от двух. Если упадёт одна - не заметишь. Упадут две - продьюсер с acks=all начнёт получать NotEnoughReplicas. Подробности про эти настройки в Acks & Durability и в Transactions & EOS.
Что лежит в __cluster_metadata
Хочется один раз увидеть глазами, чтобы ушёл страх. Топик скрытый (system topic), но он реальный лог на диске - можно дампнуть.
Внутри записи о топиках, партициях, конфигах, ACL, изменениях членства voters. Каждый брокер при старте тянет лог с beginning, восстанавливает локальный snapshot метаданных, дальше следит за tail-ом и применяет апдейты по мере появления. Контроллер пишет туда любые изменения через свой Raft слой - так все ноды видят одинаковую картину мира.
Дамп выглядит примерно так:
docker exec kafka-1 /opt/kafka/bin/kafka-dump-log.sh \
--cluster-metadata-decoder \
--files /var/lib/kafka/data/__cluster_metadata-0/00000000000000000000.log | head -50Будут записи вида RegisterBrokerRecord, TopicRecord, PartitionRecord, ConfigRecord и так далее. Не разбирайся в формате - запомни, что это обычный лог с типизированными записями. Та же модель данных, что и у Brew-топиков с заказами, просто служебная.
Программа quorum-status
Brew поднял стенд. Как убедиться, что кластер жив, контроллер выбран, все voters в строю?
Можно через CLI (kafka-metadata-quorum.sh ... describe --status внутри контейнера). Можно из Go - что мы и делаем в cmd/quorum-status/main.go. Программа выводит ClusterID, число брокеров, активного controller-leader Raft и список voters в виде таблицы.
Под капотом два запроса. Здесь зарыта одна ловушка, на которую регулярно напарываются.
Запрос первый - BrokerMetadata через kadm
admin := kadm.NewClient(cl)
md, err := admin.BrokerMetadata(rpcCtx)
// md.Cluster - ClusterID кластера (тот же UUID, что и в docker-compose.yml)
// md.Controller - id брокера-прокси для controller-запросов (НЕ Raft-leader)
// md.Brokers - []BrokerDetail с NodeID/Host/Port/RackЭтот запрос идёт через высокоуровневый kadm.Client (admin-обёртка franz-go) и возвращает общую метадату кластера. Поле Controller тут вернёт id брокера, через которого можно проксировать controller-запросы. В KRaft-мире это не Raft-leader, а просто прокси-координатор. Назвали в выводе программы MetadataControllerProxy, чтобы не сбивать с толку.
Запрос второй - DescribeQuorum через kmsg
Чтобы получить настоящего Raft-leader (то есть текущего активного controller кластера), нужен низкоуровневый запрос DescribeQuorum к топику __cluster_metadata, partition 0. У kadm готовой обёртки пока нет, поэтому собираем руками через kmsg:
req := kmsg.NewPtrDescribeQuorumRequest()
topic := kmsg.NewDescribeQuorumRequestTopic()
topic.Topic = "__cluster_metadata"
part := kmsg.NewDescribeQuorumRequestTopicPartition()
part.Partition = 0
topic.Partitions = []kmsg.DescribeQuorumRequestTopicPartition{part}
req.Topics = []kmsg.DescribeQuorumRequestTopic{topic}
resp, err := req.RequestWith(ctx, cl)
p := resp.Topics[0].Partitions[0]
// p.LeaderID - настоящий Raft-leader (активный controller)
// p.CurrentVoters - список voters: [{ReplicaID:1}, {ReplicaID:2}, {ReplicaID:3}]Это нормальная практика franz-go: высокоуровневый kadm для частого, низкоуровневый kmsg для редкого и специфичного. Обёртки появляются по мере спроса; пока его нет - пишешь как тут.
Ловушка: MetadataControllerProxy ≠ RaftLeader
md.Controller - это представление брокера о том, кто сейчас активный контроллер. В KRaft это значение обновляется через метадата-апдейты от контроллер-кворума: в спокойном состоянии оно совпадает с RaftLeader, но в момент перевыборов может временно расходиться (брокер ещё не получил новый апдейт). Если строить алёрт «жив ли controller» только на этом поле, в окне перевыборов получишь либо устаревший ответ, либо -1.
RaftLeader из DescribeQuorum спрашивается напрямую у контроллер-кворума и показывает текущего лидера на момент запроса. В нашем выводе оба поля показаны явно: на спокойном кластере увидишь одинаковые числа, в момент перевыборов - расхождение. В проде для мониторинга «жив ли controller» используй RaftLeader через DescribeQuorum.
Дальше код просто склеивает два ответа. Брокер с id из LeaderID получает в таблице роль broker + active controller, остальные voters - broker + voter.
Дальше в курсе CLI почти не зовём - всё через franz-go и kadm. Сюда вернёмся в Consumer Groups & Rebalance и в Transactions & EOS, когда понадобится знать, кто сейчас controller, чтобы понять последствия его перевыборов.
Запуск
Стенд должен быть поднят (docker compose up -d из корня репо). Дальше из директории лекции:
make runОжидаемый вывод (id будут другие, RaftLeader - любой из 1/2/3):
ClusterID: 5nnS6DRtQnKwoMjkkVxxug
Brokers: 3
MetadataControllerProxy: 1 (BrokerMetadata.Controller; в KRaft - proxy, не Raft-leader)
RaftLeader: 3 (DescribeQuorum по __cluster_metadata; это активный controller)
CurrentVoters: [1 2 3]
NODE HOST PORT RACK ROLE
1 127.0.0.1 19092 - broker + voter
2 127.0.0.1 19093 - broker + voter
3 127.0.0.1 19094 - broker + active controllerХочется проверить, что Go-вывод не врёт - сравни с CLI-версией:
make quorum-cliЭта цель дёргает kafka-metadata-quorum.sh describe --status внутри контейнера kafka-1 - официальный shell-скрипт из дистрибутива Kafka. Поля разные, но LeaderId из CLI совпадает с RaftLeader из Go-варианта (и CurrentVoters тоже совпадает с нашим списком). Совпало - дальше можно говорить с Kafka из Go без shell.
Что узнал
- Kafka - это распределённый append-only лог. Близкая аналогия из мира баз - WAL PostgreSQL, только с возможностью независимого чтения многими клиентами.
- Brew пришёл в Kafka из мира HTTP и классического RabbitMQ. Триггеры миграции: replay аналитики, fan-out нескольким независимым подписчикам, рост нагрузки и организационная связность с продьюсером.
- В кластере есть брокеры (хранят данные) и контроллер (раздаёт роли). Продьюсер пишет, консьюмер читает. На стенде три ноды, каждая совмещает функции брокера и voter.
- KRaft - это Kafka без ZooKeeper. Метаданные живут в системном топике
__cluster_metadata, реплицируемом через Raft. Voters голосуют за leader, leader Raft и есть активный controller. - Любую CLI-операцию по метаданным можно повторить из Go через
kadm.Client. Для KRaft-специфичных запросов (например,DescribeQuorum) спускайся на уровеньkmsg. - Поля
MetadataControllerProxyиRaftLeader- это разные вещи. Первое - routing hint, второе - настоящий controller. Не путай их в мониторинге.
В следующей лекции (Топики и партиции) Brew запустит промо «бесплатный кофе по пятницам», получит 8000 заказов в минуту в один топик и упрётся в потолок. Через эту историю разберёмся, что такое партиция, зачем их несколько, как работает partition key и почему число партиций нельзя уменьшать.