Перейти к содержанию

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-хост)

sudo sysctl -w net.ipv4.ip_nonlocal_bind=1   # HAProxy/keepalived биндят
                                              # VIP до выдачи VRRP

Выберите 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
Нода A должна достичь 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:

docker compose -f docker-compose.ha.yml --env-file .env.ha up -d haproxy keepalived
ip addr show <VRRP_INTERFACE> должен показать VIP на ноде A (она одна поднята — выигрывает по умолчанию).

5. Нода A — Redis + Sentinel (эта нода seed-первичная — в её .env.ha пустой REDIS_REPLICAOF):

docker compose -f docker-compose.ha.yml --env-file .env.ha up -d redis sentinel

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
Смотрите монитор (с любой ноды) — нода B должна сама пройти init → wait_standby → catchingup → secondary (pg_basebackup запускается автоматически):
docker exec -u postgres umbryx_pgaf_monitor \
  pg_autoctl show state --pgdata /var/lib/postgresql/16/main
Затем повторите HBA-правило шага 3 на ноде B и поднимите остальное:
docker compose -f docker-compose.ha.yml --env-file .env.ha up -d haproxy keepalived redis sentinel

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-ноде:

docker compose up -d backend frontend nginx

Ограничение прямого доступа к нодам — только 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.