Назад к блогу

Как работает etcd: разбор механики от raft до watch

Как работает etcd: разбор механики от raft до watch

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

Что такое etcd и почему это не просто key-value хранилище

etcd — распределённое key-value-хранилище, в котором Kubernetes держит всё своё состояние. Вы кладёте туда манифест — он превращается в запись, которую контроллеры видят одинаково с любой реплики. Обычная key-value база так не умеет или делает это с оговорками.

Что отличает etcd от привычной key-value базы:

  • Консистентность через кворум. Запись считается подтверждённой только тогда, когда её приняло большинство узлов кластера etcd. Если кластер из трёх узлов теряет один, он продолжает работать. Из двух — уже нет: кворум невозможен, и запись блокируется. Это жёстче, чем «запись прошла на одном узле, реплицируем потом».
  • История версий, а не только текущее значение. Каждое изменение ключа получает номер ревизии. Можно запросить состояние на конкретной ревизии и увидеть, что происходило раньше. Для контроллеров Kubernetes это способ восстановить полную картину после сбоя, не полагаясь на собственную память.
  • Watch как основной механизм, а не опрос. Компоненты Kubernetes не спрашивают хранилище «что изменилось?» по таймеру — они подписываются на изменения. Изменение ключа тут же приходит всем подписчикам. Представьте бухгалтерию, где вместо ежеминутного «проверь ящик» секретарь стучит в дверь ровно тогда, когда пришёл документ.
  • Данные на диске, а не только в памяти. Каждый узел etcd пишет журнал на диск перед тем, как подтвердить запись. Перезапуск узла не теряет данные, которые уже прошли кворум.

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

Число узлов etcd тоже не произвольно. Для отказоустойчивости берут нечётное количество — три, пять. Чётное число не даёт выигрыша в кворуме, зато добавляет узел, который может стать лишней точкой отказа. Классическая схема «три узла etcd на разных физических машинах» — рабочая, а «два узла» — нет, потому что потеря одного ломает кворум.

Как raft-консенсус обеспечивает согласованность данных

Запрос на запись приходит на любой узел — но принять его может не любой. Только лидер. Остальные узлы перенаправляют клиента к нему или проксируют операцию. Этот единственный принимающий узел задаёт общий порядок событий и избавляет кластер от ситуации, когда два узла одновременно считают себя правыми.

Raft работает через три состояния узла: лидер, кандидат, последователь. Выборы запускаются по таймауту. Если последователь не получает от лидера сигнал живости — он становится кандидатом, просит голоса и при большинстве голосов получает право записи. Так смена лидера занимает секунды, а не ручные действия администратора.

Каждая запись — это элемент лога. Лидер добавляет команду к своему логу и рассылает её последователям. Узел подтверждает приём, когда записал команду к себе. Как только большинство подтвердило, запись фиксируется (committed) и становится видимой для чтения. Дальше лидер сообщает всем остальным: элемент лога зафиксирован.

Здесь важно, что фиксация определяется кворумом, а не конкретным узлом. Если лидер упадёт после фиксации, новый лидер обязан иметь в своём логе все зафиксированные элементы — иначе он не получил бы большинства голосов. Это гарантия, что подтверждённая запись не исчезнет при смене лидера.

Лог — не только журнал изменений. Он ещё и механизм согласования порядка. Разные узлы видят одну и ту же последовательность команд, применяют их в одном порядке и приходят к одинаковому состоянию. Именно поэтому контроллеры Kubernetes читают согласованные данные с любой реплики: все реплики отражают один и тот же лог.

У Raft есть цена. Каждая запись требует сетевого раунда до большинства узлов. Чем больше узлов, тем выше задержка подтверждения — при этом отказоустойчивость растёт медленнее, чем число машин. Кластер из пяти узлов переживает потерю двух, но кворум для записи теперь три узла, а не два. Этот компромисс между скоростью и запасом прочности заложен в самом алгоритме, а не в настройках etcd.

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

Именно Raft делает etcd не набором независимых машин, а одним логическим хранилищем с предсказуемым поведением при сбоях.

Журнал изменений (WAL) и снимки: как etcd восстанавливается после сбоев

Запись в лог — это ещё не гарантия, что данные выживут после падения узла. Между «лидер добавил команду» и «команда оказалась на диске» есть окно, в котором сбой стирает всё: состояние в памяти исчезает, файл не дописан. Чтобы пережить перезапуск, узел должен фиксировать намерение до того, как менять данные.

Именно это делает журнал предзаписи (write-ahead log, WAL). Узел сначала дописывает операцию в append-only файл и убеждается, что запись на диске. Только после этого он меняет структуры в памяти, применяя команду к состоянию. Порядок строгий: сначала журнал, потом состояние. Если питание пропадёт в момент между этими шагами, после старта узел прочитает файл журнала и повторит все записанные, но не применённые операции. Состояние восстановится, потому что до него добираются повторным проигрыванием, а не восстановлением повреждённых структур.

Формат журнала прост в обмене на скорость. Записи идут одна за другой, в конец файла — без перезаписи середины и без поиска свободного места. Дописывание в конец дешевле произвольной записи, а последовательное чтение при восстановлении идёт быстрее, чем обход разбросанных блоков. Так работают, например, журналы в PostgreSQL и внутренний лог etcd.

Долго журнал в одиночку не живёт. Если состояние меняется часто, файл пухнет: строка за строкой, команда за командой. Тысяча операций в секунду превратит скромный файл в сотни мегабайт за считаные часы. При перезапуске узел вынужден прокрутить все эти записи, чтобы добраться до актуального состояния — а это уже минуты вместо секунд.

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

Восстановление после сбоя тогда сводится к двум действиям. Узел загружает последний снапшот — с того места, где он записан в файле. Затем проигрывает записи журнала, накопившиеся после позиции снимка. Чем чаще снимаются снапшоты, тем короче хвост и быстрее старт. Чем реже — тем меньше нагрузка на диск от самих снимков.

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

Watch-механизм: как etcd уведомляет клиентов об изменениях

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

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

Событие всегда имеет тип: PUT при создании или обновлении ключа, DELETE при его удалении. Для PUT можно запросить предыдущее значение ключа — это удобно для аудита: видно не только новое состояние, но и что было до него. Диапазон подписки задаётся теми же ключами, что и в обычных запросах чтения: можно следить за одним ключом, за префиксом — например, всем поддеревом /services/api/ — или за произвольным интервалом.

Ключевое свойство для реактивных систем — упорядоченность. События по одному ключу приходят в порядке ревизий, и ни одно не теряется между доставкой и переподключением. Сервис-дискавери, конфигурационные агенты, операторы Kubernetes строятся на этом: они не гадают, когда что-то поменялось, а получают уведомление и реагируют. Подробнее о поведении watch при переподключениях и сжатии истории — в документации etcd.

Транзакции и optimistic concurrency: как etcd избегает гонок

Как обновить адрес пода так, чтобы не затереть чужую запись между чтением и записью? Классический GET с последующим PUT оставляет окно: пока вы готовите запись, кто-то другой уже изменил ключ, и ваше значение его перезапишет. etcd закрывает это окно двумя средствами — транзакциями и операцией compare-and-swap (CAS).

Начнём с CAS. Операция CompareAndSwap сравнивает текущее значение ключа с ожидаемым и записывает новое только при совпадении. Если значение изменилось с момента вашего чтения, запись не произойдёт, и клиент узнает об этом по ответу. Схема с проверкой версии: клиент читает ключ, запоминает его, а затем отправляет CAS со старым значением. Совпало — запись прошла; не совпало — кто-то вас опередил, и вы повторяете цикл.

Транзакции в etcd устроены как набор условий и двух ветвей действий. Вы формулируете условие сравнения (например, значение ключа равно ожидаемому или его версия совпадает), а затем описываете, что делать при выполнении условия (success) и что — при невыполнении (failure). Так проверка и запись становятся одним неделимым действием: между ними никто не вклинится.

Такой подход превращает обновление в безопасную операцию над конкурентным состоянием. Вместо «прочитать, подумать, записать» вы описываете правило «запиши, только если состояние всё ещё такое, каким я его видел». Это тот же принцип, что у оптимистичной блокировки: конфликт не блокируется заранее, а обнаруживается в момент записи.

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

Для распределённых задач это даёт готовые примитивы. Лидерство в кластере реализуется через создание ключа-лока только при условии, что его ещё нет: выиграл тот, чья транзакция прошла первой. Освобождение лока и продление аренды ложатся на те же проверки и запись. Счётчики и очереди тоже строятся на CAS: инкремент значения выполняется повторением попытки, пока сравнение не совпадёт.

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

Ограничения и риски etcd: на что обратить внимание при эксплуатации

Кластер etcd любит небольшие объёмы данных и стабильную сеть — именно на этих двух допущениях ломается большинство неудачных внедрений. Начнём с размера. etcd хранит весь набор данных в памяти и рассчитан на метаданные, а не на пользовательский контент. В документации указан жёсткий предел по умолчанию — 2 ГБ на хранилище. Как только вы его перешагнете, сервер переходит в режим только для чтения и начинает возвращать ошибку mvcc: database space exceeded. Запись в кластер останавливается, но чтение продолжается.

Дальше — кворум. etcd использует алгоритм Raft, и для записи нужно согласие большинства узлов. Кластер из трёх узлов переживёт отказ одного, из пяти — двух. Отсюда типичная путаница: чётное число узлов не даёт выигрыша. Кластер из четырёх узлов требует кворум в три голоса — ровно столько же, сколько нужно кластеру из трёх. Если связь между узлами нестабильна дольше, чем election-timeout (по умолчанию 1000 мс), лидер теряет позицию, начинаются выборы, и запись временно недоступна.

Производительность подчиняется похожей логике. Каждая запись должна быть зафиксирована на большинстве дисков — отсюда высокая чувствительность к скорости fsync. На медленном диске или при перегруженном сетевом канале задержки растут нелинейно, а размер очереди запросов быстро упирается в лимит. Частая ошибка — хранить в etcd большие объекты и обновлять их часто: рост числа ревизий приводит к фрагментации и ускоренному заполнению того самого лимита в 2 ГБ.

Практическая развязка проста: держите в etcd только метаданные, периодически запускайте компактизацию и дефрагментацию, а крупные данные выносите в отдельное хранилище. Тогда кворум и задержки остаются предсказуемыми, а кластер не уходит в read-only неожиданно.

Выводы: когда etcd подходит, а когда нет

Три вещи, которые стоит запомнить после знакомства с etcd.

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

Согласованность стоит задержки. Каждая запись проходит голосование через Raft, поэтому латентность записи всегда выше, чем у одиночного узла с локальным диском. Это осознанный компромисс: вы платите миллисекундами ради гарантии, что все узлы видят одно и то же состояние.

Масштаб решается числом узлов, а не их мощностью. Отказоустойчивость кластера определяется кворумом, а не размером отдельной машины. Добавление ресурсов одному узлу не спасёт от потери кворума — только корректное число участников.

Практический совет: перед развёртыванием посчитайте ожидаемый прирост хранилища. Если он близок к пределу по умолчанию, закладывайте автомат очистки старых ревизий (compaction) и мониторинг занятого пространства — иначе однажды запись остановится, а чтение будет работать, маскируя проблему.

Похожее