Umbryx — multi-node HA-развёртывание¶
Два (и более) хоста с одним логическим набором данных Umbryx,
автоматическим failover и Postgres, и Redis за одним плавающим VIP.
Это отдельный режим развёртывания от обычного single-node
docker-compose.yml — сначала прочтите шапку-комментарий
docker-compose.ha.yml.
Собрано и проверено под нагрузкой на реальном 2-нодовом деплое (2026-09-07/08): убийство primary-ноды Postgres под живым трафиком — ноль зависших запросов, ~41 с до полного автовосстановления, все данные и зашифрованные поля (секреты учётных данных) целы и читаются с обеих нод. Аналогично Redis: убит master — Sentinel продвинул реплику, обе app-ноды подхватили за ~9 с.
Перепроверено 2026-09-08 через GET /api/system/ha (System → HA
Cluster в UI) в live-режиме: docker kill контейнера Postgres на
primary — ~50 с до доступной на запись новой primary (монитор помечает
unhealthy на +8 с, демоут/продвижение с +20 с, demote_timeout
истекает и standby достигает wait_primary — уже доступна на запись,
VIP уже указывает на неё — на +50 с), app всё это время отвечает 200
через VIP с момента записи на новую primary. Подтверждено и на уровне
SQL: pg_is_in_recovery() = false и inet_server_addr() — выжившая
нода, запрос шёл через VIP.
Известное ограничение: 2 ноды видят проблему, но не всегда могут действовать в одиночку¶
И монитору pg_auto_failover, и Redis Sentinel нужна нода, с которой они ещё могут говорить, чтобы что-то решить. При ровно 2 нодах:
- Монитор (pg_auto_failover) живёт на одной ноде (здесь — A). Если умирает весь хост A (не только её Postgres), у выжившей нет монитора для отчёта — автовыдвижения не будет, какой бы здоровой она ни была. Проверенный в этом документе failover убивает контейнер, а не хост — это сценарий, который реально отрабатывает автоматически.
- Sentinel требует большинства известных sentinel для исполнения failover, а не только обнаружения. При 2 sentinel большинство — это 2; если sentinel, живущий на одной ноде с погибшим master, уходит вместе с ним, одиночка не соберёт кворум.
Оба ограничения структурные, а не дефекты — настоящее решение это 3-я
нода (даже маленькая) под монитор / 3-й sentinel. Добавление ноды C
позже аддитивно: свой .env.ha (без профиля monitor — он бывает
только один), её pgaf-node и sentinel указывают на существующие
IP монитора/master, и она просто присоединяется.
Предварительные требования (каждый HA-хост)¶
Выберите VIP в той же подсети, который не назначен ни одному хосту
(ping -c1 <кандидат> должен таймаутиться). Выберите свободные порты
PG_VIP_PORT/APP_VIP_PORT/PG_MONITOR_PORT/PG_NODE_PORT —
сначала ss -tlnp на каждом хосте; на общей машине 5432/80/6379
часто заняты посторонними сервисами — поэтому каждый порт в
docker-compose.ha.yml переменная, а не «удачное» значение по умолчанию.
Порядок bootstrap (только первый раз — НЕ идемпотентно)¶
Регистрация ноды pg_auto_failover — one-shot рукопожатие с монитором. Делайте по порядку, по одной ноде за раз.
1. Нода A — монитор, затем её собственный Postgres:
cp .env.ha.node-a.example .env.ha
# поправьте OWN_IP/PEER_IP/VIP/порты под окружение
docker compose -f docker-compose.ha.yml --env-file .env.ha up -d pgaf-monitor
# подождите ~10 c, затем:
docker compose -f docker-compose.ha.yml --env-file .env.ha up -d pgaf-node
docker exec -u postgres umbryx_pgaf_monitor \
pg_autoctl show state --pgdata /var/lib/postgresql/16/main
single (она пока одна, работает как primary).
1b. Нода A — read-only роль мониторинга на МОНИТОРЕ
(опционально — нужна только для GET /api/system/ha / System → HA
Cluster; монитор — отдельный маленький Postgres, не БД приложения,
так что это вторая, отдельная от шага 2, роль):
docker exec -u postgres umbryx_pgaf_monitor psql -p 5433 -U postgres -d pg_auto_failover -c \
"CREATE ROLE umbryx_monitor WITH LOGIN PASSWORD 'change_me_ha_monitor_password';
GRANT CONNECT ON DATABASE pg_auto_failover TO umbryx_monitor;
GRANT USAGE ON SCHEMA pgautofailover TO umbryx_monitor;
GRANT SELECT ON pgautofailover.node TO umbryx_monitor;"
docker exec -u postgres umbryx_pgaf_monitor sh -c \
"echo 'host pg_auto_failover umbryx_monitor 172.16.0.0/12 scram-sha-256' >> /var/lib/postgresql/16/main/pg_hba.conf && pg_ctl reload -D /var/lib/postgresql/16/main"
172.16.0.0/12, а не просто <ВАША_ПОДСЕТЬ>/24 — app
подключается напрямую из своего контейнера (bridge-сеть), а не через
HAProxy (host-сеть), как основной DB-трафик, и самоподключения из
bridge-контейнера обратно на свой же хост ловят в тестах quirk
Docker NAT: сервер видит bridge-внутренний IP контейнера (например
172.20.0.7), а не реальный LAN-адрес хоста — и эта внутренняя
подсеть различна на каждом хосте/compose-проекте (наблюдались
172.20.0.0/16 и 172.18.0.0/16). 172.16.0.0/12 покрывает весь
стандартный bridge-диапазон Docker плюс реальную LAN-подсеть одним
правилом; без пароля эта роль ничего не может, а с паролем — только
SELECT одной таблицы.
Затем в .env каждой app-ноды:
HA_MONITOR_HOST=<ip-ноды-a>
HA_MONITOR_PORT=5433
HA_MONITOR_USER=umbryx_monitor
HA_MONITOR_PASSWORD=change_me_ha_monitor_password
SELECT одну таблицу — никаких путей к размаху
последствий --auth trust.
2. Создайте роль/БД приложения на ноде A (pg_auto_failover поднимает Postgres сам, но о роли приложения не знает — один раз, на той ноде, что сейчас primary):
docker exec -u postgres umbryx_pgaf_node psql -p 5434 -U postgres -c \
"CREATE ROLE umbryx WITH LOGIN PASSWORD 'umbryx_secret' CREATEDB;"
docker exec -u postgres umbryx_pgaf_node psql -p 5434 -U postgres -c \
"CREATE DATABASE umbryx OWNER umbryx ENCODING 'UTF8' LC_COLLATE 'C.utf8' LC_CTYPE 'C.utf8' TEMPLATE template0;"
C.utf8 — образ pg_auto_failover на минималистичной Ubuntu-базе;
locale -a внутри контейнера покажет доступное, если отличается.)
При миграции существующей single-node установки — сделайте pg_dump из
старого контейнера db и восстановите сюда вместо создания пустой БД.
3. Нода A — разрешите роли app подключаться из LAN (pg_auto_failover ведёт HBA-правила своих репликационных/мониторных пользователей сам; роли приложения нужно своё правило, по разу на ноду при вступлении):
docker exec -u postgres umbryx_pgaf_node sh -c \
"echo 'host umbryx umbryx <ВАША_ПОДСЕТЬ>/24 scram-sha-256' >> /var/lib/postgresql/16/main/pg_hba.conf && pg_ctl reload -D /var/lib/postgresql/16/main"
4. Нода A — HAProxy + keepalived:
ip addr show <VRRP_INTERFACE> должен показать VIP на ноде A (она
одна поднята — выигрывает по умолчанию).
5. Нода A — Redis + Sentinel (эта нода seed-первичная — в её
.env.ha пустой REDIS_REPLICAOF):
6. Нода B — подключается ко всему:
cp .env.ha.node-b.example .env.ha
# поправьте под окружение — MONITOR_IP/REDIS_SEED_MASTER_IP указывают на ноду A
docker compose -f docker-compose.ha.yml --env-file .env.ha up -d pgaf-node
init → wait_standby → catchingup → secondary (pg_basebackup
запускается автоматически):
docker exec -u postgres umbryx_pgaf_monitor \
pg_autoctl show state --pgdata /var/lib/postgresql/16/main
7. Проверьте с любого хоста, что VIP доступен и ведёт на реальную primary:
psql -h <VIP> -p <PG_VIP_PORT> -U umbryx -d umbryx -c "SELECT pg_is_in_recovery();"
# должно вывести 'f' (не в recovery = это доступная на запись primary)
Направьте app на кластер¶
В .env app на каждой ноде (обычный docker-compose.yml, не
.env.ha):
DB_HOST=<VIP>
DB_PORT=<PG_VIP_PORT>
# require, а не дефолтный `prefer`: соединение теперь идёт по LAN на
# отдельный хост, а не контейнер-в-контейнер — `prefer` молча
# откатывается на plaintext, если TLS-предложение потеряется в пути,
# `require` отказывается. Проверено работать с HBA-правилом
# `host ... scram-sha-256` из шага 3 (записи `host` в HBA Postgres
# матчат и SSL, и не-SSL попытки; выбор `require` клиентом и
# принуждает это соединение к SSL).
DB_SSLMODE=require
REDIS_SENTINEL_HOSTS=<ip-ноды-a>:26379,<ip-ноды-b>:26379
REDIS_SENTINEL_MASTER=umbryx-master
REDIS_SENTINEL_PASSWORD=
ENCRYPTION_KEY и JWT_SECRET обязаны совпадать байт-в-байт на каждой app-ноде. Обычно они генерируются заново на каждую
установку — при конвертации существующей single-node установки
скопируйте значения первой ноды в .env всех остальных вместо
регенерации. Пропуск не падает с явной ошибкой: всё выглядит рабочим, пока
кто-нибудь не попытается прочитать секрет учётной записи, конфиденциальную
заметку или чувствительный loot, записанный «другой» нодой — вместо
расшифровки будет исключение.
Затем на каждой app-ноде:
Ограничение прямого доступа к нодам — только VIP для команды¶
По умолчанию nginx каждой ноды доступен и по её собственному IP
(ports: "${PORT}:80" в docker-compose.yml), как в single-node. В
HA это значит, что можно обойти VIP — а значит и failover — ударив
прямо в реальный адрес ноды, что обесценивает весь сетап: команда
должна попадать на платформу только через VIP HAProxy.
Это закрывается целиком внутри конфига nginx-контейнера — без
хостовых правил файрвола (iptables) — чтобы слой оставался таким же
переносимым и легко демонтируемым, как остальное. Файл
nginx/access-control.conf бинд-маунтится в каждый nginx-контейнер и
include'ится в блоке server{}; поставляется пустым (no-op —
обычную single-node установку его наличие не затрагивает). Для HA
заполните его на каждой app-ноде реальными IP всех HA-нод плюс
127.0.0.1:
# nginx/access-control.conf
allow <РЕАЛЬНЫЙ_IP_НОДЫ_A>;
allow <РЕАЛЬНЫЙ_IP_НОДЫ_B>;
allow 127.0.0.1; # локальные admin curl/health-проверки хоста
deny all;
По одной строке allow на ноду — этого всё ещё достаточно самому
HAProxy: он проксирует на nginx по реальному IP каждой ноды, не по
VIP. Применяется docker compose restart nginx (без пересборки —
файл бинд-маунтится). Проверено вживую: кросс-нодовые /health-проверки
HAProxy и бэкенды umbryx_app остаются UP, VIP отвечает как обычно,
а прямые запросы на собственный адрес ноды таймаутятся/403 для
всех, кто не в списке.
(Более ранняя попытка использовала хостовые iptables-правила
DOCKER-USER/conntrack — откатана в пользу этого подхода именно ради
полностью контейнерного HA-слоя. Если хостовая фильтрация нужна по
другой причине, помните: возвратному трафику DOCKER-USER нужен
явный accept ESTABLISHED,RELATED до любого allowlist-правила по
IP источника — источник ответной ноги это bridge-IP контейнера, а не
реальный IP пира, поэтому наивная пара -s <peer> -j ACCEPT /
-j DROP вешает каждое соединение на ответной ноге.)
Восстановление ноды после потери (а не просто остановки контейнера)¶
Pid/state-файл самого pg_autoctl живёт в /tmp/pg_autoctl/... в
writable-слое контейнера, не в volume PGDATA — обычный
docker stop/docker start того же контейнера безобиден (writable-слой
переживает), но docker kill с пересозданием контейнера
(docker rm + docker run, или любая замена вместо рестарта —
например docker compose up -d после смены образа) его выбрасывает.
Подтверждено вживую 2026-09-08: убийство Postgres-контейнера ноды и
пересоздание (вместо stop/start) заставляло её регистрироваться
как новую, с коллизией duplicate key value violates unique constraint "node_nodehost_nodeport_key". Если же вы просто перезапускаете тот
же контейнер (docker start, или crash-restart под --restart unless-stopped без удаления) и он падает с pg_autoctl is already running with pid 1 — pid-файл выжил, но устарел (pid 1 в namespace
нового процесса выглядит «уже запущенным» для наивной проверки) —
лечится тем же пересозданием volume ниже: поддерживаемого способа
удалить один устаревший файл, не зная внутреннюю раскладку образа, нет.
Если контейнер pg_auto_failover ноды удалён (не просто остановлен),
пока монитор ещё держит её зарегистрированной, пересоздание натыкается
на duplicate key value violates unique constraint "node_nodehost_nodeport_key" — монитор не знает, что старая
регистрация мертва. Сначала drop с любой другой ноды, затем
пересоздание с нуля (как первый join):
docker run --rm --network host umbryx/pgautofailover:16 drop node \
--monitor postgres://autoctl_node@<MONITOR_IP>:<PG_MONITOR_PORT>/pg_auto_failover?sslmode=require \
--hostname <IP_МЁРТВОЙ_НОДЫ> --pgport <PG_NODE_PORT> --force
docker volume rm pgaf_node_data # на восстанавливаемой ноде
docker volume create pgaf_node_data
docker compose -f docker-compose.ha.yml --env-file .env.ha up -d pgaf-node
# затем повторите шаг HBA-правила (шаг 3 выше) — volume свежий
Если контейнер Sentinel удалён, пока в его volume sentinel_data
остался устаревший PID-файл от некорректного shutdown — он падает на
старте с ошибками вида «pg_autoctl is already running with pid 1»
(или Redis-эквивалентом). docker volume rm sentinel_data && docker volume create sentinel_data перед пересозданием контейнера —
Sentinel переприсоединится и переучит состояние кластера у пира за
секунды в любом случае.
Операционные заметки¶
demote_timeout(по умолчанию у pg_auto_failover ~30 с, отсюда наблюдаемые ~41 с восстановления конца-в-конец) настраивается — см. документацию pg_auto_failover, если короткое окно важнее защиты от преждевременного продвижения на нестабильной сети.- Соединениям приложения с Postgres нужны TCP keepalive для чистого
восстановления после зависшего в середине failover соединения без
RST — это уже значение по умолчанию в
backend/app/database.py; не убирайте, если собираете свой HA-слой поверх TCP-passthrough LB.