В мире разработки и эксплуатации ПО мажорные обновления — это всегда стресс. Независимо от того, насколько хорошо вы тестируете изменения, всегда есть риск, что что-то пойдёт не так. Особенно это касается обновлений, которые затрагивают пользовательские данные. В какой-то момент мы задумались о том, как нам минимизировать риски и сделать обновления более предсказуемыми.
Меня зовут Кристина Демидович, я DevOps‑инженер в СберТехе, занимаюсь автоматизацией в команде СУБД Pangolin — это целевая СУБД в Сбере и не только. Я расскажу о нашем подходе к обновлению СУБД Pangolin, который позволил нам превратить часть мажорных обновлений в обновление данных системного каталога — что проще, удобнее и занимает вдвое меньше времени.
Надеюсь, наш опыт будет полезен тем, кто занимается автоматизацией и имеет дело с обновлением сложных систем.
Когда нужны мажорные обновления
Традиционно мажорное обновление предполагает полную миграцию данных.
Как правило, это полный фарш: полный бэкап, инициализация новой БД и её
настройка, подготовка конфигурационных файлов. Миграция с помощью pg_upgrade
, после которого необходим запуск сборки новой статистики (vacuumdb
). Перенос данных с помощью rsync
,
обновление версий расширений и так далее. Всё это необходимо выполнить
единовременно. В общем, у мажорных обновлений есть ряд существенных
недостатков:
- они требуют много времени и ресурсов;
- есть риски возникновения сбоев, которые могут привести к потере информации, ввиду того, что мы работаем с пользовательскими данными;
- сложность откатов.
Полностью отказаться от мажорных обновлений мы не можем, поскольку они могут включать в себя глобальные изменения. Вот случаи, когда мы выпускаем мажорную версию:
- изменение мажорной версии базового PostgreSQL;
- изменение логического или физического формата данных;
- потеря обратной совместимости с предыдущими версиями функциональностей;
- изменение системного каталога.
В первых трёх случаях обойтись без мажорных обновлений нельзя, и это обоснованно. А вот на изменение системного каталога мы посмотрели под другим углом. Делать мажорное обновление в этом сценарии неоправданно долго и ресурсозатратно, даже если изменения незначительны — меняются только системные данные, но не сами физические объекты системного каталога (таблицы, столбцы, индексы, ключи и т. д.).
Нам надоело это терпеть — и мы дали жизнь новому способу обновления.
Работу разделили на два этапа:
- Разработка инструмента для обновления данных системного каталога.
- Автоматизация процесса обновления.
Как устроена работа инструмента
Подробно о том, как устроен этот инструмент, расскажет мой коллега Николай Литковец — он уже почти дописал свою статью на Хабр. А я расскажу вкратце и перейду к автоматизации обновления.
Да, уточню, что мы ставили в приоритет совместимость с инфраструктурой. Логика решения должна была быть прозрачной и не нарушать привычные процессы.
Механизм включает в себя добавление новых объектов в системный
каталог и изменение существующих объектов. Всё начинается с создания
дампа pg_catalog
(это происходит до обновления), чтобы
проверить согласованность исходных данных системных каталогов. С помощью
дополнительной утилиты update_catalog_version
меняется
версия каталога. После этого каталоги до пользовательских табличных
пространств переименовываются в соответствии с новой версией системного
каталога.
Далее загружаем SQL-скрипты на master-хост, для всех баз данных, включая template0 и template1. Проходит это в две итерации. Сначала скрипты запускают с функцией ROLLBACK, чтобы проверить возможность их загрузки. Если тестовая загрузка прошла успешно, то скрипты запускают повторно без ROLLBACK.
После успешной загрузки всех SQL-скриптов во все БД создаётся повторный дамп pg_catalog для проверки консистентности обновлённых данных системного каталога.
Если при первой итерации загрузки данных возникают проблемы, то вторую итерацию не запускаем. Происходит откат к исходной версии системного каталога. Для восстановления работоспособности достаточно вернуть бинарные файлы исходной версии. Если есть проблемы при второй итерации, то требуется не только восстановить бинарные файлы исходной версии, но и запустить утилиту в режиме reset.
В случае кластерной конфигурации на replica-хост данные доезжают с помощью репликации.
Как устроена работа скриптов автоматизации
Следующий шаг — автоматизация запуска. Мы реализовали её через набор ролей Ansible, которые выполняют различные задачи: установку, обновление и настройку компонентов продукта. Простота управления заключается в разделении ролей по функциональным областям и компонентам продукта.
Вот как выглядит структура нашего проекта:
installer │ ├── collections │ ├── files │ ├── group_vars │ ├── inventories │ ├── roles │ ├── pangolin_auth_reencrypt # Роль для настройки инструмента перешифрования │ ├── pangolin_backup_tools # Роль для инструмента резервного копирования │ ├── pangolin_certs_rotate # Роль для настройки ротации сертификатов │ ├── pangolin_checks # Роль с комплексом проверок перед установкой или обновлением │ ├── pangolin_dbms # Роль для установки, обновления и настройки СУБД │ ├── pangolin_manager # Роль для настройки инструмента кластеризации │ ├── pangolin_pooler # Роль для настройки пулеров │ ├── configure # Роль для конфигурирования СУБД │ ├── clean # Роль для очистки стенда │ ├── common # Роль с общими функциями │ ├── recovery # Роль для восстановления │ └── и тд. │ ├── templates │ ├── playbook_install.yaml # Плейбук для установки продукта ├── playbook_minor_update.yaml # Плейбук для минорных обновлений ├── playbook_major_update.yaml # Плейбук для мажорных обновлений └── playbook_scouting.yaml # Плейбук для проверки готовности стенда к обновлению
Каждая роль отвечает за выполнение определённых задач, связанных с конкретным компонентом или процессом. Пример структуры роли.
installer │ ├── roles │ ├── pangolin_dbms │ │ ├── tasks │ │ │ ├── backup.yml │ │ │ ├── install.yml │ │ │ ├── main.yml │ │ │ ├── revert_minor.yml │ │ │ ├── revert_major.yml │ │ │ ├── update_major.yml │ │ │ ├── update_minor.yml │ │ │ └── и тд.
При подготовке новой модели обновления нам было важно следующее:
- не затратить много времени на реализацию;
- сократить время обновления;
- обеспечить процесс восстановления;
- сократить требования к процессу обновления (х2 места для бэкапа).
Мы хотели не радикально поменять механизм скриптов, а интегрировать в него новые процессы. Внедрение нового процесса в существующий механизм минорного обновления позволило ускорить разработку и тестирование за счёт того, что он был успешно протестирован и внедрён ранее. Такое обновление мы назвали обновлением данных системного каталога.
Сейчас наши плейбуки работают преимущественно с монолитными системами, хотя у нас есть планы по переходу на компонентную архитектуру. Но это уже другая история. А пока рассмотрим исходный плейбук для минорного обновления, чтобы лучше понять, как оно работает.
kotlin#################################################### standalone ##################################################### - hosts: master roles: - { role: pangolin_checks } - { role: pangolin_license, operation_type: install } - { role: pangolin_auth_reencrypt, operation_type: install_and_update } - { role: pangolin_certs_rotate, operation_type: install_and_update } - { role: pangolin_security_utilities, operation_type: install_and_update } - { role: pangolin_manager, operation_type: update } - { role: pangolin_dbms, operation_type: update_minor } - { role: pangolin_pooler, operation_type: update } - { role: pangolin_diagnostic_tool, operation_type: install_and_update } - { role: finally, operation_type: switch_to_original_configs } - { role: configure, operation_type: configure_for_update } - { role: finally, operation_type: finish_update } - { role: pangolin_backup_tools, operation_type: install_and_update } tags: standalone ###################################################### cluster ###################################################### - hosts: master:replica:arbiter roles: - { role: pangolin_checks, when: not update_errors.aggregate } - { role: pangolin_license, when: not update_errors.aggregate, operation_type: install } - { role: pangolin_auth_reencrypt, when: not update_errors.aggregate, operation_type: install_and_update } - { role: pangolin_certs_rotate, when: not update_errors.aggregate, operation_type: install_and_update } - { role: pangolin_security_utilities, when: not update_errors.aggregate, operation_type: install_and_update } - { role: common, operation_type: update_errors_sync_to_hosts } tags: cluster - hosts: master:replica serial: 1 roles: - { role: common, operation_type: update_errors_sync_to_hosts } - { role: pangolin_manager, when: patroni and not update_errors.aggregate, operation_type: update } tags: cluster - hosts: replica:master serial: 1 roles: - { role: common, operation_type: update_errors_sync_to_hosts } - { role: pangolin_dbms, when: not update_errors.aggregate, operation_type: update_minor } - { role: common, operation_type: update_errors_sync_to_hosts } - { role: pangolin_pooler, when: pgbouncer and not update_errors.aggregate, operation_type: update } tags: cluster - hosts: master:replica roles: - { role: common, operation_type: update_errors_sync_to_hosts } - { role: pangolin_diagnostic_tool, when: not update_errors.aggregate, operation_type: install_and_update } - { role: common, operation_type: update_errors_sync_to_hosts } - { role: finally, when: not update_errors.aggregate, operation_type: switch_to_original_configs } - { role: common, operation_type: update_errors_sync_to_hosts } tags: cluster - hosts: master:replica:arbiter roles: - { role: common, operation_type: update_errors_sync_to_hosts } - { role: configure, when: not update_errors.aggregate, operation_type: configure_for_update } - { role: common, operation_type: update_errors_sync_to_hosts } - { role: finally, when: not update_errors.aggregate, operation_type: finish_update } - { role: common, operation_type: update_errors_sync_to_hosts } tags: cluster - hosts: master:replica roles: - { role: pangolin_backup_tools, when: not update_errors.aggregate, operation_type: install_and_update } - { role: common, operation_type: update_errors_sync_to_hosts } tags: cluster ###################################################### recovery ##################################################### - hosts: master:replica:arbiter roles: - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_checks } - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_backup_tools, vars: { revert_pgbackup: false } } tags: standalone,cluster - hosts: master:replica:arbiter serial: 1 roles: - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_manager } tags: standalone,cluster - hosts: master roles: - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_dbms } - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_pooler } tags: standalone,cluster - hosts: replica roles: - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_dbms } - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_pooler } tags: cluster - hosts: replica:master:arbiter roles: - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_security_utilities } - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_diagnostic_tool } - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_auth_reencrypt } - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_certs_rotate } - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: finally } tags: standalone,cluster - hosts: master:replica roles: - { role: pangolin_backup_tools, operation_type: return_original_scripts } tags: standalone,cluster
Плейбук состоит из трёх основных разделов:
- обновление standalone-архитектур;
- обновление кластерных архитектур;
- откат в случае возникновения проблем.
Сосредоточимся на обновлении СУБД Pangolin. В случае кластерной конфигурации мы обеспечиваем отсутствие простоя с помощью последовательного обновления хостов.
kotlin- hosts: replica:master serial: 1 roles: - { role: common, operation_type: update_errors_sync_to_hosts } - { role: pangolin_dbms, when: not update_errors.aggregate, operation_type: update_minor }
При обновлении данных системного каталога БД нам требуется обеспечить
полную недоступность СУБД для пользователей. Во время снятия дампов
системных таблиц (например, pg_class
, pg_type
, pg_proc
и других), которые содержат важную информацию о структуре данных, любые
изменения этих таблиц могут привести к несоответствию между состоянием
БД на момент начала обновления и по его окончании. Это может создать
проблемы при восстановлении данных или привести к ошибкам в работе самой
СУБД после обновления. Чтобы исключить эти риски, организован полный
downtime — период, когда БД недоступна для всех операций записи и чтения
со стороны пользователей.
Это ключевое отличие потребовало переработки плейбука, и теперь он выглядит так:
kotlin- hosts: master:replica roles: - { role: common, operation_type: update_errors_sync_to_hosts } - { role: pangolin_dbms, when: pg_inplace_upgrade | d(false) and not update_errors.aggregate, operation_type: update_minor_shutdown } - { role: common, operation_type: update_errors_sync_to_hosts } tags: cluster - hosts: replica:master serial: 1 roles: - { role: common, operation_type: update_errors_sync_to_hosts } - { role: pangolin_dbms, when: not pg_inplace_upgrade | d(false) and not update_errors.aggregate, operation_type: update_minor } - { role: common, operation_type: update_errors_sync_to_hosts } - { role: pangolin_pooler, when: pgbouncer and not update_errors.aggregate, operation_type: update } tags: cluster
Путь, по которому идёт обновление, определяется параметром pg_inplace_upgrade
.
Его значение вычисляется запуском скрипта inplace_upgrade.sh в режиме
info. Запуск выполняется после всех проверок готовности стенда к
обновлению в рамках роли pangolin_checks
, причём одновременно на всех хостах. В коде это выглядит примерно так:
- name: execute utility with key info ansible.builtin.expect: chdir: "{{ pangolin_ansible_cache }}/pg_inplace_upgrade" command: "./inplace_upgrade.sh info \ -n {{ current_pangolin_dbms_version }} \ -N {{ version_components.pangolin_dbms }} \ -s {{ pangolin_ansible_cache }}/pg_inplace_upgrade \ -B {{ pg_inplace_upgrade_work_dir }}/pg_inplace_upgrade/backup \ -d {{ _PGDATA | d(PGDATA) }} \ -t {{ _PGHOME | d(PGHOME) }}/bin \ -T {{ _PGHOME_CLIENT | d(PGHOME_CLIENT) }}/bin \ -l {{ pg_inplace_upgrade_work_dir }}/pg_inplace_upgrade/log \ -m {{ pg_inplace_upgrade_work_dir }}/pg_inplace_upgrade/dump \ -p {{ ports.pg }} \ -h {{ ansible_default_ipv4.address }} \ -u {{ connect_user }} \ -D \ -b postgres \ {% if inventory_hostname == 'replica' %}-r{% endif %} \ {% if skip_test %}-k{% endif %}" responses: (?im)password*: "{{ postgres_db_pass }}" timeout: 1000000 environment: - PG_LICENSE_PATH: "{{ license.path }}" - "{{ locale_lang }}" vars: ansible_python_interpreter: '{{ python.postgresql_venv }}/bin/python3' register: status_work_pg_inplace_upgrade no_log: "{{ nolog }}" ignore_errors: true become_user: postgres - name: save state execute utility ansible.builtin.set_fact: pg_inplace_upgrade: "{{ (not status_work_pg_inplace_upgrade.rc) | ternary(true, false) }}"
Рассмотрим роль pangolin_dbms
— именно здесь произошли
изменения. Скрипты по обновлению и восстановлению данных системного
каталога мы разместили в отдельных файлах — update_minor_shutdown.yml и
revert_minor_shutdown.yml соответственно, чтобы не пересекаться с
существующим процессом. Структура роли приобрела вид:
installer │ ├── roles │ ├── pangolin_dbms │ │ ├── tasks │ │ │ ├── backup.yml │ │ │ ├── install.yml │ │ │ ├── main.yml │ │ │ ├── revert_minor_shutdown.yml │ │ │ ├── revert_minor.yml │ │ │ ├── revert_major.yml │ │ │ ├── update_major.yml │ │ │ ├── update_minor.yml │ │ │ ├── update_minor_shutdown.yml │ │ │ └── и тд.
Обновление
Схематично последовательность действий выглядит так:

Этап, отмеченный жёлтым цветом, играет важную роль. Он включает в себя:
- бэкап системных файлов для возможности восстановления в случае ошибки во время работы скрипта inplace_upgrade.sh;
- изменение версии системного каталога;
- запуск узла БД;
- снятие SQL-дампа исходного системного каталога для проверки целостности его системных данных;
- обновление и добавление новых системных объектов;
- снятие SQL-дампа обновлённого системного каталога для последующей проверки целостности его системных данных;
- запуск тестов по проверке корректности обновления системных данных.
На этом этапе ключевой аспект — резервное копирование только системных файлов. Это отличается от полного резервного копирования всех данных, которое происходит при мажорном обновлении и может занимать длительное время на большой БД.
Создание бэкапа определено первым шагом не случайно. На этом этапе обновления могут возникнуть сценарии, требующие последующего восстановления. В зависимости от кода состояния скрипта inplace_upgrade.sh, контролируется дальнейшая работа скриптов автоматизации. Рассмотрим возможные сценарии и их обработку:
- Некорректное обновление без возможности автоматического восстановления (код: 1). Транзакция по изменению данных системного каталога была запущена, но при выполнении скрипта произошла критическая ошибка. Сообщение скриптов автоматизации:
FAIL__В процессе обновления данных системного каталога возникли ошибки: {}. Процесс дальнейшего обновления остановлен. Автоматическое восстановление невозможно. Необходимо произвести анализ состояния стенда вручную и восстановить его к исходному состоянию или дообновить.__FAIL - Некорректное обновление с возможностью автоматического восстановления (код: 4). Транзакция по изменению данных системного каталога была запущена, но в дальнейшей работе скрипта произошла ошибка. Сообщение скриптов автоматизации:
FAIL__В процессе обновления данных системного каталога возникли ошибки: {}. Процесс дальнейшего обновления остановлен. Будет запущено автоматическое восстановление.__FAIL - Некорректное обновление с возможностью автоматического восстановления (альтернативный сценарий) (код: 5). Транзакция по изменению данных системного каталога не была запущена, и данные в системном каталоге остались в исходном состоянии. Сообщение скриптов автоматизации:
FAIL__В процессе обновления данных системного каталога возникли ошибки: {}. Процесс дальнейшего обновления остановлен. Будет запущено автоматическое восстановление.__FAIL - Корректное обновление (код: 0). Работа скрипта завершилась без ошибок. Сообщение скриптов автоматизации:
INFO__Процесс обновления данных системного каталога завершился успешно.__INFO
Важно уточнить: если сбой произойдёт до этого этапа, система может быть автоматически восстановлена, после — автоматически это будет уже невозможно.
Восстановление
Параметр pg_inplace_upgrade
управляет тем, как будет выполняться восстановление. Мы организовали плейбук следующим образом:
kotlin- hosts: master:replica roles: - { role: recovery, when: pg_inplace_upgrade | d(false) and handle_update_errors and update_errors.aggregate, recovery_type: pangolin_dbms } tags: standalone,cluster - hosts: master:replica:arbiter serial: 1 roles: - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_manager } tags: standalone,cluster - hosts: master roles: - { role: recovery, when: not pg_inplace_upgrade | d(false) and handle_update_errors and update_errors.aggregate, recovery_type: pangolin_dbms } - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_pooler } tags: standalone,cluster - hosts: replica roles: - { role: recovery, when: not pg_inplace_upgrade | d(false) and handle_update_errors and update_errors.aggregate, recovery_type: pangolin_dbms } - { role: recovery, when: handle_update_errors and update_errors.aggregate, recovery_type: pangolin_pooler } tags: cluster
Посмотрим на процесс восстановления на схеме:

Как можно заметить, по существующим исходам работы inplace_upgrade.sh может быть два пути восстановления:
- восстановление версии СУБД с запуском восстановления файлов системного каталога из бэкапа;
- восстановление версии СУБД без запуска восстановления файлов системного каталога из бэкапа.
Восстановление версии СУБД с запуском восстановления файлов системного каталога из бэкапа предполагает возврат бинарных файлов к предыдущей версии СУБД, а также использование ранее созданной резервной копии данных для восстановления в исходное состояние данных системного каталога. Этот метод используется только если при обновлении были затронуты данные системного каталога. Восстановление из бэкапа происходит с помощью запуска скрипта inplace_upgrade.sh в режиме reset.
Так выглядит запуск:
- name: execute utility with key reset ansible.builtin.expect: chdir: "{{ pangolin_ansible_cache }}/pg_inplace_upgrade" command: "./inplace_upgrade.sh reset \ -n {{ current_pangolin_dbms_version }} \ -N {{ version_components.pangolin_dbms }} \ -s {{ pangolin_ansible_cache }}/pg_inplace_upgrade \ -B {{ pg_inplace_upgrade_work_dir }}/pg_inplace_upgrade/backup \ -d {{ _PGDATA | d(PGDATA) }} \ -t {{ _PGHOME | d(PGHOME) }}/bin \ -T {{ _PGHOME_CLIENT | d(PGHOME_CLIENT) }}/bin \ -l {{ pg_inplace_upgrade_work_dir }}/pg_inplace_upgrade/log \ -m {{ pg_inplace_upgrade_work_dir }}/pg_inplace_upgrade/dump \ -p {{ ports.pg }} \ -h {{ ansible_default_ipv4.address }} \ -u {{ connect_user }} \ -D \ -b postgres \ {% if inventory_hostname == 'replica' %}-r{% endif %} \ {% if skip_test %}-k{% endif %}" responses: (?im)password*: "{{ postgres_db_pass }}" timeout: 1000000 environment: - PG_LICENSE_PATH: "{{ license.path }}" - "{{ locale_lang }}" vars: ansible_python_interpreter: '{{ python.postgresql_venv }}/bin/python3' register: status_work_pg_inplace_upgrade no_log: "{{ nolog }}" ignore_errors: true become_user: postgres
И восстановление версии СУБД без запуска восстановления файлов из бэкапа. Если данные системного каталога остались нетронутыми, достаточно вернуть бинарные файлы СУБД к предыдущей версии, не нужно восстанавливать файлы из бэкапа.
Результаты. Что насчёт скорости обновления
Подвести итог хотелось бы числами. Напомню, что мы стремились сократить время таких обновлений. После разработки решения пришли к следующим результатам:
- Мажорное обновление. Общее время обновления всех компонентов продукта в конфигурации cluster объёмом 100 ГБ — 65 минут.
- Минорное обновление. Общее время обновления всех компонентов продукта в конфигурации cluster объёмом 100 ГБ — 25 минут.
- Обновление данных системного каталога. Общее время обновления всех компонентов продукта в конфигурации cluster объёмом 100 ГБ — 23 минуты.
Мы внедрили обновление данных системных каталогов в версии 6.4.0. В результате этого текущее состояние наших версий выглядит так: 6.1.0 — 6.2.0 — 6.3.0 — 6.4.0 — 6.5.0 — 6.6.0 — 7.1.0. Изменение минорной версии означает обновление данных системных каталогов, а изменение мажорной версии — мажорное обновление. Если бы мы не внедрили этот подход, то наше состояние могло бы быть таким: 6.1.0 — 6.2.0 — 6.3.0 — 7.1.0 — 8.1.0 — 9.1.0 — 10.1.0.
Заметный прогресс, хотя и, безусловно, есть над чем работать. Если интересно узнать больше о том, как мы развиваем решение, приходите в наше сообщество. Скоро появится статья моего коллеги о том, как устроен инструмент для этого обновления.