Kafka CookbookОсновыАрхитектура и KRaft
0 / 42 (0%)

Архитектура и 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 classicKafka
Модельbroker распределяет сообщения между потребителямиbroker хранит лог, потребитель сам выбирает позицию
Что после прочтениясообщение удаляется (ack)сообщение остаётся, удаляется по retention
Несколько потребителей одной очередиcompeting consumers, делят потокconsumer group делит поток; разные group видят весь поток независимо
Replayтребует особой настройки или внешнего хранилищаштатно, через смену offset
Подключение нового потребителязаводи queue + bindingподпишись на топик, продьюсер не узнает
Throughputдесятки тысяч msg/s на типичном кластересотни тысяч и миллионы msg/s

Это не означает, что Kafka «лучше». Лучше - под свой кейс. Очереди задач со сложным роутингом и приоритетами по-прежнему удобнее в RabbitMQ. Но как только появляется сценарий «надо иметь возможность перечитать историю» или «несколько независимых подписчиков на один поток» - Kafka подходит лучше.

Действующие лица

В Kafka четыре типа участников. Они мелькают во всех остальных лекциях, поэтому имена запомни сразу.

  1. Брокер - нода, которая хранит данные. Топики и партиции живут на брокерах. Если брокеров два - данные размазываются по двум; если пять - по пяти. На стенде курса их три (kafka-1, kafka-2, kafka-3). Без брокера хранить нечего.
  2. Контроллер - мозг кластера. Назначает leader партициям, следит за списком ISR (in-sync replicas, см. Репликацию и ISR), реассайнит партиции при падении нод, валидирует изменения схемы топиков. Контроллер один активный на кластер. Без контроллера никто не решит, кто сейчас leader.
  3. Продьюсер - клиент, который пишет. Это твой Go-код с kgo.Client.Produce(...). Продьюсер выбирает топик, ключ, payload и отправляет в брокер. Без продьюсера лог пустой.
  4. Консьюмер - клиент, который читает. Тоже 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.

plaintext
                      хост (твой 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 слой - так все ноды видят одинаковую картину мира.

Дамп выглядит примерно так:

sh
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

go
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:

go
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 из корня репо). Дальше из директории лекции:

sh
make run

Ожидаемый вывод (id будут другие, RaftLeader - любой из 1/2/3):

plaintext
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-версией:

sh
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 и почему число партиций нельзя уменьшать.

·Модуль 01

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Основы / Архитектура и KRaft