Поднимаем кластер PostgreSQL в Docker и Testcontainers

Привет Хабр!

В одной их своих предыдущих статей я рассказывал о том, как запустить 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. Пусть они станут отправной точкой для ваших экспериментов!

© Habrahabr.ru