Отказоустойчивый кластер PostgreSQL на Patroni: установка, испытания, бэкапы и грабли
Один сервер PostgreSQL хорош ровно до того дня, когда гипервизор под ним перезагружается под обновления, а вместе с ним встают все приложения, которые держат на этом сервере свои базы. Эта статья — о том, как из трёх машин собрать кластер PostgreSQL, который переживает потерю любой из них сам, без администратора у консоли: что в нём за части, как они ставятся на Ubuntu, что происходит при отказе, как это проверить, как бэкапить и как потом обслуживать. Отдельно — о граблях, на которые я наступил, пока собирал такой кластер: некоторые из них отнимают часы, если не знать, куда смотреть.
Все имена и адреса в статье условные: домен example.com, узлы db1, db2, db3 с адресами 192.0.2.11–192.0.2.13, плавающий адрес 192.0.2.10. Операционная система — Ubuntu 26.04 LTS, в репозитории которой лежит PostgreSQL 18, Patroni 4.1 и etcd 3.5; все команды из статьи выполняются от root или через sudo.
Из чего состоит кластер
PostgreSQL сам по себе умеет потоковую репликацию: один сервер, мастер (в терминах PostgreSQL — primary), принимает запись, а реплики (standby) получают от него журнал упреждающей записи, WAL, и воспроизводят его у себя, оставаясь копиями мастера с отставанием в миллисекунды. Протокол репликации — это обычное соединение PostgreSQL, по которому вместо SQL-запросов течёт поток WAL-записей: реплика подключается к мастеру ролью с правом REPLICATION, называет позицию, с которой хочет читать, и дальше получает записи по мере их появления, периодически отчитываясь, что записала и применила. Чего PostgreSQL не умеет сам — решать, что мастер умер, назначать нового и переключать на него клиентов. Для этого нужны ещё три части.
клиенты (приложения, psql, бэкапы)
│
▼ 192.0.2.10 (VIP, всегда на лидере)
┌───────────────────────┼──────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ db1 │ │ db2 │ │ db3 │
│ PostgreSQL │◄──WAL──│ PostgreSQL │──WAL──►│ PostgreSQL │
│ реплика │ │ лидер │ │ синх. реп. │
│ Patroni │ │ Patroni │ │ Patroni │
│ vip-manager │ │ vip-manager │ │ vip-manager │
│ pgBackRest │ │ pgBackRest │ │ pgBackRest │
│ etcd ◄──────┼──Raft──┼─► etcd ◄────┼──Raft──┼─► etcd │
└─────────────┘ └──────┬──────┘ └─────────────┘
│ WAL + копии
▼
S3-совместимый бакет
Patroni — демон на каждом узле, который управляет локальным PostgreSQL: сам запускает его, сам пишет postgresql.conf и pg_hba.conf, следит за здоровьем и решает, кто мастер. Решает не в одиночку: лидерство — это ключ с временем жизни в распределённом хранилище, и мастером считается тот, кто успевает его продлевать. Если лидер не продлил ключ, остальные устраивают выборы, и самый свежий из них повышает себя.
etcd — то самое хранилище: небольшая база ключей и значений, которую три экземпляра держат согласованной по протоколу Raft. В Raft у группы есть лидер, он принимает все записи и рассылает их остальным; запись считается совершённой, когда её подтвердило большинство (из трёх — двое). Если лидер пропал, остальные выбирают нового, но только если их по-прежнему большинство. Благодаря этому у кластера не может появиться двух мастеров одновременно: при разделении сети ключ лидера сможет продлевать только та сторона, где осталось двое из трёх, а узел по другую сторону увидит, что потерял ключ, и сам переведёт свой PostgreSQL в режим реплики. Клиенты etcd говорят с ним по gRPC — это вызовы процедур поверх HTTP/2, и в нашем случае поверх TLS.
vip-manager — крошечный демон, который смотрит в etcd на ключ лидера и держит плавающий адрес на том узле, чьё имя в этом ключе. Узел получил лидерство — vip-manager поднял адрес на интерфейсе; потерял — снял. Клиентам достаточно знать один адрес.
pgBackRest — резервное копирование: непрерывно принимает от мастера архив WAL и раз в сутки снимает копию в S3-совместимое хранилище. Репликация защищает от смерти машины, но не от DROP TABLE, который реплики повторят за мастером через миллисекунду; от этого защищает только архив, из которого можно откатиться к любому моменту времени.
Есть и другие способы сложить такой кластер. repmgr обходится без внешнего хранилища, но без него нет и кворума: при разделении сети обе половины могут решить, что мастер — они. pg_auto_failover проще, но его монитор — одиночная точка отказа, и третий узел уходит под монитор, а не под данные. Операторы для Kubernetes вроде CloudNativePG хороши, если база живёт в кластере Kubernetes; у меня она сознательно живёт снаружи. Patroni с etcd — самый распространённый вариант, и его поведение при отказах мне было важнее всего.
Подготовка узлов
Три виртуальные машины на трёх разных гипервизорах, иначе кластер не переживёт то, ради чего строится. На каждой — Ubuntu Server 26.04, 4 ГБ памяти, диск 64 ГБ, в одном сегменте сети: плавающий адрес работает только внутри одного L2-домена, потому что при переезде адреса vip-manager рассылает gratuitous ARP — широковещательный ответ «адрес 192.0.2.10 теперь у такого-то MAC», по которому коммутаторы и соседи обновляют свои таблицы. ARP — протокол, которым узлы в одном сегменте Ethernet узнают MAC-адрес по IP-адресу; через маршрутизатор он не проходит, так что VIP в другой подсети не поднять.
Адреса узлов — статические, и это не вопрос вкуса. Плавающий адрес живёт на том же интерфейсе eth0, что и собственный адрес узла, и любой механизм, который регистрирует адреса узла в DNS сам, рано или поздно припишет VIP к имени узла-лидера. Таких механизмов два: DHCP-сервер, регистрирующий имя клиента по выданной лизе, и sssd на узле, введённом в домен Active Directory, который по умолчанию обновляет запись узла всеми адресами интерфейса. Это первая грабля, и наступать на неё необязательно: у имени db2.example.com в DNS должен быть ровно один адрес, иначе часть клиентов и мониторинг будут ходить на лидера под именем реплики, а после переключения — в никуда. Поэтому адреса задаются в netplan, записи A и PTR для узлов создаются в DNS руками как статические, а динамическую регистрацию у sssd выключаем:
# /etc/netplan/00-static.yaml
network:
version: 2
ethernets:
eth0:
dhcp4: false
dhcp6: false
addresses: [192.0.2.11/24]
routes:
- to: default
via: 192.0.2.1
nameservers:
search: [example.com]
addresses: [192.0.2.2, 192.0.2.3]
# /etc/sssd/sssd.conf, в секции домена
dyndns_update = False
Если в сегменте есть DHCP — протокол, по которому узел при загрузке широковещательно просит у сервера адрес, маску, шлюз и DNS и получает их в аренду на срок, — адреса узлов и плавающий адрес нужно исключить из его области, иначе сервер однажды выдаст один из них кому-то ещё.
Пакеты на всех трёх узлах одинаковые:
apt install postgresql-18 patroni python3-etcd etcd-server etcd-client pgbackrest prometheus-postgres-exporter
systemctl disable --now patroni etcd
pg_dropcluster 18 main --stop
Пакет postgresql-18 при установке создаёт и запускает кластер 18-main — его нужно снести, потому что PostgreSQL будет запускать Patroni, со своим каталогом данных и своим конфигом. Пакет etcd-server тоже запускается сразу после установки, и это вторая грабля, о ней ниже.
Пакет python3-etcd — третья грабля, самая обидная. Без него Patroni стартует и падает с сообщением:
Can not find suitable configuration of distributed configuration store
Available implementations: consul, kubernetes
Пакет patroni в Ubuntu не тянет клиент etcd как зависимость, только рекомендует, и в минимальной установке рекомендации не ставятся. Поддержка etcd появляется в списке реализаций только с установленным python3-etcd.
etcd с TLS
etcd можно запустить и без шифрования: три узла в приватном сегменте, кто туда полезет. Но в том же сегменте часто живут машины, которым доверять не стоит, например раннеры CI, исполняющие чужой код. Открытый etcd без аутентификации позволяет любому в сегменте переписать ключ лидера и устроить кластеру переключение или остановку. Поэтому etcd стоит закрыть TLS с проверкой клиентских сертификатов с обеих сторон: между членами и от клиентов.
TLS здесь обычный: сервер предъявляет сертификат, подписанный нашим удостоверяющим центром, клиент проверяет его по корневому сертификату этого центра и предъявляет свой, который сервер проверяет так же. Поскольку центр свой, срок жизни сертификатов выбираем сами. Для внутреннего кластера разумно выпустить корень на десять лет, а сертификаты узлов на пять: плановое обновление случится один раз за жизнь машин. etcd перечитывает файлы сертификатов при изменении без перезапуска, а Patroni и vip-manager читают клиентский сертификат при старте, и после обновления их нужно перезапустить по одному; перезапуск Patroni не трогает PostgreSQL.
Корень:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 3650 \
-subj "/CN=etcd CA example.com" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-keyout ca.key -out ca.crt
Два -addext здесь не для красоты, и это четвёртая грабля. openssl req -x509 по умолчанию не ставит в сертификат расширение keyUsage. OpenSSL при проверке цепочки это прощает, и curl с таким корнем работает. Но Python, на котором написан Patroni, с версии OpenSSL 3.5 проверяет сертификаты в строгом режиме и отвергает корень без keyUsage:
certificate verify failed: CA cert does not include key usage extension
Patroni при этом пишет в журнал только certificate verify failed и ждёт etcd вечно, а etcdctl и curl с теми же файлами работают, что сбивает с толку. Диагностируется прямым вызовом из Python:
sudo -u postgres python3 -c "
import urllib3
h = urllib3.PoolManager(cert_reqs='CERT_REQUIRED', ca_certs='/etc/patroni/pki/ca.crt',
cert_file='/etc/patroni/pki/client.crt', key_file='/etc/patroni/pki/client.key')
print(h.request('GET', 'https://192.0.2.11:2379/version').data)"
Сертификаты узлов — по одному серверному и одному клиентскому на узел, с именем узла, коротким именем и адресом в SAN и с обоими назначениями, serverAuth и clientAuth:
cat > db1.ext <<EOF
subjectAltName=DNS:db1.example.com,DNS:db1,IP:192.0.2.11,IP:127.0.0.1
extendedKeyUsage=serverAuth,clientAuth
keyUsage=digitalSignature
basicConstraints=CA:FALSE
EOF
for kind in server client; do
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-subj "/CN=db1.example.com" -keyout db1-$kind.key -out db1-$kind.csr
openssl x509 -req -in db1-$kind.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 1825 -extfile db1.ext -out db1-$kind.crt
done
Серверный сертификат с ключом кладём в /etc/etcd/pki/ владельцем etcd, клиентский — в /etc/patroni/pki/ владельцем postgres, потому что Patroni работает от этого пользователя; vip-manager работает от root и читает тот же клиентский сертификат. Приватный ключ корня на узлах не нужен и храниться там не должен.
Конфигурация etcd на Ubuntu — это переменные окружения в /etc/default/etcd, на каждом узле со своим именем и адресом:
ETCD_NAME=db1
ETCD_DATA_DIR=/var/lib/etcd/default
ETCD_LISTEN_PEER_URLS=https://192.0.2.11:2380
ETCD_LISTEN_CLIENT_URLS=https://192.0.2.11:2379,https://127.0.0.1:2379
ETCD_INITIAL_ADVERTISE_PEER_URLS=https://192.0.2.11:2380
ETCD_ADVERTISE_CLIENT_URLS=https://192.0.2.11:2379
ETCD_INITIAL_CLUSTER=db1=https://192.0.2.11:2380,db2=https://192.0.2.12:2380,db3=https://192.0.2.13:2380
ETCD_INITIAL_CLUSTER_STATE=new
ETCD_INITIAL_CLUSTER_TOKEN=db-etcd-example-com
ETCD_CERT_FILE=/etc/etcd/pki/server.crt
ETCD_KEY_FILE=/etc/etcd/pki/server.key
ETCD_TRUSTED_CA_FILE=/etc/etcd/pki/ca.crt
ETCD_CLIENT_CERT_AUTH=true
ETCD_PEER_CERT_FILE=/etc/etcd/pki/server.crt
ETCD_PEER_KEY_FILE=/etc/etcd/pki/server.key
ETCD_PEER_TRUSTED_CA_FILE=/etc/etcd/pki/ca.crt
ETCD_PEER_CLIENT_CERT_AUTH=true
ETCD_AUTO_COMPACTION_RETENTION=1
ETCD_ENABLE_V2=false
Теперь вторая грабля. Пакет etcd-server при установке стартует с настройками по умолчанию и создаёт в /var/lib/etcd/default кластер из одного члена с именем default и адресом http://localhost:2380. Если после этого просто записать конфиг и перезапустить службу, параметры ETCD_INITIAL_* будут проигнорированы: они читаются только при создании каталога данных. В итоге на каждом узле продолжает жить свой одиночный кластер, причём с одинаковым идентификатором члена, потому что он выводится из имени и адреса, а они на всех трёх одинаковые. etcdctl endpoint status по трём адресам покажет три «лидера» с одним ID, и Patroni на разных узлах будут видеть разные хранилища. Лечение — остановить etcd на всех узлах, удалить каталог данных и запустить заново:
systemctl stop etcd && rm -rf /var/lib/etcd/default
systemctl enable --now etcd
При запуске первого узла systemctl start может отвалиться по таймауту: служба типа notify отчитывается о готовности только после того, как кластер собрал кворум, а второй узел ещё не запущен. Это не ошибка: как только стартует второй, первый дособирается и станет active. Проверка:
E="--cacert /etc/etcd/pki/ca.crt --cert /etc/patroni/pki/client.crt --key /etc/patroni/pki/client.key"
etcdctl --endpoints=https://192.0.2.11:2379,https://192.0.2.12:2379,https://192.0.2.13:2379 $E endpoint status -w table
etcdctl --endpoints=https://127.0.0.1:2379 $E member list -w table
В таблице должны быть три разных ID, один IS LEADER и адреса членов https://192.0.2.1x:2380, а не http://localhost:2380. И заодно убедиться, что без клиентского сертификата etcd отказывает: тот же endpoint health без --cert должен вернуть ошибку.
Patroni
Конфигурация Patroni — /etc/patroni/config.yml, на всех узлах одинаковая по смыслу, разная только в имени и адресах. В ней три части: как найти etcd, что делать при первой инициализации (bootstrap) и как запускать локальный PostgreSQL.
scope: db
namespace: /service/
name: db1
restapi:
listen: 192.0.2.11:8008
connect_address: 192.0.2.11:8008
etcd3:
hosts:
- 192.0.2.11:2379
- 192.0.2.12:2379
- 192.0.2.13:2379
protocol: https
cacert: /etc/patroni/pki/ca.crt
cert: /etc/patroni/pki/client.crt
key: /etc/patroni/pki/client.key
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
synchronous_mode: true
synchronous_node_count: 1
postgresql:
use_pg_rewind: true
use_slots: true
parameters:
max_connections: 300
shared_buffers: 1GB
effective_cache_size: 3GB
work_mem: 16MB
maintenance_work_mem: 256MB
wal_level: replica
hot_standby: "on"
wal_log_hints: "on"
max_wal_senders: 10
max_replication_slots: 10
wal_keep_size: 1GB
password_encryption: scram-sha-256
initdb:
- encoding: UTF8
- locale: en_US.UTF-8
- data-checksums
postgresql:
listen: 0.0.0.0:5432
connect_address: 192.0.2.11:5432
data_dir: /var/lib/postgresql/18/patroni
bin_dir: /usr/lib/postgresql/18/bin
pgpass: /var/lib/postgresql/.pgpass-patroni
authentication:
superuser:
username: postgres
password: "..."
replication:
username: replicator
password: "..."
rewind:
username: rewind
password: "..."
parameters:
unix_socket_directories: /var/run/postgresql
pg_hba:
- local all all peer
- host all all 127.0.0.1/32 scram-sha-256
- host replication replicator 192.0.2.0/24 scram-sha-256
- host all all 0.0.0.0/0 scram-sha-256
Несколько слов о том, что здесь важно.
В etcd3.hosts перечислены все три члена, а не 127.0.0.1: если etcd на этом узле остановлен, Patroni продолжит работать через двух других. Я это проверял — об этом ниже.
ttl, loop_wait и retry_timeout задают скорость реакции. Ключ лидера живёт ttl секунд, лидер продлевает его каждые loop_wait секунд. Если лидер умер молча, ключ истечёт через ttl, остальные заметят это на своей следующей итерации, и переключение займёт до ttl + loop_wait, то есть около 40 секунд при значениях по умолчанию. Можно уменьшить, но тогда короткая сетевая заминка между гипервизорами будет вызывать переключение на ровном месте. Для приложений, которые переживают паузу до минуты, умолчания — правильный выбор.
synchronous_mode: true с synchronous_node_count: 1 означает, что одна из двух реплик всегда синхронная: мастер не подтверждает транзакцию клиенту, пока она не записана на этой реплике. При отказе мастера Patroni повысит именно её, и ни одна подтверждённая транзакция не пропадёт. Падение самой синхронной реплики запись не останавливает: Patroni переназначит синхронной вторую, а на время, пока реплик нет вовсе, разрешит мастеру работать без подтверждения (строгий режим synchronous_mode_strict для этого должен быть выключен, по умолчанию так и есть).
use_pg_rewind и роль rewind нужны, чтобы бывший мастер после возвращения мог стать репликой нового: pg_rewind откатывает в его каталоге данных те записи, которых у нового мастера нет, вместо того чтобы переливать всё заново. Для этого же в параметрах wal_log_hints: on.
pg_hba повторяет обычные правила: локально через сокет по peer (пользователь ОС postgres входит ролью postgres без пароля, так работает и сам Patroni, и psql на узле), по сети всем по SCRAM-SHA-256. SCRAM-SHA-256 — это метод парольной аутентификации PostgreSQL, в котором пароль не передаётся ни открыто, ни в виде хеша: обе стороны доказывают знание пароля через обмен случайными значениями и HMAC, а в каталоге хранится только соль с производным от пароля ключом. Раздел bootstrap применяется один раз, когда первый узел инициализирует кластер; потом все эти настройки живут в etcd, и менять их нужно через patronictl edit-config, а не в файле.
Запускаем Patroni сначала на одном узле:
systemctl enable --now patroni
patronictl -c /etc/patroni/config.yml list
Через полминуты в таблице появится лидер со State: running. Файл config.yml содержит пароли, поэтому у него владелец postgres и права 600. Потом запускаем Patroni на остальных двух: каждый найдёт в etcd существующий кластер, снимет с лидера pg_basebackup и подключится репликой; через минуту patronictl list покажет лидера, Sync Standby и Replica, у всех одинаковый таймлайн и нулевое отставание.
Если в первой попытке что-то пошло не так и Patroni успел инициализировать кластер, который хочется пересоздать с нуля, patronictl remove db удаляет кластер из etcd, после чего нужно вычистить каталог данных на узлах.
vip-manager
vip-manager с Ubuntu не ставится из репозитория, deb-пакет берётся из релизов проекта на GitHub. Конфигурация — /etc/default/vip-manager.yml:
interval: 1000
trigger-key: "/service/db/leader"
trigger-value: "db1"
ip: 192.0.2.10
netmask: 24
interface: eth0
hosting-type: basic
dcs-type: etcd
dcs-endpoints:
- https://192.0.2.11:2379
- https://192.0.2.12:2379
- https://192.0.2.13:2379
etcd-ca-file: /etc/patroni/pki/ca.crt
etcd-cert-file: /etc/patroni/pki/client.crt
etcd-key-file: /etc/patroni/pki/client.key
retry-num: 3
retry-after: 250
trigger-key — это ключ лидера Patroni: namespace + scope + /leader. trigger-value на каждом узле своё, имя узла из конфига Patroni. Раз в секунду vip-manager читает ключ и, если значение совпало с его именем, следит, чтобы адрес был поднят на интерфейсе, иначе — чтобы снят. После systemctl enable --now vip-manager на всех трёх узлах адрес появится у лидера:
ip -4 -br addr show eth0
Отдельно о том, как к кластеру подключаются приложения. Плавающий адрес подходит всем клиентам сразу, и с ним ничего в них менять не надо: имя базы в DNS просто указывает на 192.0.2.10. Но у клиентов на libpq и Npgsql есть и свой способ: перечислить все узлы в строке подключения и попросить мастер. Для libpq это host=db1,db2,db3 target_session_attrs=read-write, для Npgsql — Host=db1,db2,db3;Target Session Attributes=ReadWrite. Клиент перебирает узлы и остаётся на том, который отвечает, что он не в режиме восстановления.
Если приложение пользуется LISTEN и NOTIFY, нужно знать, что они работают только на мастере, на репликах LISTEN запрещён, а уведомления не сохраняются и не реплицируются. При переключении мастера соединение, в котором приложение ждёт уведомлений, рвётся: Patroni переводит старый узел в реплику и закрывает его соединения. Приложение должно переподключиться, повторить LISTEN и вычитать из таблицы то, о чём уведомление могло прийти в окно переключения. Обычная схема transactional outbox именно так и устроена: уведомление лишь ускоряет доставку, а источником правды остаётся таблица. И ещё одно: соединение, которое ждёт уведомлений и ничего не шлёт, узнает о молчаливой смерти мастера только по TCP-keepalive, а он у Linux по умолчанию два часа. В Npgsql спасает Keepalive=30 в строке подключения, в libpq — keepalives_idle=30.
Испытания
Собрать кластер — полдела; нужно знать, как он ведёт себя при отказах, до того, как они случатся. Для измерений я написал на одном из узлов пробник: раз в полсекунды подключается к плавающему адресу, вставляет строку с временем и адресом сервера и пишет в журнал, удалось или нет. По журналу видно каждый промежуток, когда запись была невозможна.
#!/bin/bash
while true; do
t=$(date +%s.%N)
if out=$(psql "host=192.0.2.10 dbname=probe user=probe connect_timeout=2" -AtXq \
-c "insert into probe(ts, server) values (now(), inet_server_addr()) returning server" 2>&1)
then echo "$t ok $out"; else echo "$t fail ${out//$'\n'/ }"; fi
sleep 0.5
done
Четыре сценария и что получилось:
| Сценарий | Как вызвать | Пауза записи |
|---|---|---|
| Плановое переключение | patronictl switchover db --leader db1 --candidate db3 --force |
1.6 с |
| Жёсткая смерть мастера | выключить VM лидера на гипервизоре без завершения работы | 33 с |
| Остановка etcd на узле-лидере | systemctl stop etcd на 45 секунд |
0 |
| Перезагрузка реплики | systemctl reboot |
0 |
Плановое переключение — почти незаметно: Patroni останавливает мастер в режиме fast, дожидается, пока кандидат доиграет последние записи WAL, повышает его, ключ лидера меняется, vip-manager переносит адрес. Жёсткая смерть — те самые ttl плюс итерация: синхронная реплика стала мастером, адрес переехал, старый мастер после включения увидел новый таймлайн и через четверть минуты после загрузки сам стал репликой. Остановка etcd на лидере не дала ни одного сбоя, потому что в конфиге перечислены все три члена. Перезагрузка реплики для клиентов не существует.
Из таблицы видно главное: если приложение переживает минуту без базы, этот кластер даёт отказоустойчивость без единого ручного действия. Если нужно быстрее, можно снизить ttl до 20 и loop_wait до 5, но каждая секунда здесь куплена чувствительностью к сетевым задержкам.
Цена синхронной репликации тоже измеряется. На лидере:
sudo -u postgres pgbench -i -s 5 probe
sudo -u postgres pgbench -c 4 -j 2 -T 20 probe
sudo -u postgres env PGOPTIONS="-c synchronous_commit=off" pgbench -c 4 -j 2 -T 20 probe
У меня вышло 11.5 мс на транзакцию в синхронном режиме против 0.76 мс без ожидания реплики. Это не сеть, сеть между гипервизорами даёт доли миллисекунды; это fsync на диске реплики, который на виртуальных машинах стоит дорого. Для веб-приложений с десятками записей в секунду разница незаметна. Если упрётесь, synchronous_commit = remote_write оставит ожидание реплики, но снимет её fsync с критического пути: транзакция будет считаться подтверждённой, когда реплика получила WAL в память, и потеряется только при одновременной гибели обеих машин.
Бэкапы
pgBackRest ставится на все три узла с одинаковым конфигом /etc/pgbackrest/pgbackrest.conf. Репозиторий — в S3-совместимом хранилище: подойдёт любой провайдер с S3 API, то есть с HTTP-интерфейсом, где объекты кладутся и читаются запросами PUT и GET по адресу бакет/ключ с подписью запросов ключом доступа.
[global]
repo1-type=s3
repo1-s3-endpoint=storage.example.com
repo1-s3-region=ru-central1
repo1-s3-bucket=db-cluster-backups
repo1-s3-key=...
repo1-s3-key-secret=...
repo1-path=/pgbackrest
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=...
repo1-retention-full=2
repo1-retention-diff=7
repo1-bundle=y
repo1-block=y
process-max=2
compress-type=zst
log-path=/var/log/pgbackrest
start-fast=y
[db]
pg1-path=/var/lib/postgresql/18/patroni
pg1-port=5432
pg1-socket-path=/var/run/postgresql
Файл содержит ключ и парольную фразу, поэтому владелец postgres, права 640. Шифрование на клиенте защищает содержимое от того, кто получит доступ к бакету; обратная сторона — без фразы бэкапы не прочитать никому, включая вас, так что она обязана лежать в менеджере паролей, а не только в этом файле. Правилам хранения в самом бакете делать нечего: сроки ведёт pgBackRest (retention-*), а сервисному аккаунту для этого нужно право удалять объекты. Единственное правило жизненного цикла, которое стоит завести на бакете, — прерывать незавершённые составные загрузки через день.
Архивация WAL включается не в конфиге PostgreSQL, а через Patroni, потому что конфиг пишет он:
patronictl -c /etc/patroni/config.yml edit-config db --force \
-s "postgresql.parameters.archive_mode=on" \
-s "postgresql.parameters.archive_command=pgbackrest --stanza=db archive-push %p" \
-s "postgresql.parameters.archive_timeout=300" \
-s "postgresql.recovery_conf.restore_command=pgbackrest --stanza=db archive-get %f \"%p\""
patronictl -c /etc/patroni/config.yml restart db --force
archive_mode требует перезапуска, и patronictl restart перезапускает членов по одному. restore_command в recovery_conf достаётся репликам: отставшая реплика, которой уже не хватает WAL на мастере, дотянет недостающее из репозитория. Архивирует только мастер, у реплик archive_command не вызывается. Дальше инициализация репозитория, проверка и первая копия, всё от postgres на мастере:
pgbackrest --stanza=db stanza-create
pgbackrest --stanza=db check
pgbackrest --stanza=db --type=full backup
pgbackrest --stanza=db info
check не только проверяет доступ к бакету, но и заставляет PostgreSQL переключить сегмент WAL и ждёт, пока он появится в архиве: это проверка archive_command целиком.
Ночную копию запускает cron пользователя postgres на всех трёх узлах одним и тем же скриптом, который сам решает, лидер ли он:
#!/bin/bash
set -euo pipefail
if [[ "$(psql -AtXq -c 'select pg_is_in_recovery()')" != "f" ]]; then
exit 0
fi
type=diff
[[ "$(date +%u)" == "7" ]] && type=full
exec pgbackrest --stanza=db --type="$type" --log-level-console=warn backup
Бэкап, который ни разу не восстанавливали, — не бэкап. Проверка восстановления на реплике, в отдельный каталог и на отдельном порту, занимает пару минут:
D=/var/lib/postgresql/18/restore-test
sudo -u postgres pgbackrest --stanza=db --pg1-path=$D restore
sudo -u postgres /usr/lib/postgresql/18/bin/pg_ctl -D $D -w start \
-o "-p 5433 -c archive_mode=off -c listen_addresses=127.0.0.1 -c unix_socket_directories=/tmp"
sudo -u postgres psql -h /tmp -p 5433 -c "select datname from pg_database"
sudo -u postgres /usr/lib/postgresql/18/bin/pg_ctl -D $D -m fast stop
sudo rm -rf $D
И здесь пятая грабля, самая опасная из всех. Восстановленный экземпляр доигрывает WAL из архива до конца и становится самостоятельным мастером на новом таймлайне. В его каталоге лежит postgresql.conf, написанный Patroni, с archive_mode=on и archive_command, который пишет в тот же репозиторий. Если запустить его без -c archive_mode=off, он начнёт складывать в архив WAL своего таймлайна, и когда живой кластер при следующем переключении перейдёт на тот же номер таймлайна, его записи столкнутся с чужими. Проверить, что архив не пострадал, просто: pgbackrest info после теста должен показывать те же границы wal archive min/max, что и до него.
Восстановление боевого кластера на момент времени делается так же, только --type=time --target="2026-10-19 12:00:00+00" и в основной каталог данных одного узла при остановленном на всех узлах Patroni; остальные два потом получают patronictl reinit и переливаются с него.
Обслуживание
Повседневные команды умещаются в короткий список.
patronictl list— кто лидер, кто синхронная реплика, таймлайны и отставание.- Обслуживание узла: реплику можно просто перезагружать; лидера сначала переключить через
patronictl switchover, потом перезагружать. Обновление пакетаpatroniилиvip-manager— обычныйsystemctl restart, PostgreSQL при этом продолжает работать. - Параметры PostgreSQL меняются через
patronictl edit-config, а не в файлах: Patroni перепишет файл при следующем старте. Параметры, требующие рестарта, Patroni помечает вpatronictl listкакpending restart; применяютсяpatronictl restart db <узел>по одному, лидер последним после switchover. - Узел, который отстал или испорчен:
patronictl reinit db <узел>переливает его с лидера заново. - Добавление узла: те же пакеты и сертификаты,
etcdctl member add, новый член вETCD_INITIAL_CLUSTERсETCD_INITIAL_CLUSTER_STATE=existing, конфиг Patroni, и он сам подключится репликой. - Сертификаты etcd: следить за сроком (
openssl x509 -enddate -noout -in /etc/etcd/pki/server.crt), перевыпускать тем же скриптом, что выпускал; etcd подхватит сам, Patroni и vip-manager перезапустить по одному.
Для наблюдения у Patroni есть /metrics на порту REST API с метриками patroni_primary, patroni_sync_standby, patroni_xlog_replay_location и подобными, а prometheus-postgres-exporter на каждом узле даёт обычные метрики PostgreSQL. Алерты, которые стоит завести сразу: лидер без синхронной реплики дольше нескольких минут, отставание реплики, недоступный член etcd, плавающий адрес не отвечает, последний бэкап старше суток.
Грабли одним списком
- VIP в DNS у имени узла. DHCP-сервер и sssd регистрируют адреса узла в DNS сами, а плавающий адрес живёт на том же интерфейсе; узлам нужны статические адреса, статические записи
AиPTRиdyndns_update = Falseв sssd. - etcd из пакета стартует сразу и создаёт одиночный кластер с одинаковым ID на всех узлах; после записи конфига каталог данных надо удалить, иначе
ETCD_INITIAL_*игнорируются. python3-etcdне ставится вместе сpatroni; без него Patroni не видит реализацию etcd и перечисляет только consul и kubernetes.- Корень без
keyUsageпроходит у OpenSSL иcurl, но Python на OpenSSL 3.5 его отвергает; Patroni пишет лишьcertificate verify failedи ждёт etcd вечно. - Восстановленный экземпляр с
archive_mode=onпишет свой таймлайн в общий архив и портит его для живого кластера; запускать только сarchive_mode=off. - Таймаут
systemctl start etcdна первом узле при сборке кластера — не ошибка, служба ждёт кворума. pkill -fпо имени скрипта убивает и SSH-сессию, из которой вы его вызываете, если шаблон встречается в её командной строке; это не про кластер, но стоило мне нескольких минут недоумения.
Итог
Три виртуальные машины, пять служб на каждой и один конфиг на всех. Плановое переключение — две секунды, потеря машины — полминуты, и всё это без участия человека; подтверждённые транзакции не теряются благодаря синхронной реплике, а архив WAL в бакете позволяет откатиться на любой момент. Самое ценное в этой сборке — не инструкции, а испытания: пока не выключишь мастер руками и не посмотришь на журнал пробника, кластер отказоустойчив только на бумаге.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.