Patroni 4.0 и quorum-репликация: переводим трёхнодовый кластер на синхронный режим
Patroni 4.0 принёс нативную поддержку quorum-репликации. Разбираем, что это меняет в трёхнодовом кластере с нодами в разных ЦОД и как ведёт себя при split-brain.
Patroni 4.0: поддержка синхронной репликации quorum и переработанный REST API управления кластером
Patroni 4.0 вышел в начале года, и главное изменение там - это не косметика и не рефакторинг API (хотя REST API тоже переработали), а доведённая до ума поддержка quorum-репликации. Для нас это был повод наконец-то перевести один кластер, который давно хотелось сделать по-человечески, с асинхронного режима на синхронный - но без потери возможности пережить отказ одной ноды без вмешательства руками.
Почему до 4.0 это было неудобно
В Patroni до версии 4.0 синхронная репликация работала по принципу «один синхронный стендбай»: лидер ждёт подтверждения от одной конкретной реплики. Если эта реплика падает - Patroni переключается в асинхронный режим, чтобы не положить запись. Это предсказуемо, но означает следующее: в момент, когда вы потеряли синхронную реплику, у вас временно нет гарантий durability до тех пор, пока другая реплика не займёт её место.
При трёх нодах в разных ЦОД этот сценарий неприятен: ЦОД-2 упал, Patroni переключился в асинхрон, в этот момент записи идут только на ЦОД-1, и если сразу за этим падает ЦОД-1 - данные за окно асинхронного режима под вопросом.
Quorum-репликация в PostgreSQL (параметр synchronous_commit = remote_apply или remote_write с synchronous_standby_names = ANY 1 (...)) решает это иначе: лидер ждёт подтверждения от любых N реплик из пула. При трёх нодах и кворуме 1-из-2 реплик - транзакция подтверждена когда хотя бы одна из двух стендбаев ответила. Отказ одной реплики не меняет поведение записи. Но в Patroni до 4.0 управлять этим параметром было неудобно: synchronous_standby_names нужно было прописывать вручную и синхронизировать с тем, что Patroni знает о топологии кластера. Рассинхронизация - отдельный класс инцидентов.
Что даёт Patroni 4.0
В 4.0 параметр synchronous_mode: quorum в конфигурации Patroni - появился он ещё в 3.0, но 4.0 довёл его поведение до стабильного состояния. Когда он включён, Patroni сам управляет synchronous_standby_names в PostgreSQL: добавляет ноды в список по мере их готовности, убирает при отказе, следит за тем, чтобы кворум оставался достижимым. Это то, что раньше приходилось делать руками или через внешние скрипты.
Параметр synchronous_node_count задаёт сколько реплик должны подтвердить транзакцию. При трёх нодах кластера (1 лидер + 2 реплики) и synchronous_node_count: 1 - это классический кворум: любая одна из двух реплик достаточна. При synchronous_node_count: 2 - обе, что избыточно для большинства сценариев.
Практически это выглядит так в конфиге:
bootstrap:
dcs:
synchronous_mode: quorum
synchronous_node_count: 1
После перезапуска Patroni сам выставил в PostgreSQL:
synchronous_standby_names = 'ANY 1 (node2,node3)'
И начал обновлять этот параметр при изменениях топологии - что при тесте отказа мы и проверили.
Три ноды в трёх ЦОД: поведение при split-brain
Наш стенд - три ноды, каждая в отдельном ЦОД, DCS (etcd) - тоже трёхнодовый, по одной ноде на ЦОД. Это классическая схема для geographically distributed кластера.
Сценарий, который нас интересовал: потеря связи между ЦОД-1 (лидер) и ЦОД-2, при сохранении связи ЦОД-1 с ЦОД-3 и связи ЦОД-2 с ЦОД-3. Потенциальный split-brain: ЦОД-1 и ЦОД-2 не видят друг друга, у каждого может быть соблазн считать себя лидером.
Что произошло:
- ЦОД-1 (лидер) видит etcd: два из трёх etcd-нодов (ЦОД-1 и ЦОД-3) доступны, кворум etcd есть. Лидер продолжает работать. Синхронная реплика ЦОД-3 подтверждает транзакции - запись продолжается без деградации.
- ЦОД-2 (реплика) потерял связь с лидером, но видит etcd через ЦОД-3. Patroni на ЦОД-2 видит в etcd что лидер жив и держит lease. Промоута не происходит. ЦОД-2 просто ждёт восстановления репликации.
Фактического split-brain не возникло - и это корректное поведение, etcd как DCS предотвращает его независимо от режима репликации. Quorum-репликация здесь решает другую проблему: что происходит с durability пока ЦОД-2 недоступен.
При асинхронной репликации Patroni переключился бы в полностью асинхронный режим (только ЦОД-3 в пуле, и то без гарантии). При quorum-режиме с synchronous_node_count: 1 - достаточно одной подтверждающей реплики, и ЦОД-3 её обеспечивает. Гарантия durability не пропадает.
Переработанный REST API
Второе крупное изменение в 4.0 - REST API. В предыдущих версиях набор эндпоинтов был неполным: часть операций требовала patronictl, часть можно было сделать напрямую через API, но документация была скудной. В 4.0 API приведён в порядок: добавлены эндпоинты для управления конфигурацией, явный /cluster с состоянием всех нод, улучшены ответы с кодами ошибок.
Для нас это имеет прикладной смысл: мониторинг кластера через Prometheus/Grafana у нас строится на опросе Patroni REST API. Раньше /patroni возвращал состояние локальной ноды, а для агрегированной картины приходилось опрашивать все три ноды отдельно. Теперь /cluster с любой ноды даёт состояние всего кластера - одним запросом.
Небольшое неудобство при апгрейде: несколько эндпоинтов поменяли поведение ответов. У нас был самодельный скрипт для проверки роли ноды перед деплоем - он полагался на конкретный формат JSON из /patroni. Пришлось обновить.
Как обновлялись
Rolling upgrade без остановки кластера: сначала реплики, потом лидер. Patroni поддерживает смешанные версии в кластере на период обновления.
Перед включением synchronous_mode: quorum убедились что все ноды на 4.0 - смешивать старый и новый режим не стоит, поведение непредсказуемо. Включали через patronictl edit-config, не через файл конфига - чтобы изменение применилось сразу через DCS, без рестарта Patroni.
После включения проверили что PostgreSQL получил корректный synchronous_standby_names - это видно через patronictl show-config и через SHOW synchronous_standby_names в psql на лидере. Совпало с ожидаемым.
Итог на сейчас
Кластер работает на quorum-репликации около двух недель. Write-latency немного выросла - синхронный режим это и предполагает, платишь за durability сетевым RTT до ближайшей реплики. На наших нодах внутри одного города это несколько миллисекунд - для DWH-нагрузки приемлемо, не OLTP с тысячами транзакций в секунду.
Главный выигрыш - управляемость. Patroni сам следит за тем чтобы synchronous_standby_names в PostgreSQL соответствовал реальному состоянию кластера. Один конфигурационный параметр вместо ручного сопровождения. Для трёхнодового геораспределённого кластера это перестаёт быть экспериментальной настройкой и становится нормальным эксплуатационным выбором.