Меня зовут Ирек Агмалов, я 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 нет, важны успешные тесты работы приложения на новой версии. 

Исходный кластер PostgreSQL под управлением Patroni + Consul (+consul-dns)
Исходный кластер PostgreSQL под управлением Patroni + Consul (+consul-dns)

Предполагаемые шаги для выполнения обновления:

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 подключением к созданному ранее и смещённому слоту репликации.

Одна реплика стала логической, версия PostgreSQL на ней обновлена до целевой
Одна реплика стала логической, версия PostgreSQL на ней обновлена до целевой

Переключение трафика реплик на обновлённую ноду через правку 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.

Переключились на обновлённый PostgreSQL
Переключились на обновлённый PostgreSQL

Так как 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.