Привет Хабр!
В одной их своих предыдущих статей я рассказывал о том, как запустить PostgreSQL в Docker. Тогда речь шла об использовании «ванильных» образов Postgres и поднятии одного хоста. В большинстве случаев этого достаточно как для тестов, так и для экспериментов, но нужно понимать, что в промышленной эксплуатации чаще всего используются высокодоступные (отказоустойчивые, кластеризованные) конфигурации PostgreSQL.
Такое решение помимо собственно отказоустойчивости позволяет частично решить проблему производительности, перераспределяя чтение данных с primary хоста на реплики. Все запросы на запись при этом по-прежнему будут идти на primary хост.
Терминология
Небольшое отступление касательно терминологии:
primary = master = мастер-хост
slave = standby = secondary = реплика
failover — автоматический процесс превращения резервного хоста (реплики) в основной (при возникновении аварийной ситуации). Реплика становится новым primary и начинает обслуживать запрос на запись.
switchover — ручной процесс переноса primary с одного хоста на другой в кластере.

Больше терминологии здесь.
Из коробки PostgreSQL предоставляет механизм для превращения (promote) реплики в primary в случае аварии, но не предоставляет полноценного решения для построения HA-кластера (High Availability). На рынке существует целый ряд таких решений от разных вендоров (вы можете встретить термин HA-утилиты). Я рекомендую посмотреть видеодоклад Алексея Лесовского для расширения кругозора в этой области.
Наверное, самым популярным решением на данный момент является Patroni, но я в этой статье буду использовать repmgr от EnterpriseDB. Дело в том, что для repmgr уже есть готовые образы от bitnami, которые регулярно обновляются.
Отказоустойчивость в кластере PostgreSQL достигается за счёт WAL и потоковой репликации. Более подробно об этом можно почитать в блоге Selectel. Важно понимать: любые изменения, которые вы совершаете на primary, сначала попадают в WAL, а потом транслируются по сети на реплику и проигрываются там.
Лирическое отступление
Поэтому выполнение какой-либо DML команды, обернутое в блок BEGIN/ROLLBACK, не является безопасным: оно защищает только от видимого внешнему наблюдателю изменения данных, но потенциально может замедлить или даже уронить ваш кластер БД.
Например, есть большая таблица на 100 Гбайт; нужно проверить миграцию, которая обновит примерно половину строк в этой таблице. Что может пойти не так? Может закончиться CPU или место на диске (MVCC и WAL), забиться сеть. В это время все остальные запросы, приходящие на кластер, будут простаивать и, вероятно, прерываться по таймауту. Снаружи ситуация будет выглядеть как denial of service.
Прежде чем выполнить какую-либо команду на БД, подумайте, к чему она приведёт.
Кластер через docker-compose
Сначала давайте поднимем кластер PostgreSQL в Docker через compose-файл. Исходные коды, как обычно, доступны на GitHub.
Мой вариант compose-файла базируется на таковом от bitnami. Дополнительно я добавил мониторинг через postgres_exporter. Для запуска выполните команду из директории с compose-файлом:
docker-compose --project-name="habr-pg-ha-14" up -d
Имейте в виду, что кластер будет подниматься некоторое время: на Linux быстрее, на macOS/Windows дольше. В зависимости от мощности вашего компьютера для старта кластера может потребоваться до двух минут.
version: "3.9" services: pg-1: container_name: postgres_1 image: docker.io/bitnami/postgresql-repmgr:14.9.0 ports: - "6432:5432" volumes: - pg_1_data:/bitnami/postgresql - ./create_extensions.sql:/docker-entrypoint-initdb.d/create_extensions.sql:ro environment: - POSTGRESQL_POSTGRES_PASSWORD=adminpgpwd4habr - POSTGRESQL_USERNAME=habrpguser - POSTGRESQL_PASSWORD=pgpwd4habr - POSTGRESQL_DATABASE=habrdb - REPMGR_PASSWORD=repmgrpassword - REPMGR_PRIMARY_HOST=pg-1 - REPMGR_PRIMARY_PORT=5432 - REPMGR_PARTNER_NODES=pg-1,pg-2:5432 - REPMGR_NODE_NAME=pg-1 - REPMGR_NODE_NETWORK_NAME=pg-1 - REPMGR_PORT_NUMBER=5432 - REPMGR_CONNECT_TIMEOUT=1 - REPMGR_RECONNECT_ATTEMPTS=2 - REPMGR_RECONNECT_INTERVAL=1 - REPMGR_MASTER_RESPONSE_TIMEOUT=5 restart: unless-stopped networks: - postgres-ha pg-2: container_name: postgres_2 image: docker.io/bitnami/postgresql-repmgr:14.9.0 ports: - "6433:5432" volumes: - pg_2_data:/bitnami/postgresql - ./create_extensions.sql:/docker-entrypoint-initdb.d/create_extensions.sql:ro environment: - POSTGRESQL_POSTGRES_PASSWORD=adminpgpwd4habr - POSTGRESQL_USERNAME=habrpguser - POSTGRESQL_PASSWORD=pgpwd4habr - POSTGRESQL_DATABASE=habrdb - REPMGR_PASSWORD=repmgrpassword - REPMGR_PRIMARY_HOST=pg-1 - REPMGR_PRIMARY_PORT=5432 - REPMGR_PARTNER_NODES=pg-1,pg-2:5432 - REPMGR_NODE_NAME=pg-2 - REPMGR_NODE_NETWORK_NAME=pg-2 - REPMGR_PORT_NUMBER=5432 - REPMGR_CONNECT_TIMEOUT=1 - REPMGR_RECONNECT_ATTEMPTS=2 - REPMGR_RECONNECT_INTERVAL=1 - REPMGR_MASTER_RESPONSE_TIMEOUT=5 restart: unless-stopped networks: - postgres-ha pg_exporter-1: container_name: pg_exporter_1 image: prometheuscommunity/postgres-exporter:v0.11.1 command: --log.level=debug environment: DATA_SOURCE_URI: "pg-1:5432/habrdb?sslmode=disable" DATA_SOURCE_USER: habrpguser DATA_SOURCE_PASS: pgpwd4habr PG_EXPORTER_EXTEND_QUERY_PATH: "/etc/postgres_exporter/queries.yaml" volumes: - ./queries.yaml:/etc/postgres_exporter/queries.yaml:ro ports: - "9187:9187" networks: - postgres-ha restart: unless-stopped depends_on: - pg-1 pg_exporter-2: container_name: pg_exporter_2 image: prometheuscommunity/postgres-exporter:v0.11.1 command: --log.level=debug environment: DATA_SOURCE_URI: "pg-2:5432/habrdb?sslmode=disable" DATA_SOURCE_USER: habrpguser DATA_SOURCE_PASS: pgpwd4habr PG_EXPORTER_EXTEND_QUERY_PATH: "/etc/postgres_exporter/queries.yaml" volumes: - ./queries.yaml:/etc/postgres_exporter/queries.yaml:ro ports: - "9188:9187" networks: - postgres-ha restart: unless-stopped depends_on: - pg-2 networks: postgres-ha: driver: bridge volumes: pg_1_data: pg_2_data:
Инициируем auto failover
Одной из функциональных возможностей repmgr является switchover — ручная управляемая смена мастер-хоста в кластере. К сожалению, в образах от bitnami эта операция не поддерживается (в старом репозитории была issue на это, но сейчас репозиторий удален).
Для тестирования доступен только failover режим. Как его инициировать? Просто погасите контейнер с primary:
docker stop postgres_1
В логах на реплике появятся записи типа:
LOG: database system was not properly shut down; automatic recovery in progress … LOG: database system is ready to accept connections
После этого бывшая реплика станет primary и будет готова обслуживать запросы на запись.
Теперь можно вернуть обратно в строй первый хост:
docker start postgres_1
Он поднимется, увидит, что есть новый мастер, и продолжит функционировать как реплика:
INFO ==> This node was acting as a primary before restart! INFO ==> Current master is 'pg-2:5432'. Cloning/rewinding it and acting as a standby node... ... LOG: database system is ready to accept read-only connections
Кто сейчас primary?
Возникает вопрос: «Как понять, в какой роли функционирует конкретный хост?» Ответить на него поможет простой запрос:
select case when pg_is_in_recovery() then 'secondary' else 'primary' end as host_status;
Из консоли контейнера его удобнее выполнять без явного входа в psql:
psql -c "select case when pg_is_in_recovery() then 'secondary' else 'primary' end as host_status;" "dbname=habrdb user=habrpguser password=pgpwd4habr"
А ещё удобнее сделать это из командной строки:
docker exec postgres_1 psql -c "select case when pg_is_in_recovery() then 'secondary' else 'primary' end as host_status;" "dbname=habrdb user=habrpguser password=pgpwd4habr"
Кластер в Testcontainers
Автоматизированное тестирование и Continuous Integration (CI) — две практики, которые, на мой взгляд, сильно изменили разработку ПО. Когда нужно проверить взаимодействие приложения с каким-либо хранилищем или брокером сообщений, удобно и полезно использовать Testcontainers. В библиотеке pg-index-health, которую я совместно с open source сообществом поддерживаю и развиваю, есть функционал по сбору статистики на всех хостах кластера. Для его полноценной проверки нам потребовалось поднимать кластер PostgreSQL в end-to-end тестах.
Готового модуля для кластера в Testcontainers не было, поэтому мы сделали небольшую обёртку PostgresBitnamiRepmgrContainer над стандартным JdbcDatabaseContainer и упаковали всё это в PostgreSqlClusterWrapper. Большое спасибо Алексею @Evreke Антипину за проделанную работу!
PostgreSqlClusterWrapper умеет поднимать двухнодовый кластер, а также «гасить» первый хост, вызывая тем самым auto failover. Jar-ник доступен в Maven Central.
Основной сложностью стала правильная инициализация кластера: поднять первый хост, затем второй; дождаться, пока второй хост войдет в кластер и начнёт стримить WAL с primary. Для этой цели мы использовали LogMessageWaitStrategy и Awaitility.
Зачем PostgreSqlClusterWrapper может пригодиться вам? Главным образом, это возможность проверить поведение вашего приложения при возникновении нештатной ситуации на БД: сможет ли оно продолжить корректно работать при смене мастера.
Тюнинг параметров JDBC драйвера
JDBC driver поддерживает клиентскую балансировку и сам может переподключаться к новому primary-хосту, если в connection URL указать сразу несколько хостов базы данных.
В общем виде строка подключения выглядит следующим образом:
jdbc:postgresql://localhost:32769,localhost:32770/test_db?connectTimeout=1&hostRecheckSeconds=2&socketTimeout=600&targetServerType=primary
Здесь мы видим два инстанса БД на разных портах: localhost:32769 и localhost:32770, но гораздо важнее и интереснее параметры подключения.
targetServerType=primary указывает, что нас интересует только мастер-хост БД. Если вы хотите организовать чтение с реплики, то вам подойдёт вариант targetServerType=secondary или targetServerType=preferSecondary.
Параметр connectTimeout определяет, как много времени мы готовы ждать до установления соединения с сервером БД. Значение по умолчанию — 10 секунд. Уменьшение этого значен��я позволяет более агрессивно перебирать хосты в списке и пытаться к ним подключиться.
Опция hostRecheckSeconds влияет на частоту проверки, кто у нас сейчас мастер, а кто реплика.
И, наконец, socketTimeout выступает в качестве глобального ограничителя времени выполнения всех запросов к БД. Установка этого параметра не обязательна; более того, конкретное значение нужно аккуратно подбирать под ваши нужды.
Безусловно, это далеко не все параметры, которые поддерживает JDBC драйвер, но это, на мой взгляд, необходимый минимум.
В промышленных приложениях обычно используется пул соединений к БД. Каждая реализация connection pool’а имеет свой набор параметров, позволяющих тонко управлять соединениями в пуле. Более подробно об этом можно почитать в статье Database timeouts. Для HikariCP, как минимум, имеет смысл настроить connectionTimeout и validationTimeout:
import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import javax.annotation.Nonnull; import lombok.SneakyThrows; import lombok.experimental.UtilityClass; import org.postgresql.Driver; @UtilityClass public class HikariDataSourceProvider { @Nonnull @SneakyThrows public HikariDataSource getDataSource(@Nonnull final String jdbcUrl, @Nonnull final String username, @Nonnull final String password) { final HikariConfig hikariConfig = new HikariConfig(); hikariConfig.setMaximumPoolSize(3); hikariConfig.setConnectionTimeout(250L); hikariConfig.setMaxLifetime(30_000L); hikariConfig.setValidationTimeout(250L); hikariConfig.setJdbcUrl(jdbcUrl); hikariConfig.setUsername(username); hikariConfig.setPassword(password); hikariConfig.setDriverClassName(Driver.class.getCanonicalName()); return new HikariDataSource(hikariConfig); } }
Я подготовил пару приложений, демонстрирующих использование PostgreSqlClusterWrapper, и разместил их на GitHub: консольное и spring-boot. Пусть они станут отправной точкой для ваших экспериментов!
