Назад к блогу

Как работает etcd: механика Raft от предложение клиента до watch

Как работает etcd: механика Raft от предложение клиента до watch

Статья разбирает внутреннюю механику etcd — от того, как предложение клиента попадает в Raft-лог, до выборов лидера и распространения watch-событий. Это подробный разбор на уровне исходного кода: какие сообщения ходят между узлами, как принимаются решения о кампаниях и почему предложение может быть отброшено. Полезно тем, кто хочет понимать etcd не как чёрный ящик, а как распределённую систему с конкретными правилами и переходами состояний.

etcd хранит состояние в реплицированном логе, и почти всё поведение кластера объясняется тем, как узлы договариваются о содержимом этого лога. Ниже — разбор механики: куда попадает предложение клиента, как узел становится лидером, что лидер делает с разными типами сообщений, как проверяется изменение конфигурации и как последователь догоняет отставший лог.

Куда попадает предложение клиента

Клиент предлагает изменение машины состояний, сериализуя данные в байтовый срез и вызывая метод предложения у узел. Этот вызов передаёт данные в Raft, где они превращаются в сообщение типа MsgProp — предложение. Полезная нагрузка предложения лежит в поле Entries этого сообщения.

Если MsgProp приходит лидеру, тот обрабатывает его в stepLeader. Сначала проверяется, что список записей не пуст: пустой MsgProp вызывает панику. Затем лидер перебирает записи, при необходимости распознаёт изменения конфигурации, добавляет их в свой лог и рассылает последователям. Если предложение в итоге закоммичено, данные появятся в закоммиченных записях с типом raftpb.EntryNormal. Гарантии коммита нет: команду, возможно, придётся предложить повторно после таймаута.

На стороне последователя MsgProp не несёт терм — предложения пересылаются лидеру и считаются локальным сообщением. Если лидер известен и пересылка предложений не отключена, последователь подменяет только поле To, устанавливая его в идентификатор лидера, и отправляет сообщение дальше; остальные поля не трогаются.

Отбрасывается MsgProp с ошибкой ErrProposalDropped в двух случаях, и каждый пишет свою строку в лог. Если у последователя нет известного лидера, пишется no leader at term ...; dropping proposal. Если включён режим запрета пересылки предложений лидеру, пишется not forwarding to leader ...; dropping proposal. Ошибка определена как raft proposal dropped, чтобы предложивший мог быстро узнать об отказе.

Состояния узла и переходы

Узел начинает кампанию. Общей точкой входа для запуска выборов служит внутренняя функция, которая запускает выборы и принимает тип кампании. Она вызывается при получении MsgHup: при включённом preVote передаётся campaignPreElection, иначе campaignElection.

Перед выборами эта функция выполняет три проверки. Если узел уже лидер, MsgHup игнорируется. Если узел непромоутируемый, кампанию начать нельзя. Если есть неприменённые изменения конфигурации, кампания запрещена: hasUnappliedConfChanges проверяет, что applied < committed, и сканирует неприменённые закоммиченные записи в поисках EntryConfChange или EntryConfChangeV2. Только пройдя все три проверки, узел логирует старт выборов и вызывает campaign, который переводит узел в pre-candidate или candidate и рассылает MsgPreVote или MsgVote.

Три типа кампаний различаются фазой выборов и видом сообщения. Предварительная кампания (campaignPreElection) — первая фаза обычных выборов при включённом Config.PreVote: узел становится pre-candidate, рассылается MsgPreVote на следующий терм (r.Term + 1) до увеличения r.Term. Обычная кампания (campaignElection) — вторая фаза при Config.PreVote: узел становится candidate, рассылается MsgVote на текущий терм. Кампания передачи лидерства (campaignTransfer) попадает в ту же ветку, что и обычная, поэтому рассылает MsgVote на текущий терм, но дополнительно кладёт в поле Context строку "CampaignTransfer". Получатель MsgVote или MsgPreVote с более высоким термом проверяет Context на равенство []byte(campaignTransfer) и при совпадении игнорирует проверку аренды, тогда как при обычном голосовании в пределах минимального election timeout от последнего сообщения лидера голос не отдаётся.

Когда кандидат получает ответ на запрос голоса, он записывает голос отправителя и сразу подсчитывает кворум: сколько голосов «за», сколько отказов и каков итог голосования. Итог бывает трёх видов. При победе pre-candidate запускает обычную кампанию, а candidate становится лидером и рассылает пустые записи. При поражении узел становится последователем. Третий исход — промежуточный, когда кворум ещё не набран и не потерян: тогда узел продолжает ждать ответов. Свой голос кандидат учитывает отдельно: себе MsgVote он не шлёт, а отправляет ответное сообщение, которое доставляется обратно после записи голоса на диск.

Обработка ответов о голосовании общая для candidate и pre-candidate, потому что различие между ними только в том, на какой ответ они реагируют. Ожидаемый тип выбирается по текущему состоянию: для pre-candidate это MsgPreVoteResp, иначе MsgVoteResp. При проигрыше голосования используется именно r.Term, а не терм из ответа, потому что MsgPreVoteResp содержит будущий терм pre-candidate, который больше текущего.

Когда узел получает сообщение с термом больше текущего, он сначала проверяет, не является ли это MsgVote или MsgPreVote: если да и при этом действует аренда (checkQuorum включён, лидер известен, electionElapsed < electionTimeout), то без флага campaignTransfer сообщение игнорируется и терм не меняется. Для остальных типов с более высоким термом узел становится последователем: для MsgApp, MsgHeartbeat и MsgSnap — с указанием лидера, для прочих — без лидера. Два типа сообщений никогда не меняют терм: MsgPreVote и MsgPreVoteResp с не-отказом, для которого терм увеличивается только после получения кворума.

Что лидер делает с разными типами сообщений

Пять типов сообщений лидер обрабатывает без обращения к прогрессу отправителя. MsgBeat заставляет лидера разослать heartbeat-сообщения последователям. MsgCheckQuorum запускает проверку активности кворума. MsgProp несёт предложение клиента. MsgForgetLeader на лидере ничего не делает.

При MsgCheckQuorum лидер проверяет активность кворума; если кворум неактивен, он пишет предупреждение и переходит в последователя с тем же термином. После этой проверки, независимо от её результата, лидер помечает всех, кроме себя, как неактивных — это подготовка к следующей проверке кворума, чтобы активность учитывалась только по сообщениям, полученным после текущего момента. Само MsgCheckQuorum лидер отправляет себе в tickHeartbeat, когда electionElapsed >= r.electionTimeout и включён r.checkQuorum.

При MsgProp лидер выполняет три проверки. Пустой список записей вызывает панику. Отсутствие собственного прогресса (узел удалён из конфигурации, пока был лидером) означает отбрасывание предложения с ErrProposalDropped. Незавершённая передача лидерства тоже означает отбрасывание. После этих проверок для каждой записи типа EntryConfChange или EntryConfChangeV2 распаковывается изменение конфигурации и выполняются проверки: есть ли уже не применённое изменение, находится ли конфигурация в совместном состоянии, хочет ли изменение выйти из совместной конфигурации. Если проверка не пройдена и валидация не отключена, запись заменяется на пустую EntryNormal; иначе обновляется индекс ожидающего изменения конфигурации. В конце лидер добавляет записи в лог и рассылает их.

При MsgReadIndex поведение зависит от режима чтения. ReadOnlySafe гарантирует линейризуемость чтения через обмен с кворумом и является значением по умолчанию. ReadOnlyLeaseBased полагается на лидерский лиз (аренду лидерства) и может пострадать от дрейфа часов, поэтому требует включённого CheckQuorum: validate() возвращает ошибку, если ReadOnlyOption == ReadOnlyLeaseBased и CheckQuorum выключен. Если лидер — единственный голосующий участник, он сразу отвечает по закоммиченному индексу. Если лидер ещё не закоммитил ни одной записи в текущем терме, запрос откладывается и позже освобождается после первого коммита в текущем терме. Иначе вызывается sendMsgReadIndexResponse: при ReadOnlySafe запрос регистрируется по закоммиченному индексу, локальный узел сразу подтверждает его, и рассылаются heartbeat'ы; при ReadOnlyLeaseBased ответ отправляется немедленно по закоммиченному индексу.

Подтверждения от последователей обрабатываются в ветке MsgAppResp: подтверждение принимается, затем возвращается список готовых запросов, и по каждому отправляется ответ. Отложенные запросы освобождаются только после первого коммита в текущем терме, и для каждого вызывается sendMsgReadIndexResponse.

Валидация изменения конфигурации

EntryConfChange — это тип записи в raft-логе, которая после фиксации возвращается приложению и должна быть применена к узлу через ApplyConfChange. ConfChangeV2 — расширенный формат изменения конфигурации, который может содержать несколько ConfChangeSingle и поддерживает joint-конфигурацию; пустой ConfChangeV2 получается из nil Data.

Joint-конфигурация] определяется наличием непустого исходящего большинства. LeaveJoint выполняет переход из joint-конфигурации: удаляет исходящую конфигурацию, продвигая входящую как единственного решающего, и переносит отложенных участников в Learners. Обычная (Simple) запись изменения конфигурации не может применяться в joint-состоянии и допускает изменение входящего большинства не более чем на одного участника.

checkInvariants проверяет согласованность конфигурации и карты прогресса: для каждого идентификатора из Voters, Learners и LearnersNext должна существовать запись в карте прогресса. Также проверяется, что каждый отложенный участник присутствует в исходящем большинстве и не помечен как learner, а Learners и Voters не пересекаются и помечены как learner. Для не-joint конфигурации требуется, чтобы исходящее большинство и LearnersNext были nil, а признак автоматического выхода из joint-конфигурации — false. checkAndReturn вызывает checkInvariants и при ошибке сообщает о ней, иначе возвращает входные конфигурацию и карту прогресса без изменений.

DisableConfChangeValidation отключает propose-time проверку изменений конфигурации против текущей активной конфигурации. Эти проверки могут давать ложные срабатывания, так как активная конфигурация может быть не самой последней, и ложные пропуски, так как проверка может не выполняться против фактической конфигурации-предшественника.

Репликация и восстановление последователя

При получении MsgAppResp с флагом Reject лидер сначала вычисляет индекс для следующей попытки. По умолчанию это RejectHint — предлагаемый базовый индекс для следующего append, то есть следующая попытка идёт на RejectHint+1. Если LogTerm > 0, индекс уточняется поиском конфликта по терму, чтобы не пробовать индексы, которые заведомо не совпадут по терму. Затем лидер решает, стоит ли вообще понижать позицию следующей отправки для этого последователя: устаревшие отклонения отбрасываются, а настоящие — превращаются в новую, меньшую позицию, от которой имеет смысл повторить отправку. Если понижение признано нужным, прогресс переводится в состояние probing, и отправляется append.

При обработке неудачного снапшота порядок действий важен: сначала сбрасывается PendingSnapshot, и только потом прогресс переводится в probing. Иначе узел начал бы probing от индекса снапшота, который так и не был применён.

Последователь при получении MsgApp сбрасывает таймер выборов, запоминает отправителя как лидера и обрабатывает записи. Если предыдущий индекс меньше уже зафиксированного, он сразу отвечает с индексом зафиксированного. Если записи успешно добавлены, он отвечает с индексом последней добавленной. При несовпадении лога он отвечает с флагом Reject, подсказкой и термом: подсказка формируется как максимальная пара «индекс-терм» с термом не выше присланного и индексом не выше присланного, начиная с минимума из присланного индекса и последнего индекса лога.

MsgHeartbeat заставляет последователя продвинуть зафиксированный индекс и отправить ответ с тем же контекстом. MsgSnap вызывает восстановление из снимка: если оно прошло, последователь отвечает с индексом последней записи, иначе — с индексом зафиксированного. Восстановление не выполняется, если индекс снимка не больше зафиксированного, если узел не в состоянии последователя, если его идентификатор не найден в состоянии конфигурации, или если узел уже содержит запись с этим индексом и термом — тогда зафиксированный индекс продвигается до индекса снимка.

Что из этого следует на практике

Предложение клиента не гарантирует коммита: команду может потребоваться предложить повторно после таймаута, а последователь может отбросить её с ErrProposalDropped, если потерял лидера или ему запрещена пересылка.

Кампания не начнётся, пока есть неприменённые изменения конфигурации, а непромоутируемый узел не может стать лидером вообще. Предварительное голосование не меняет терм, поэтому узел, оторванный от сети, не сбивает работающего лидера, вернувшись с завышенным термом.

Лидер отбрасывает предложения, если он уже удалён из конфигурации или идёт передача лидерства. Проверка кворума сбрасывает активность всех узлов, кроме лидера, — так следующая проверка учитывает только свежие сообщения.

Выбор режима чтения не свободен: ReadOnlyLeaseBased требует включённого CheckQuorum, иначе конфигурация не пройдёт валидацию. Чтение в этом режиме может пострадать от дрейфа часов, тогда как ReadOnlySafe опирается на обмен с кворумом.

Изменение конфигурации проходит проверки на согласованность карты прогресса, а обычное изменение не может применяться в joint-состоянии и не может изменить входящее большинство больше чем на одного участника. Отключение propose-time валидации убирает проверки, которые и так могут ошибаться в обе стороны.

Где смотреть в коде

Источники

Похожее