BitPage

Patroni игнорирует правки patroni.yml: почему bootstrap.dcs работает один раз и как вернуть Ansible-роли управление кластером

Автор:  ·   · 11 мин чтения

Короткий ответ: правки параметров 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)

Почему правка 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».

Для всех остальных параметров действует обратное правило: локальная конфигурация имеет приоритет над динамической. Это и есть рычаг, которым чинится вся история.

Проверить разделение на живом кластере можно одним запросом — параметры из командной строки видны по источнику:

1
SELECT name, setting, source FROM pg_settings WHERE source = 'command line';

Как вернуть Ansible-роли статус источника правды

План свёлся к трём ходам: развести параметры по тому, кто физически может ими управлять, и заставить роль уведомлять Patroni о каждой правке.

  1. Всё, что не входит в CMDLINE_OPTIONS, — в локальную секцию postgresql.parameters. Здесь локальный файл выигрывает у DCS, значит роль становится источником правды сама собой: обычный прогон плейбука, без вмешательства в etcd. В нашем случае под это подпали все параметры шаблона, кроме тех полутора десятков, что уходят в командную строку.
  2. В bootstrap.dcs остаётся только cluster-wide минимум, которым иначе управлять нельзя. Для него — отдельный плейбук с идемпотентным PATCH /config через REST API Patroni. Он намеренно не включён в обычный прогон роли: рутинный деплой не должен трогать живой кластер.
  3. Хендлер на 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 и не вредил никому. Он ожил ровно в тот момент, когда я перенёс параметры в работающую секцию. Отсюда правило, которое я теперь произношу вслух перед каждым таким переносом: перенос параметра из мёртвой секции в живую — это изменение, а не рефакторинг.

Поймал я это на реплике, где цена ошибки нулевая, — одним запросом:

1
SELECT name, setting, source, pending_restart FROM pg_settings WHERE pending_restart;

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

Почему 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 filepostgresql.conf (генерирует Patroni) или postgresql.auto.confсмотреть sourcefile
command lineCMDLINE_OPTIONS, только через DCSpatronictl edit-config / REST API
databaseALTER DATABASE … SETALTER DATABASE … RESET
userALTER ROLE … SETALTER ROLE … RESET

Полный аудит ручных наслоений — три команды:

1
2
3
SELECT * FROM pg_db_role_setting;                    -- ALTER DATABASE / ALTER ROLE
SELECT name, setting, source, sourcefile
FROM pg_settings WHERE source <> 'default';
1
cat $PGDATA/postgresql.auto.conf                     # ALTER SYSTEM

Отдельно про ALTER SYSTEM под Patroni: он невидим и для git, и для самого Patroni, и теряется при пересборке ноды из бэкапа. Из всех способов «быстро поправить прод» этот — самый дорогой в обслуживании.

Аудит DCS принёс ещё один сюрприз: теневую копию pg_hba с ослабленным правилом доступа. Она годами лежала миной на случай, если из роли уберут локальную секцию, — и ушла вместе с мёртвым wal_keep_segments.

Сколько ставить wal_keep_size: считать, а не наследовать

Значение удержания WAL надо выводить из измеренной скорости генерации, причём по часам — среднее за сутки скрывает пики. Старые 128 МБ и «правильный» 1 ГБ из git одинаково взяты с потолка; я решил посчитать.

Если включён архив pgBackRest, дешёвый способ — распределение заливок сегментов по часам из лога PostgreSQL:

1
grep "pushed WAL file" postgresql-<дата>.log | cut -c12-13 | sort | uniq -c

Порядок величин, из которого удобно считать: возьмём кластер, который прокачивает под полтораста гигабайт 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 невозможным:

1
2
3
4
5
6
7
8
9
- name: Check if stanza exists
  ansible.builtin.shell: pgbackrest --stanza={{ stanza }} info
  register: stanza_check
  changed_when: false
  failed_when: false

- name: Create stanza
  ansible.builtin.shell: pgbackrest --stanza={{ stanza }} stanza-create
  when: stanza_check.rc != 0

В check-режиме shell пропускается, stanza_check не получает rc, следующий таск падает на неопределённой переменной. Read-only проверке лечение — одна строка: check_mode: false.

Пошагово

  1. Снять фактическое состояние: SELECT name, setting, source, sourcefile FROM pg_settings WHERE source <> 'default', содержимое pg_db_role_setting, postgresql.auto.conf и patronictl show-config. Четыре слоя — четыре снимка.
  2. Свести расхождения в таблицу git ↔ файл ↔ DCS ↔ факт. У каждого расхождения найти, какой слой прав по смыслу, а не по свежести.
  3. Разделить параметры: всё, что не в CMDLINE_OPTIONS, — в postgresql.parameters роли; cluster-wide минимум — в отдельный плейбук с PATCH /config.
  4. Перед переносом провалидировать каждое значение — единицы измерения, удалённые параметры. Перенос из мёртвой секции в живую — изменение, а не рефакторинг.
  5. Добавить хендлер systemctl reload patroni на таск деплоя конфига. Не рестарт.
  6. Прогнать на реплике, проверить SELECT … FROM pg_settings WHERE pending_restart и source ожидаемых параметров. Только потом — лидер.
  7. Убрать ручные наслоения: ALTER SYSTEM — через ALTER SYSTEM RESET, pg_db_role_setting — через ALTER DATABASE/ROLE … RESET, лишнее в DCS — через patronictl edit-config.
  8. 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 раза кэшем.

Первоисточники, к которым я обращался по дороге:

#PostgreSQL #Patroni #Ansible #etcd #конфигурация

<< Previous Post

|

Next Post >>