Укрощаем мажорные обновления: сценарий обновления системных данных каталога без лишней боли

Публикации в СМИ
18.03.2025
Опубликовано на Хабре

В мире разработки и эксплуатации ПО мажорные обновления — это всегда стресс. Независимо от того, насколько хорошо вы тестируете изменения, всегда есть риск, что что-то пойдёт не так. Особенно это касается обновлений, которые затрагивают пользовательские данные. В какой-то момент мы задумались о том, как нам минимизировать риски и сделать обновления более предсказуемыми.

Меня зовут Кристина Демидович, я DevOps‑инженер в СберТехе, занимаюсь автоматизацией в команде СУБД Pangolin — это целевая СУБД в Сбере и не только. Я расскажу о нашем подходе к обновлению СУБД Pangolin, который позволил нам превратить часть мажорных обновлений в обновление данных системного каталога — что проще, удобнее и занимает вдвое меньше времени.

Надеюсь, наш опыт будет полезен тем, кто занимается автоматизацией и имеет дело с обновлением сложных систем.

Когда нужны мажорные обновления

Традиционно мажорное обновление предполагает полную миграцию данных. Как правило, это полный фарш: полный бэкап, инициализация новой БД и её настройка, подготовка конфигурационных файлов. Миграция с помощью pg_upgrade, после которого необходим запуск сборки новой статистики (vacuumdb). Перенос данных с помощью rsync, обновление версий расширений и так далее. Всё это необходимо выполнить единовременно. В общем, у мажорных обновлений есть ряд существенных недостатков:

  • они требуют много времени и ресурсов;
  • есть риски возникновения сбоев, которые могут привести к потере информации, ввиду того, что мы работаем с пользовательскими данными;
  • сложность откатов.

Полностью отказаться от мажорных обновлений мы не можем, поскольку они могут включать в себя глобальные изменения. Вот случаи, когда мы выпускаем мажорную версию:

  • изменение мажорной версии базового PostgreSQL;
  • изменение логического или физического формата данных;
  • потеря обратной совместимости с предыдущими версиями функциональностей;
  • изменение системного каталога.

В первых трёх случаях обойтись без мажорных обновлений нельзя, и это обоснованно. А вот на изменение системного каталога мы посмотрели под другим углом. Делать мажорное обновление в этом сценарии неоправданно долго и ресурсозатратно, даже если изменения незначительны — меняются только системные данные, но не сами физические объекты системного каталога (таблицы, столбцы, индексы, ключи и т. д.). 

Нам надоело это терпеть — и мы дали жизнь новому способу обновления.

Работу разделили на два этапа:

  1. Разработка инструмента для обновления данных системного каталога.
  2. Автоматизация процесса обновления.

Как устроена работа инструмента

Подробно о том, как устроен этот инструмент, расскажет мой коллега Николай Литковец — он уже почти дописал свою статью на Хабр. А я расскажу вкратце и перейду к автоматизации обновления.

Да, уточню, что мы ставили в приоритет совместимость с инфраструктурой. Логика решения должна была быть прозрачной и не нарушать привычные процессы.

Механизм включает в себя добавление новых объектов в системный каталог и изменение существующих объектов. Всё начинается с создания дампа 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 │ │ │ └── и тд.

Обновление

Схематично последовательность действий выглядит так:

d0472491712a96ad563af3207719479b.png

Этап, отмеченный жёлтым цветом, играет важную роль. Он включает в себя:

  • бэкап системных файлов для возможности восстановления в случае ошибки во время работы скрипта 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

Посмотрим на процесс восстановления на схеме:

0fac82fdfa368a634e8f0ee161eb0595.png

Как можно заметить, по существующим исходам работы 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.

Заметный прогресс, хотя и, безусловно, есть над чем работать. Если интересно узнать больше о том, как мы развиваем решение, приходите в наше сообщество. Скоро появится статья моего коллеги о том, как устроен инструмент для этого обновления.