Меня зовут Ирек Агмалов, я DBA-SRE в Ви.Tech - IT-дочке ВсеИнструменты.ру.
Мы обновляли PostgreSQL в кластере Patroni и хотели переключить приложение на новую версию без смены строки подключения и без долгого простоя.
Для роутинга у нас уже использовались consul-dns и Patroni, поэтому вместо замены DSN мы попробовали временно взять переключение трафика на себя через записи в Consul. В статье покажу, как перевели реплику на PostgreSQL 18, сохранили синхронизацию через логическую репликацию и переключили master и replica так, чтобы потом вернуть кластер в нормальный режим работы.

Ранее мы уже разбирали обновление PostgreSQL через отдельный кластер с последующим переключением приложения. Здесь пошли другим путем: решили сохранить ту же строку подключения и переключать трафик через consul-dns.
Можно обновляться не меняя строку подключения, например выключив patroni на обновляемом кластере и включив его на свежем кластере. Тогда будут затраты времени на включение/выключение Patroni с промежуточным удалением кластера, что может быть “не быстрым”.
Описание на основе кластера Patroni с postgresql v.15 из трёх нод, с ограничением загруженности: сервис должен успешно мочь работать на одной ноде БД в плане мощности хоста/виртуальной машины. По итогу привязки к версиям PostgreSQL нет, важны успешные тесты работы приложения на новой версии.

Предполагаемые шаги для выполнения обновления:
1) перевести физ.реплику в логическую с обновлением до pg v.18.
2) Переключаем трафик replica через consul-dns
3) Переключаем трафик master и синхронизируем sequence.
4) Возвращаем кластер под управление Patroni.
План описывает самый простой вариант, когда переключение производится на одну ноду. Можно выполнить переключение на кластер без патрони на физической репликации с лидером и репликами с различными наборами тегов для consul-dns.
Перевод физической реплики в логическую на новой версии PostgreSQL
Одна физическая реплика переводится в логическую реплику. Обновляется до версии 18 с сохранением логической репликации. В конфигурации (pg_hba) учесть смену хешей паролей на scram-sha256. Оставляется запущенной: она запущена и работает без patroni, только postgresql.
Потребуется проверить, что на текущем мастере wal_level = logical,а таблицы позволяют однозначно идентифицировать строку для логической репликации.
Выполняемые шаги по аналогии можно глянуть в статье , только в данном случае бэкап разворачивать не надо, так как уже есть физическая реплика. Слот логической репликации надо создать до выключения физической реплики в рамках patroni и запуска как независимой ноды. Далее классический pg_upgrade c подключением к созданному ранее и смещённому слоту репликации.

Переключение трафика реплик на обновлённую ноду через правку consul-dns
Выполняем запросами через curl к локальным consul агентам на всех хостах репликах кластера, то есть все кроме текущего мастера pg v.15.
Проверка данных прописанных в consul-dns на локальной ноде до внесения изменений:
$ curl http://localhost:8500/v1/agent/services | jq .
Добавление replica на будущем мастере на новой версии pg, указать имя кластера и IP адрес будущего мастера, выполнять на будущем мастере:
$ curl --request PUT --data '{"Name": "<pgcluster-name>","ID": "<pgcluster-name>","Address": "<node_IP>","Port": 5432,"Tags": ["replica"],"Meta": {},"EnableTagOverride": true}' http://127.0.0.1:8500/v1/agent/service/register
Удаление replica на репликах на прошлой версии pg v.15, указать имя кластера и имя ноды используемое в данных из consul-dns, выполнять на репликах:
$ curl -X PUT http://localhost:8500/v1/agent/service/deregister/<pgcluster-name>/<node-name>
Повторяем проверку того что получилось в consul-dns после проведённых изменений используя первую команду.
Переключение трафика на мастер на обновлённую ноду через правку consul-dns
Предварительно создать файл ~/.pgpass для возможности подключиться к ноде будущему мастеру на pg v.18 для синхронизации последовательностей/счётчиков. Проверьте подключение к postgres на будущем мастере с учётом данных введённых в .pgpass.
Само переключение выполняем через bash скрипт. Скрипт выполняется на ноде с мастером pg v.15 из под учётной записи postgres. Требуется ввести параметры для локальной базы (pg15) и базы назначения (pg18).
Как вариант для предварительного тестирования можно закомментировать всё кроме шагов синхронизации счётчиков/последовательностей. Заложено много логирования и в файл и на экран как на этапе до, так и после. Не нужное логирование можно закомментировать.
Команда nslookup, срабатывая сразу после изменения consul-dns, выдаёт старые данные, так как днс всё же так быстро не обновляется.
switch_master.sh
#!/bin/bash ### switch master from previous cluster to new instance # --- Configuration --- LOG_FILE=log_switch_master.log # CURRENT LEADER/MASTER # LOCAL INSTANCE SOURCE_HOST="localhost" SOURCE_DB="testdb" SOURCE_HOSTNAME=`hostname -s` PGCLUSTER_NAME="pgcluster-testdb" # NEXT LEADER/MASTER DEST_IP="10.243.187.62" DEST_PORT="5432" DEST_DB="$SOURCE_DB" DEST_USER="postgres" # --------------------- ### START LOGGING echo "$(date '+%Y-%m-%d %H:%M:%S') | START SCRIPT" "$SOURCE_HOSTNAME" "$PGCLUSTER_NAME" "$SOURCE_DB" "$DEST_IP" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') | START SCRIPT" "$SOURCE_HOSTNAME" "$PGCLUSTER_NAME" "$SOURCE_DB" "$DEST_IP" ### nslookup for master and replica before switch RESULT=$(nslookup master."$PGCLUSTER_NAME".service.consul 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP MASTER| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP MASTER| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP MASTER| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP MASTER| ERROR | $RESULT" fi RESULT=$(nslookup replica."$PGCLUSTER_NAME".service.consul 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP REPLICA| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP REPLICA| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP REPLICA| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP REPLICA| ERROR | $RESULT" fi ### check logical slot is active SQL="select slot_name,database,active,catalog_xmin,restart_lsn,confirmed_flush_lsn from pg_replication_slots where slot_name like '%_slot' and slot_type = 'logical' and active = 't';" RESULT=$(psql -d "$SOURCE_DB" -t -c "$SQL" 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |LOGICAL SLOT| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |LOGICAL SLOT| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |LOGICAL SLOT| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |LOGICAL SLOT| ERROR | $RESULT" fi ### log current state of consul from current_node (pg15) --not remove now - just log current state # state before changes RESULT=$(curl http://localhost:8500/v1/agent/services | jq . 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |BEFORE REMOVE MASTER DNS| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |BEFORE REMOVE MASTER DNS| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |BEFORE REMOVE MASTER DNS| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |BEFORE REMOVE MASTER DNS| ERROR | $RESULT" fi echo "$(date '+%Y-%m-%d %H:%M:%S') | START SWITCH" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') | START SWITCH" ### enable read_only on previous master SQL1="ALTER SYSTEM SET default_transaction_read_only TO on;" SQL2="SELECT pg_reload_conf();" SQL3="SHOW default_transaction_read_only;" psql -d "$SOURCE_DB" -c "$SQL1" psql -d "$SOURCE_DB" -c "$SQL2" RESULT=$(psql -d "$SOURCE_DB" -t -c "$SQL3" 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |READ ONLY ON| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |READ ONLY ON| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |READ ONLY ON| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |READ ONLY ON| ERROR | $RESULT" fi ### terminate all user/client connections SQL_TERM="SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE backend_type = 'client backend' AND pid <> pg_backend_pid() AND datname = '$SOURCE_DB';" RESULT=$(psql -d "$SOURCE_DB" -c "$SQL_TERM" 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |TERMINATE CLIENTS| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |TERMINATE CLIENTS| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |TERMINATE CLIENTS| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |TERMINATE CLIENTS| ERROR | $RESULT" fi ### synchronize sequences from previous master to new # Import sequences values as SQL commands from the source SQL_SEQ="SELECT 'SELECT setval(' || quote_literal(schemaname || '.' || sequencename) || ', ' || last_value || ');' FROM pg_sequences;" SEQUENCE_VAL=$(psql -d "$SOURCE_DB" -Atc "$SQL_SEQ" 2>&1) RESULT=$SEQUENCE_VAL if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |SEQUENCE IMPORT| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |SEQUENCE IMPORT| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |SEQUENCE IMPORT| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |SEQUENCE IMPORT| ERROR | $RESULT" fi # Export sequences values by executing on the destination RESULT=$(psql -h "$DEST_IP" -p "$DEST_PORT" -d "$DEST_DB" -U "$DEST_USER" -Atc "$SEQUENCE_VAL" 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |SEQUENCE EXPORT| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |SEQUENCE EXPORT| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |SEQUENCE EXPORT| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |SEQUENCE EXPORT| ERROR | $RESULT" fi ### add master teg for new master instance (pg18) CURL_ADDR=$"http://127.0.0.1:8500/v1/agent/service/register" CURL_DATA4="{\"Name\": \"$PGCLUSTER_NAME\",\"ID\": \"$PGCLUSTER_NAME\",\"Address\": \"$DEST_IP\",\"Port\": $DEST_PORT,\"Tags\": [\"primary\",\"master\",\"replica\"],\"Meta\": {},\"EnableTagOverride\": true}" echo "curl --request PUT --data" "$CURL_DATA4" "$CURL_ADDR" RESULT=$(curl --request PUT --data "$CURL_DATA4" "$CURL_ADDR" 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |SET NEW CONSUL-DNS| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |SET NEW CONSUL-DNS| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |SET NEW CONSUL-DNS| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |SET NEW CONSUL-DNS| ERROR | $RESULT" fi ### remove old master # removing master, should be without result RESULT=$(curl -X PUT http://localhost:8500/v1/agent/service/deregister/"$PGCLUSTER_NAME"/"$SOURCE_HOSTNAME" 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |REMOVE MASTER DNS| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |REMOVE MASTER DNS| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |REMOVE MASTER DNS| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |REMOVE MASTER DNS| ERROR | $RESULT" fi # state of local consul-dns after master tag remove RESULT=$(curl http://localhost:8500/v1/agent/services | jq . 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |AFTER REMOVE MASTER DNS| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |AFTER REMOVE MASTER DNS| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |AFTER REMOVE MASTER DNS| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |AFTER REMOVE MASTER DNS| ERROR | $RESULT" fi ### repeat session termination on previous master RESULT=$(psql -d "$SOURCE_DB" -c "$SQL_TERM" 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |TERMINATE CLIENTS| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |TERMINATE CLIENTS| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |TERMINATE CLIENTS| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |TERMINATE CLIENTS| ERROR | $RESULT" fi echo "$(date '+%Y-%m-%d %H:%M:%S') | FINISH SWITCH" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') | FINISH SWITCH" ### additional consul-dns check after switch # state after changes RESULT=$(curl http://localhost:8500/v1/agent/services | jq . 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |CONSUL-DNS AFTER SWITCH| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |CONSUL-DNS AFTER SWITCH| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |CONSUL-DNS AFTER SWITCH| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |CONSUL-DNS AFTER SWITCH| ERROR | $RESULT" fi ### nslookup for master and replica after switch RESULT=$(nslookup master."$PGCLUSTER_NAME".service.consul 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP MASTER| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP MASTER| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP MASTER| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP MASTER| ERROR | $RESULT" fi RESULT=$(nslookup replica."$PGCLUSTER_NAME".service.consul 2>&1) if [ $? -eq 0 ]; then CLEAN_RESULT=$(echo "$RESULT" | xargs) echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP REPLICA| $CLEAN_RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP REPLICA| $CLEAN_RESULT" else echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP REPLICA| ERROR | $RESULT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') |NSLOOKUP REPLICA| ERROR | $RESULT" fi echo "$(date '+%Y-%m-%d %H:%M:%S') | FINISH SCRIPT" >> "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') | FINISH SCRIPT"
После успешного выполнения скрипта новые соединения обращающиеся с использованием consul-dns как к реплике так и к мастеру будут приходить на postgresql v.18.

Так как consul-dns прописан через ноду бывшего мастера (pg15), то там нельзя ни выключать consul агент, ни сам хост. Пока кластер patroni/pg не будет переразвёрнут на pg.18.
Пересоздание кластера patroni с pg15 на pg18
Далее, при пересоздании кластера в patroni, роутинг к мастеру и репликам сохраняется благодаря ручным записям в consul.
Выключаем сервис patroni на всех оставшихся нодах c pg v.15 ($ sudo service patroni stop)
Удаляем кластер patroni, указываем имя для текущего обновляемого кластера
postgres$ patronictl -c /etc/patroni/patroni.yml remove <pgcluster-name>
На ноде мастере pg18 включаем сервис patroni. Если с конфигурацией всё ранее было выполнено корректно, не были внесены кардинально отличные параметры требующие перезапуска, то сервис запуститься не перезапуская инстанс базы, при необходимости перезагрузив конфигурацию.
На нодах с pg15 устанавливаем ПО PostgreSQL 18. Создаём необходимые папки с корректными правами и пользователем, очищаем папку базы pg15 для освобождения места. Запускаем сервис patroni, запускаем перезаливку.
Проверяем корректность работы кластера patroni. Также можно проверить как обрабатываются записи в consul-dns через nslookup:
postgres$ patronictl -c /etc/patroni/patroni.yml topology $ nslookup replica."$PGCLUSTER_NAME".service.consul $ nslookup master."$PGCLUSTER_NAME".service.consul
На нодах прошлого и настоящего мастера очищаем добавленные нами в consul-dns записи. Только проверьте, что они не пересекаются с теми, что выставляет Patroni, в примере всё же под нашу конфигурацию.
postgres$ curl -X PUT http://localhost:8500/v1/agent/service/deregister/<pgcluster-name>
По итогу получаем кластер PostgreSQL на обновлённой версии с переключением трафика и минимальным временем недоступности PostgreSQL. Недоступность будет вызвана тем как быстро приложение переключиться на обновлённую днс запись для создания соединения.
Тестирование и итоги
Самое важное, что мы поняли на тестах: в этом сценарии решает не только сама схема переключения, но и порядок действий в момент смены мастера. Если сначала убрать старую DNS-запись мастера, а новая еще не начала стабильно резолвиться у клиентов, новые попытки подключения могут зависать в поиске ноды больше 120 секунд, в зависимости от настроек клиента.
Обратная сторона когда новый мастер появляется до удаления старого: подключения могут попасть на старый мастер и получить ошибку вида
ERROR: cannot execute INSERT in a read-only transaction
Мы в своей практике остановились на сценарии в баш скрипте при котором днс запись всегда хоть какая-то доступна, то есть сперва добавляем новую запись и затем удаляем ссылку на старого лидера.
В одном из приложений столкнулись с тем, что оно проверяло “а не в read-only ли база?”, и если нет, то переподключалось. То есть было заложено подключиться именно на реплику. Про такое поведение приложения надо узнавать на тестах ;) Решается созданием полноценной реплики с нужными записями в consul.
Другой момент: трафик с реплики переводится пораньше на обновлённую базу попутно прогревая кэши; если профиль нагрузки лидера и реплики отличается, то озаботьтесь прогревом нового мастера самостоятельно.
И дополнительно бонус: одновременно с обновлением конвертим int на bigint, где на горизонте нескольких лет маячит предел; и стремимся включить data_checksums где это ещё не было сделано.
По итогу: выполнение скрипта переключения на обновлённый pg на нодах без существенной нагрузки и без существенного количества соединений (< 300) укладывалось в пол секунды, что сопоставимо с переключение лидера через Patroni.
