Primary умер, и через ~24 секунды другой узел принимает записи: никто не голосует, узлы просто бегут к одному ключу в etcd. Ключ живёт, пока лидер продлевает lease, а узел, потерявший etcd, обязан остановить PostgreSQL раньше, чем lease истечёт, иначе primary окажется два. Реплики, которые Patroni переключает, — те самые, что описаны в статье про потоковую репликацию, а возврат старого лидера держится на том же REDO, что и восстановление после сбоя. Опыт ниже — три узла и три etcd в Docker, база 83 МБ.
Кто решает, что лидер умер
У Patroni нет голосования. Каждый узел раз в loop_wait (10 с) читает из etcd /service/demo/leader. Лидер продлевает lease ключа. Если ключа нет, реплика сначала проверяет, годится ли она: её WAL отстаёт от последней известной позиции primary не больше чем на maximum_lag_on_failover (Ha.is_lagging, по умолчанию 1 048 576 байт), её timeline не меньше кластерной. Годные реплики записывают ключ одной и той же операцией «создать, если нет», и побеждает первая дошедшая (_do_attempt_to_acquire_leader, тег v4.1.5):
try:
return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
create_revision='0' превращает запись в условие «ключа ещё нет». Роль DCS отсюда ясна: etcd не решает, кто лучше, он решает, кто первым записал. Поэтому etcd сам нужен с кворумом (три узла переживают потерю одного), а время жизни ключа задают три числа из динамической конфигурации: ttl 30 с, loop_wait 10 с, retry_timeout 10 с. Документация требует loop_wait + 2 × retry_timeout ≤ ttl: 10 + 2 × 10 = 30, запас нулевой.
Ключ лидера с TTL: kill -9 и отрезанный лидер на одной оси времени
kill -9 новый лидер ждёт истечения lease целиком; отрезанный от etcd лидер останавливает PostgreSQL в +18,4 с, до истечения lease в +30 с, поэтому двух primary нет.Стенд
Образ на основе postgres:18 и пакеты patroni, python3-etcd из подключённого в нём репозитория PGDG (без python3-etcd Patroni отвечает «Can not find suitable configuration of distributed configuration store»). Три etcd и три узла в одной сети:
S=dba-failover-patroni-dcs
mkdir w && cd w
printf 'FROM postgres:18\nRUN apt-get update -qq && apt-get install -y -qq patroni python3-etcd iproute2 procps\nUSER postgres\nENTRYPOINT []\n' > Dockerfile
docker build -t ${S}:local .
docker network create ${S}-net
CL=etcd1=http://${S}-etcd1:2380,etcd2=http://${S}-etcd2:2380,etcd3=http://${S}-etcd3:2380
for i in 1 2 3; do
docker run -d --name ${S}-etcd$i --network ${S}-net --memory 256m quay.io/coreos/etcd:v3.6.5 etcd \
--name etcd$i --data-dir /etcd-data \
--listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://${S}-etcd$i:2379 \
--listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://${S}-etcd$i:2380 \
--initial-cluster $CL --initial-cluster-state new
done
Конфиг узла pg1.yml; для остальных: sed 's/pg1/pg2/g' pg1.yml > pg2.yml и то же для pg3:
scope: demo
name: pg1
restapi: {listen: 0.0.0.0:8008, connect_address: dba-failover-patroni-dcs-pg1:8008}
etcd3: {hosts: 'dba-failover-patroni-dcs-etcd1:2379,dba-failover-patroni-dcs-etcd2:2379,dba-failover-patroni-dcs-etcd3:2379'}
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
postgresql: {use_pg_rewind: true, parameters: {wal_log_hints: 'on'}}
initdb: [data-checksums, encoding: UTF8]
pg_hba: ['host replication repl 0.0.0.0/0 scram-sha-256', 'host all all 0.0.0.0/0 scram-sha-256']
postgresql:
listen: 0.0.0.0:5432
connect_address: dba-failover-patroni-dcs-pg1:5432
data_dir: /var/lib/postgresql/pgdata
bin_dir: /usr/lib/postgresql/18/bin
authentication:
superuser: {username: postgres, password: pg}
replication: {username: repl, password: repl}
rewind: {username: rewind, password: rewind}
Запуск: docker run -d --name ${S}-pg$i --network ${S}-net --memory 1g -v $PWD/pg$i.yml:/patroni.yml:ro ${S}:local patroni /patroni.yml. Через 45 секунд patronictl -c /patroni.yml list:
+ Cluster: demo (7701503083569365024) --+---------+-----------+----+-------------+-----+------------+-----+
| Member | Host | Role | State | TL | Receive LSN | Lag | Replay LSN | Lag |
+--------+------------------------------+---------+-----------+----+-------------+-----+------------+-----+
| pg1 | dba-failover-patroni-dcs-pg1 | Leader | running | 1 | | | | |
| pg2 | dba-failover-patroni-dcs-pg2 | Replica | streaming | 1 | 0/4000060 | 0 | 0/4000060 | 0 |
| pg3 | dba-failover-patroni-dcs-pg3 | Replica | streaming | 1 | 0/4000060 | 0 | 0/4000060 | 0 |
+--------+------------------------------+---------+-----------+----+-------------+-----+------------+-----+
Опыт 1: ключ лидера и kill -9
Что лежит в etcd, пока всё живо:
docker exec ${S}-etcd1 etcdctl get --prefix /service/ --keys-only | grep .
docker exec ${S}-etcd1 etcdctl --write-out=fields get /service/demo/leader | egrep -i 'Key|Value|Lease|Rev'
docker exec ${S}-etcd1 etcdctl lease timetolive $(printf %x 5707927148084689157)
/service/demo/config
/service/demo/initialize
/service/demo/leader
/service/demo/members/pg1
/service/demo/members/pg2
/service/demo/members/pg3
/service/demo/status
"Revision" : 21
"Key" : "/service/demo/leader"
"CreateRevision" : 12
"ModRevision" : 12
"Value" : "pg1"
"Lease" : 5707927148084689157
lease 4f36a17fd1657905 granted with TTL(30s), remaining(21s)
Ключ leader привязан к lease с TTL 30 с; пока pg1 жив, его lease обновляется, и осталось 21 секунда из тридцати. Ключ config хранит те же ttl, loop_wait, retry_timeout и maximum_lag_on_failover, что заданы в bootstrap.dcs. Теперь нагрузка (pgbench -T 25 -c 4) и docker kill -s KILL лидера. Время убийства записано до команды, строки patronictl list на pg2 через 3, 12 и 38 секунд (с точностью до задержки docker exec):
kill at: 00:06:29.957272309
== около +12 с после kill
| pg1 | dba-failover-patroni-dcs-pg1 | Leader | running | 1 | | | | |
| pg2 | dba-failover-patroni-dcs-pg2 | Replica | running | 1 | 0/8023878 | 0 | 0/8023878 | 0 |
| pg3 | dba-failover-patroni-dcs-pg3 | Replica | running | 1 | 0/8023878 | 0 | 0/8023878 | 0 |
== около +38 с после kill
| pg2 | dba-failover-patroni-dcs-pg2 | Leader | running | 2 | | | | |
| pg3 | dba-failover-patroni-dcs-pg3 | Replica | streaming | 2 | 0/8023A88 | 0 | 0/8023A88 | 0 |
Через 12 секунд мёртвый pg1 всё ещё Leader: ключ жив, пока не истёк lease. Логи pg2 и pg3 с метками времени и последняя строка pg1 до убийства:
pg1 00:06:21,613 INFO: no action. I am (pg1), the leader with the lock
pg3 00:06:53,951 INFO: Could not take out TTL lock
pg3 00:06:53,972 INFO: following new leader after trying and failing to obtain lock
pg2 00:06:53,982 INFO: promoted self to leader by acquiring session lock
pg2 00:06:54,071 INFO: updated leader lock during promote
pg3 00:06:55,397 INFO: primary_timeline=2
pg3 00:06:55,397 INFO: primary: history=1 0/8023878 no recovery target specified
pg1 продлил ключ в 00:06:21,6, значит lease истёк бы в 00:06:51,6. Новый лидер появился в 00:06:53,98: через 2,4 с после номинального истечения и через 24,0 с после kill (00:06:29,96). Оба оставшихся узла пошли за ключом, pg3 проиграл гонку и стал следовать за pg2. Новый timeline — 2: pg3 с позиции 0/8023878 на timeline 1 видит primary_timeline=2 и историю 1 0/8023878 и переходит за pg2.
Опыт 2: возвращение старого лидера и pg_rewind
docker start старого лидера в 00:07:39,09. Patroni на нём не запускает PostgreSQL как primary: видит чужой ключ, в 00:07:39,8 доводит базу до согласованного состояния в однопользовательском режиме, сравнивает историю timeline и запускает pg_rewind:
00:07:39,751 WARNING: Postgresql is not running.
00:07:39,800 INFO: doing crash recovery in a single user mode
00:07:40,275 INFO: Local timeline=1 lsn=0/9000028
00:07:40,289 INFO: primary_timeline=2
00:07:40,289 INFO: primary: history=1 0/8023878 no recovery target specified
00:07:40,337 INFO: running pg_rewind from pg2
00:07:41,614 INFO: pg_rewind exit code=0
00:07:41,614 INFO: stderr=pg_rewind: servers diverged at WAL location 0/8023878 on timeline 1
00:07:41,659 INFO: starting as a secondary
2026-10-28 00:07:42.115 UTC [59] LOG: started streaming WAL from primary at 0/8000000 on timeline 2
Старый лидер локально дошёл до 0/9000028, а новая primary ответвилась от timeline 1 на 0/8023878: позиция его журнала ушла за точку расхождения на 16 631 728 байт, и этот хвост есть только у него. Как pg_rewind находит эту точку, видно в findCommonAncestorTimeline (тег REL_18_6): он идёт по историям обоих кластеров, пока совпадают номер timeline и начало, и берёт конец последней общей:
n = Min(a_nentries, b_nentries);
for (i = 0; i < n; i++)
{
if (a_history[i].tli != b_history[i].tli ||
a_history[i].begin != b_history[i].begin)
break;
}
if (i > 0)
{
i--;
*recptr = MinXLogRecPtr(a_history[i].end, b_history[i].end);
*tliIndex = i;
return;
}
Дальше pg_rewind по документации копирует с новой primary только блоки, изменённые на старом лидере после точки расхождения, а не весь кластер; от docker start до started streaming прошло 3,0 секунды. Он требует контрольные суммы страниц или wal_log_hints: на стенде включены оба (show wal_log_hints и show data_checksums дают on). Файлы .history на кластере теперь хранят цепочку переключений:
$ tail -n +1 pg_wal/00000004.history # узел pg3 в конце всех опытов, timeline 4
1 0/8023878 no recovery target specified
2 0/8039F88 no recovery target specified
3 0/803BF10 no recovery target specified
Опыт 3: отрезанный лидер сам себя останавливает
Убить контейнер просто, опаснее живой лидер без связи с etcd. pg2 (лидер, timeline 2) отключён от сети командой docker network disconnect. Пока он отрезан, каждые 0,5 с на него шёл INSERT в таблицу probe с localhost внутри контейнера:
docker exec -e PGPASSWORD=pg ${S}-pg2 psql -h localhost -U postgres -qc "create table probe(t timestamptz)"
echo "disconnect at: $(date -u +%T.%N)" # 00:08:44.367294312
docker network disconnect ${S}-net ${S}-pg2
# в цикле: insert into probe values (clock_timestamp()) returning 'insert ok'
Результат цикла: insert ok на каждой итерации с 00:08:44 по 00:08:53 (17 вставок), с 00:08:54 psql: error: connection to server at "localhost" ... failed. Лог pg2:
00:08:35,340 INFO: no action. I am (pg2), the leader with the lock
00:08:45,297 INFO: Lock owner: pg2; I am pg2
00:08:53,701 ERROR: Error communicating with DCS
00:08:53,701 INFO: demoting self because DCS is not accessible and I was a leader
00:08:53,701 INFO: Demoting self (offline)
2026-10-28 00:08:53.723 UTC [31] LOG: received fast shutdown request
Лог pg3 при том же событии: 00:09:07,508 INFO: promoted self to leader by acquiring session lock. Решение принимает Ha._handle_dcs_error: если etcd недоступен, а узел был primary, он гасит PostgreSQL (demote('offline')), если не включён failsafe_mode (тогда лидер продолжает, пока видит остальные узлы):
def _handle_dcs_error(self) -> str:
if not self.is_paused() and self.state_handler.is_running():
if self.state_handler.is_primary():
if self.is_failsafe_mode() and self.check_failsafe_topology():
self.set_is_leader(True)
self._failsafe.set_is_active(time.time())
self.watchdog.keepalive()
self._sync_replication_slots(True)
return 'continue to run as a leader because failsafe mode is enabled and all members are accessible'
self._failsafe.set_is_active(0)
logger.info('demoting self because DCS is not accessible and I was a leader')
self.demote('offline')
return 'demoted self because DCS is not accessible and I was a leader'
else:
self._sync_replication_slots(True)
Хронология относительно последнего продления lease (00:08:35,34): отключение в +9,0 с, остановка PostgreSQL на pg2 в +18,4 с, номинальное истечение ключа в +30 с, новый лидер pg3 в +32,2 с. pg2 ушёл из primary на 13,8 с раньше появления нового: двух primary не было, но в течение этих 13,8 секунд не было ни одного. Остановка случилась через 9,3 с после отключения сети. Документация говорит, что сбои DCS короче retry_timeout (здесь 10 с) лидера не демоутят.
Цена асинхронной репликации видна в данных: на pg2 в окне между отключением и остановкой принято 17 вставок, на pg3 в probe их 0. После docker network connect в 00:09:51 pg2 вернулся через pg_rewind (servers diverged at WAL location 0/8039F88 on timeline 2, локально 0/803A700 — хвост в 1 912 байт) и в 00:09:55,6 уже стримил с pg3:
$ select pg_is_in_recovery(), (select count(*) from probe) -- pg1, pg2, pg3
t|0
t|0
f|0
Те же 17 подтверждённых клиенту INSERT пропали при откате. Если такая потеря недопустима, Patroni умеет синхронный режим (synchronous_mode); он вне этого опыта. Ещё одна проверка: два из трёх etcd убиты в 00:10:51,05; лидер pg3 потерял кворум DCS, не смог прочитать ключ и в 00:11:13,30 (через 22,2 с) вывел себя из primary тем же сообщением demoting self because DCS is not accessible and I was a leader.
Что из этого следует
- Время failover ограничено
ttl. Новый лидер появляется черезttlпосле последнего продления ключа плюс около двух секунд: в опыте 32,2–32,4 с от продления и 24,0 с отkill. Меняяttl, соблюдайloop_wait + 2 × retry_timeout ≤ ttl, иначе лидер не успеет продлить ключ до истечения. - Fencing здесь — это дисциплина лидера, а не принуждение. Узел, потерявший DCS, останавливает PostgreSQL до того, как ключ освободится, но делает это сам Patroni. Если завис он (OOM, остановленный процесс), остановки не будет; в цитате выше рядом стоит
watchdog.keepalive(), и этот механизм в опыте не проверялся. - Асинхронный failover теряет подтверждённые транзакции.
maximum_lag_on_failoverограничивает только отставание кандидата, но не данные, которые до реплики не дошли: в опыте 3 это 17 вставок. - etcd — часть отказоустойчивости. Без кворума DCS здоровый лидер тоже уходит в демоут: нужны три узла etcd и мониторинг их доступности отдельно от PostgreSQL.
pg_rewindнужны условия, заданные заранее.wal_log_hintsили контрольные суммы страниц, пользовательrewindи сеть до новой primary. Без нихpg_rewindне запустится.
Чек-лист
- Запиши
patronictl show-configи проверьloop_wait + 2 × retry_timeout ≤ ttl; сравниmaximum_lag_on_failoverс объёмом WAL в секунду на своей нагрузке. - В etcd найди ключ
/service/<scope>/leader, его lease и оставшееся время: убедись, что значение совпадает сpatronictl list. - Проверь, что
wal_log_hints = onили включены контрольные суммы, а пользовательrewindможет подключиться к любому узлу. - Повтори
docker kill -s KILLна лидере своего стенда и измерь время от убийства доpromoted self to leader; сравни сttl. - Убери стенд вместе с данными:
docker rm -f -v ${S}-pg1 ${S}-pg2 ${S}-pg3 ${S}-etcd1 ${S}-etcd2 ${S}-etcd3 && docker network rm ${S}-net && docker rmi ${S}:local.