Patroni игнорирует правки patroni.yml: почему bootstrap.dcs работает один раз и как вернуть Ansible-роли управление кластером
Короткий ответ: правки параметров PostgreSQL в секции bootstrap.dcs шаблона patroni.yml не действуют на живом кластере — секция читается один раз, при первичной инициализации, дальше конфигурацией управляет DCS (etcd) через patronictl edit-config. Чтобы Ansible-роль снова стала источником правды, я перенёс в локальную секцию postgresql.parameters всё, что не входит в CMDLINE_OPTIONS Patroni: из полусотни параметров шаблона за DCS остаются только полтора десятка — ровно те, что уходят в командную строку, — а для всего остального приоритет у локального конфига. Выкат прошёл без единого рестарта, через SIGHUP.
Кластер тот же, что в истории про conflict with recovery: PostgreSQL 15 под Patroni 3.0.2, конфигурация в etcd, синхронная реплика, pgBackRest в S3, PgBouncer, HAProxy. Тогда я мимоходом обнаружил, что ротация логов, аккуратно прописанная в шаблоне роли, не работала несколько лет и в каталоге тихо лежали десятки гигабайт. Эта статья — про то, что вскрылось, когда я сел разбираться до конца.
Масштаб оказался больше одной ротации. Параметр, который в репозитории значится в часах, на сервере равен секундам. Заявленный гигабайт удержания WAL на деле — 128 МБ. Никакой диверсии: все правки делались добросовестно, просто они физически не могли примениться, и ничто в выводе Ansible об этом не сообщало.
Коротко (TL;DR)
- Решение: из полусотни параметров шаблона старт переживают только полтора десятка
CMDLINE_OPTIONS; всё остальное перенести изbootstrap.dcsв локальную секциюpostgresql.parameters— там локальный файл выигрывает у DCS, и роль становится источником правды обычным прогоном плейбука. - Для остатка: cluster-wide минимум (15 параметров из
CMDLINE_OPTIONS) иначе как через DCS не задать — для него отдельный плейбук с идемпотентнымPATCH /config, намеренно не включённый в обычный прогон роли. - Обязательно: хендлер на
systemctl reload(SIGHUP). Таск, который пишет конфиг и никого не уведомляет, создаёт иллюзию управления. Рестарт не подходит: рестарт Patroni на лидере — это failover. - Грабли:
shared_buffers: 4G— невалидная единица (PostgreSQL принимаетGB, неG). Patroni выбрасывает параметр целиком, сервер работает на старом значении до первого рестарта, после — поднимается со стоковыми 128 МБ. - Грабли:
wal_keep_segmentsудалён в PostgreSQL 13, но Patroni молча пересчитывает его вwal_keep_size(8 × 16 МБ = 128 МБ). Параметр-призрак работает через слой совместимости — ровно до какого-нибудь обновления Patroni. - Диагностика:
SELECT name, setting, source FROM pg_settings— колонкаsourceточно говорит, из какого слоя пришло значение и как его оттуда убрать.
Почему правка patroni.yml ничего не меняет на живом кластере
Секция bootstrap.dcs применяется ровно один раз за жизнь кластера — при первичной инициализации. Документация Patroni в разделе Dynamic Configuration Settings формулирует это без оговорок: «All later changes of bootstrap.dcs will not take any effect! If you want to change them please use either patronictl edit-config or Patroni REST API». После bootstrap конфигурация переезжает в распределённое хранилище — у нас etcd — и живёт там.
То есть правка шаблона на живом кластере — это правка файла, который больше никто не читает. Ansible исправно рапортует changed, файл на сервере действительно меняется, ревью проходит, коммит есть. А поведение базы — прежнее.
За несколько лет так накопилось четыре независимых слоя конфигурации, каждый со своей историей:
| Слой | Что это | Как разошёлся |
|---|---|---|
| git | роль и group_vars | обычные правки в репозитории |
/etc/patroni/patroni.yml на хосте | что реально развёрнуто | роль давно не прогонялась — файл отстал от git |
| DCS в etcd | чем управляется живой кластер | правки руками через patronictl edit-config |
| Фактические значения | что видит PostgreSQL | сверху ALTER SYSTEM и ALTER DATABASE |
Показательная деталь: файл на сервере отставал от git, а DCS, наоборот, опережал его. Кто-то грамотно поправил живой кластер и синхронно обновил репозиторий — но роль с тех пор ни разу не прогонял. Дисциплина у людей была. Не было архитектуры, при которой дисциплина не нужна.
Какие параметры нельзя забрать из DCS
Пятнадцать параметров Patroni передаёт PostgreSQL аргументами командной строки, и для них локальный конфиг не действует в принципе. В исходниках это словарь CMDLINE_OPTIONS в patroni/postgresql/config.py: listen_addresses, port, cluster_name, wal_level, hot_standby, max_connections, max_wal_senders, wal_keep_segments, wal_keep_size, max_prepared_transactions, max_locks_per_transaction, track_commit_timestamp, max_replication_slots, max_worker_processes, wal_log_hints.
Причина уважительная: реплика в любой момент может стать мастером, поэтому такие параметры обязаны совпадать на всех нодах. Документация Patroni говорит про них прямо: «values set either in the local patroni configuration files or via the environment variables take no effect».
Для всех остальных параметров действует обратное правило: локальная конфигурация имеет приоритет над динамической. Это и есть рычаг, которым чинится вся история.
Проверить разделение на живом кластере можно одним запросом — параметры из командной строки видны по источнику:
| |
Как вернуть Ansible-роли статус источника правды
План свёлся к трём ходам: развести параметры по тому, кто физически может ими управлять, и заставить роль уведомлять Patroni о каждой правке.
- Всё, что не входит в
CMDLINE_OPTIONS, — в локальную секциюpostgresql.parameters. Здесь локальный файл выигрывает у DCS, значит роль становится источником правды сама собой: обычный прогон плейбука, без вмешательства в etcd. В нашем случае под это подпали все параметры шаблона, кроме тех полутора десятков, что уходят в командную строку. - В
bootstrap.dcsостаётся только cluster-wide минимум, которым иначе управлять нельзя. Для него — отдельный плейбук с идемпотентнымPATCH /configчерез REST API Patroni. Он намеренно не включён в обычный прогон роли: рутинный деплой не должен трогать живой кластер. - Хендлер на reload. Таск деплоя конфига обязан кого-то уведомлять, иначе файл переписан, а Patroni об этом не знает. Правильный сигнал — SIGHUP (
systemctl reload patroni), не рестарт: рестарт Patroni на лидере означает потерю лидерства и failover на ровном месте.
Бонус от разделения: параметр, зависящий от объёма RAM, теперь разведён по нодам через group_vars — раньше на кластер из разных по размеру машин стояло одно значение на всех, потому что DCS другого и не умеет.
Выкатывал я в порядке «сначала реплика, потом мастер» — и этот порядок окупился на первом же шаге.
Как shared_buffers: 4G превращается в 128 МБ без единой ошибки
Невалидная единица измерения не роняет Patroni и не ломает YAML — она молча выбрасывает параметр целиком. В конфиге лежало shared_buffers: 4G. Выглядит нормально, читается нормально. Но PostgreSQL принимает другие суффиксы — документация по настройке перечисляет их закрытым списком: «Valid memory units are B (bytes), kB (kilobytes), MB (megabytes), GB (gigabytes), and TB (terabytes)». Голое G в списке отсутствует, и валидатор Patroni реагирует так:
WARNING: Removing integer parameter=shared_buffers from the config due to the invalid value=4G
Параметр не «падает в дефолт» — его просто нет в сгенерированном postgresql.conf, и значение подхватывается из включаемого postgresql.base.conf, где лежит стоковое 128MB. Работающий сервер этого не замечает: он держит старое значение в памяти. Ловушка взводится и ждёт следующего рестарта — нода поднимется с 128 МБ кэша вместо 4 ГБ. На реплике это деградация чтения, после failover — деградация продакшена.
Самое коварное: 4G годами лежал в инертной bootstrap.dcs и не вредил никому. Он ожил ровно в тот момент, когда я перенёс параметры в работающую секцию. Отсюда правило, которое я теперь произношу вслух перед каждым таким переносом: перенос параметра из мёртвой секции в живую — это изменение, а не рефакторинг.
Поймал я это на реплике, где цена ошибки нулевая, — одним запросом:
| |
Тот же прогон сразу на лидере оставил бы кластер в состоянии, где следующий рестарт — а он рано или поздно случается сам, без спроса — поднимает прод с кэшем, урезанным в тридцать два раза.
Почему wal_keep_segments из DCS продолжает работать на PostgreSQL 15
Параметр, удалённый из PostgreSQL три мажорные версии назад, работал через слой совместимости Patroni — и это опаснее честной ошибки. В DCS лежал wal_keep_segments: 8. Release notes PostgreSQL 13 описывают его судьбу: «Rename configuration parameter wal_keep_segments to wal_keep_size … It is specified in megabytes, rather than number of files as with the old parameter».
Кластер на PG15, но никакой ошибки нет: Patroni молча пересчитывает старый параметр в новый по формуле 8 × 16 МБ = 128 МБ. В git при этом значился 1 ГБ — и не действовал: wal_keep_size входит в CMDLINE_OPTIONS, файл против DCS здесь бессилен.
Опасность не в самом расхождении, а в его молчаливости. Пока слой совместимости работает, всё выглядит нормально. Исчезнет он при каком-нибудь обновлении Patroni — и удержание WAL изменится без единой правки в конфигах, и никто не свяжет одно с другим.
Как найти, откуда на самом деле пришло значение параметра
Единственный надёжный способ — колонка source в pg_settings: она прямо называет слой и подсказывает, как из него значение убрать.
source | Где лежит | Как убрать |
|---|---|---|
default | нигде не задано | — |
configuration file | postgresql.conf (генерирует Patroni) или postgresql.auto.conf | смотреть sourcefile |
command line | CMDLINE_OPTIONS, только через DCS | patronictl edit-config / REST API |
database | ALTER DATABASE … SET | ALTER DATABASE … RESET |
user | ALTER ROLE … SET | ALTER ROLE … RESET |
Полный аудит ручных наслоений — три команды:
| |
| |
Отдельно про ALTER SYSTEM под Patroni: он невидим и для git, и для самого Patroni, и теряется при пересборке ноды из бэкапа. Из всех способов «быстро поправить прод» этот — самый дорогой в обслуживании.
Аудит DCS принёс ещё один сюрприз: теневую копию pg_hba с ослабленным правилом доступа. Она годами лежала миной на случай, если из роли уберут локальную секцию, — и ушла вместе с мёртвым wal_keep_segments.
Сколько ставить wal_keep_size: считать, а не наследовать
Значение удержания WAL надо выводить из измеренной скорости генерации, причём по часам — среднее за сутки скрывает пики. Старые 128 МБ и «правильный» 1 ГБ из git одинаково взяты с потолка; я решил посчитать.
Если включён архив pgBackRest, дешёвый способ — распределение заливок сегментов по часам из лога PostgreSQL:
| |
Порядок величин, из которого удобно считать: возьмём кластер, который прокачивает под полтораста гигабайт WAL в сутки, — в среднем это около сотни мегабайт в минуту, а на всплесках кратно больше. Чтобы пережить транзиентный обрыв реплики минут в двадцать, достаточно пары гигабайт — столько и уехало в конфиг.
Важная оговорка из документации PostgreSQL: «This sets only the minimum size of segments retained in pg_wal» — это пол удержания, не гарантия. При включённой архивации отставшая реплика доберёт сегменты из архива, а при живом слоте репликации параметр вообще не участвует. В современной конфигурации это третий по значимости слой защиты, раздувать его бессмысленно.
Куда важнее оказался сосед, на которого никто не смотрел: max_slot_wal_keep_size. Та же страница документации: «If max_slot_wal_keep_size is -1 (the default), replication slots may retain an unlimited amount of WAL files». Слоты у нас включены. При таком потоке умершая реплика забивает диск мастера за считанные дни, а переполнение pg_wal — это остановка записи в базу. Вот настоящий риск — в отличие от выбора между 128 МБ и гигабайтом, вокруг которого всё началось.
Две мелкие ловушки Ansible, съевшие полдня
Обе не про Patroni, но обе всплыли в этом же выкате — оставляю здесь, потому что искал я их дольше, чем чинил.
--diff печатает секреты в терминал и в лог. Шаблон рендерит конфиг pgBackRest с ключами S3. Ansible в режиме --diff показывает изменённые строки с тремя строками контекста — в короткие файлы в это окно попадает соседняя строка с секретом, и дальше он оседает в ansible.log, который живёт вечно. Соблазн закрыть всё через no_log: true или diff: false — плохой размен: на прод-выкате diff и есть главный контроль, слепое применение опаснее секрета на экране оператора, у которого и так есть ключи от хранилища. Проблема не в видимости, а в сохранении. Durable-фикс — вынести секреты из шаблонируемого конфига: pgBackRest читает /etc/pgbackrest/conf.d/*.conf, так что ключи уехали в отдельный маленький файл под no_log, а основной конфиг остался полностью диффабельным.
--check ломается на связке shell + register. Классика, которая делает dry-run невозможным:
| |
В check-режиме shell пропускается, stanza_check не получает rc, следующий таск падает на неопределённой переменной. Read-only проверке лечение — одна строка: check_mode: false.
Пошагово
- Снять фактическое состояние:
SELECT name, setting, source, sourcefile FROM pg_settings WHERE source <> 'default', содержимоеpg_db_role_setting,postgresql.auto.confиpatronictl show-config. Четыре слоя — четыре снимка. - Свести расхождения в таблицу git ↔ файл ↔ DCS ↔ факт. У каждого расхождения найти, какой слой прав по смыслу, а не по свежести.
- Разделить параметры: всё, что не в
CMDLINE_OPTIONS, — вpostgresql.parametersроли; cluster-wide минимум — в отдельный плейбук сPATCH /config. - Перед переносом провалидировать каждое значение — единицы измерения, удалённые параметры. Перенос из мёртвой секции в живую — изменение, а не рефакторинг.
- Добавить хендлер
systemctl reload patroniна таск деплоя конфига. Не рестарт. - Прогнать на реплике, проверить
SELECT … FROM pg_settings WHERE pending_restartиsourceожидаемых параметров. Только потом — лидер. - Убрать ручные наслоения:
ALTER SYSTEM— черезALTER SYSTEM RESET,pg_db_role_setting— черезALTER DATABASE/ROLE … RESET, лишнее в DCS — черезpatronictl edit-config. wal_keep_sizeпосчитать от измеренной генерации WAL по часам;max_slot_wal_keep_sizeограничить, если включены слоты.
Итог
Весь выкат лёг через SIGHUP, без единого рестарта. Все параметры теперь приходят из кода: source = configuration file, pg_db_role_setting пуст, postgresql.auto.conf пуст, в DCS — только cluster-wide минимум без параметров-призраков. Ротация логов заработала: на мастере каталог сжался с десятков гигабайт до единиц, на реплике — с гигабайтов до сотен мегабайт.
Из этой истории я унёс три вещи. Ansible-роль, которая пишет файл и никого не уведомляет, не сходится к желаемому состоянию — она создаёт иллюзию управления. Конфигурация, которую можно править из двух мест, будет расходиться — не «может», а будет, вопрос срока; лечится это не дисциплиной, а тем, что второе место убирают или делают производным от первого. И порядок «сначала реплика, потом мастер» — не перестраховка: он поймал ошибку с 4G там, где она стоила ноль, вместо прода с урезанным в 32 раза кэшем.
Первоисточники, к которым я обращался по дороге:
- Patroni — Dynamic Configuration Settings (про однократность
bootstrap.dcs) - Patroni — Patroni configuration (приоритеты слоёв, параметры только для DCS)
- Patroni 3.0.2 —
CMDLINE_OPTIONSв patroni/postgresql/config.py и пересчёт wal_keep_segments → wal_keep_size - PostgreSQL 15 — Setting Parameters, допустимые единицы
- PostgreSQL 15 — wal_keep_size и max_slot_wal_keep_size
- PostgreSQL 13 — Release Notes, переименование wal_keep_segments