Горькая правда о GitOps и откатах базы данных

Почему существующие инструменты и практики не могут выполнить обещания GitOps о декларативных и постоянно синхронизируемых рабочих процессах и как мы можем использовать шаблон оператора (Operator Pattern) для создания нового решения для надежных и безопасных откатов схем.
На протяжении 20 лет распространенной практикой для выполнения откатов миграций схем базы данных были заранее подготовленные скрипты (down migration scripts).

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

Кнопка «Отменить»

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


Цифровые инструменты изменили все. Кнопка «Отменить» превратила исправления в простое нажатие клавиши, освобождая создателей от страха совершить непоправимую ошибку. Это подарило свободу пробовать, ошибаться и дорабатывать идеи.

Откаты и доставка программного обеспечения

Когда речь идет о доставке программного обеспечения, возможность откатывать изменения — критический фактор безопасности для команд, развертывающих новые функции, обновления или исправления ошибок. В частности, откаты влияют на один из ключевых показателей доставки программного обеспечения: среднее время восстановления (Mean Time to Recovery, MTTR).


MTTR измеряет, как быстро система может восстановиться после сбоев. У команд обычно есть два варианта: определить проблему и исправить ее (откат вперед) или откатиться к предыдущему известному стабильному состоянию.


Когда решение проблемы не является очевидным, либо проблема слишком серьезная, откат — самый быстрый способ восстановления работы сервиса. Именно поэтому наличие надежного механизма отката имеет решающее значение для сокращения MTTR и обеспечения высокой доступности сервисов.

Как вообще возможны откаты?

Отмена изменений в локальной среде, например, в текстовом редакторе, — задача достаточно простая. Система отслеживает каждое изменение и может вернуться к предыдущему состоянию.


В распределенной системе, такой как современные облачные приложения, все не так просто. Изменения вносятся в множество компонентов с сложными зависимостями и конфигурациями.


Ключевой способ, позволяющий откатывать изменения, описан в книге Accelerate: The Science of Lean Software and DevOps. Авторы выделяют «полное управление конфигурацией» как одну из ключевых практик, позволяющих достичь высоких показателей производительности при доставке программного обеспечения:

«Необходимо иметь возможность автоматически развертывать наши окружения и собирать, тестировать и развертывать наше программное обеспечение, опираясь исключительно на информацию, хранящуюся в системе контроля версий»

Теоретически это означает, что если мы можем хранить всю информацию в системе контроля версий и иметь автоматический способ применения этих изменений, мы должны быть в состоянии откатиться к предыдущему состоянию, просто применив предыдущий коммит.

GitOps и откаты

Принцип «полного управления конфигурацией» со временем развился в такие идеи, как IaC и GitOps. Эти практики предполагают ключевые для нас вещи:


  1. хранение всех конфигураций и определений инфраструктуры в системе контроля версий в декларативном формате
  2. использование автоматизации для применения этих изменений к системе

Проекты ArgoCD и Flux популяризировали подход GitOps для управления кластерами Kubernetes. Обеспечивая структурированный способ определения желаемого состояния системы в Git (например, манифесты Kubernetes) и автоматическую синхронизацию с фактическим состоянием, инструменты GitOps предоставляют стандартизированный способ удовлетворения данного принципа.


На бумаге GitOps наконец-то предоставил нам рабочее решение для откатов. Откатите коммит, который привел к возникновению проблемы, и все проблемы исчезнут. Проблема решена. Конец разговора, верно?

Горькая правда

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


Декларативное управление ресурсами прекрасно работает для stateless ресурсов, таких как контейнеры. Способ, которым Kubernetes обрабатывает развертывания, сервисы и другие ресурсы, идеально подходит для GitOps.


Рассмотрим, как работает типичный процесс развертывания:


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

Но будет ли это работать для ресурсов с состоянием stateful, таких как базы данных? Предположим, что нам нужно изменить схему базы данных. Можем ли мы применить те же принципы для отката миграции схемы? Вот как это может выглядеть:


  • Создается новая база данных с новой схемой.
  • Выполняются проверки работоспособности, чтобы убедиться, что новая схема работоспособна.
  • Трафик постепенно перенаправляется на новый экземпляр базы данных.
  • Старая база данных удаляется.

Это могло бы сработать... но, вероятно, вскоре после этого вас бы уволили.


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


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


Ресурсы с состоянием, такие как базы данных, требуют другого подхода к управлению изменениями.

Миграции вверх и вниз

Распространенная практика управления изменениями схемы в базах данных — использование скриптов миграции up и down вместе с инструментом миграции (таким как Flyway или Liquibase). Идея проста: когда вы хотите внести изменения в схему, вы пишете скрипт, который описывает, как применить изменение (миграция "вверх"). Дополнительно вы пишете скрипт, который описывает, как отменить изменение (миграция "вниз").


Например, вы хотите добавить столбец с именем short_bio в таблицу users. Ваш скрипт миграции "вверх" может выглядеть так:

ALTER TABLE users
    ADD COLUMN short_bio TEXT;

А ваш скрипт миграции "вниз" — так:

ALTER TABLE users DROP COLUMN short_bio;

В теории эта концепция обоснована и удовлетворяет требованиям полного управления конфигурацией. Вся информация, необходимая для применения и отката изменений, хранится в системе контроля версий.


Однако теория, как всегда, сильно отличается от практики.

Миф о миграциях вниз

Несмотря на широкое принятие концепции, миграции вниз редко применяются на практике. Почему?


Наивные предположения


Когда вы пишете скрипт, вы по сути создаете сценарий, который будет выполнен в будущем для отката изменений. По определению, этот скрипт пишется до того, как изменения "вверх" применяются. Это значит, что миграция "вниз" основана на предположении, что изменения будут выполнены корректно.


Но что, если не так?


Допустим, миграция "вверх" должна была добавить два столбца, а файл "вниз" предназначен для удаления этих двух столбцов. Но что, если миграция выполнена частично, и только один столбец был добавлен? Запуск файла миграции "вниз" приведет к сбою.


Да, некоторые базы данных, такие как PostgreSQL, поддерживают транзакционный DDL, что означает, что если миграция не удается, изменения откатываются, и вы получаете состояние, соответствующее определенной версии. Но даже для PostgreSQL некоторые операции не могут быть выполнены в рамках транзакции, и база данных может оказаться в несогласованном состоянии.


Для MySQL, которая не поддерживает транзакционный DDL, ситуация еще хуже. Если миграция прерывается на полпути, вы остаетесь с частично выполненной миграцией и без возможности отката.


Потеря данных


Если вы работаете с локальной базой данных, механизм миграции "вверх/вниз" может ощущаться как нажатие на кнопки "Отменить" и "Повторить" в текстовом редакторе. Но в реальной среде с реальным трафиком это не так.


Если вы успешно внедрили миграцию, добавляющую столбец в таблицу, а затем решили ее откатить, обратная операция (DROP COLUMN) не просто удаляет столбец. Она удаляет все данные в этом столбце. Повторное применение миграции не вернет данные, так как они были потеряны.


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


Несовместимость с современными практиками развертывания


CD и GitOps выступают за то, чтобы процесс доставки ПО был автоматизированным и повторяемым. Это означает, что процесс развертывания должен быть детерминированным и не требовать ручного вмешательства. Обычно для этого нужен конвейер, который получает коммит и автоматически развертывает артефакты сборки из этого коммита в целевую среду.


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


Теоретически откат развертывания должен быть таким же простым, как развертывание предыдущей версии приложения. Что касается версий нашего кода приложения, это работает идеально. Мы скачиваем и разворачиваем контейнерный образ, соответствующий предыдущей версии.


Эта стратегия не работает для базы данных по двум причинам:


  1. Для большинства инструментов миграции down или rollback — это отдельные команды. То есть, механизм развертывания должен знать текущую версию целевой базы данных, чтобы решить, выполнять миграцию "вверх" или "вниз".
  2. Когда мы извлекаем артефакты из предыдущей версии, они не содержат файлов "вниз", которые нужны для отката изменений базы данных обратно к необходимой схеме.

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

Миграции "вниз" и GitOps

Возвращаясь к нашей основной теме — исследованию, могут ли откаты базы данных быть совместимы с GitOps, давайте расширим этот последний пункт.


Документация ArgoCD предполагает, что интеграция миграций схемы осуществляется с помощью Kubernetes Job, который выполняет выбранный вами инструмент миграции, и аннотирует эту задачу как PreSync-хук.


Этот образ обычно создается как часть вашего CI/CD-конвейера и будет содержать инструмент и скрипты миграции для соответствующего коммита или релиза.

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migration
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
  template:
    spec:
      containers:
        - name: migrate
          image: your-migration-image:{{ .Values.image.tag }} # Example using Helm values
      restartPolicy: Never

Когда ArgoCD обнаруживает новый коммит в Git-репозитории, он создает новую задачу (Job), которая запускает инструмент миграции. Если миграция успешна, задача завершается, и разворачивается новая версия приложения.


Это работает для миграции "вверх". Но что происходит, когда нужно выполнить откат?


Команды часто сталкиваются с двумя проблемами:


  1. Механизм развертывания не знает текущую версию целевой базы данных, и, следовательно, не может решить, следует ли выполнять миграцию "вверх" или "вниз". Если команда не продумала это и не реализовала внутри образа механизм для принятия решения, механизм развертывания всегда будет пытаться выполнить миграцию "вверх".
  2. Образ, который используется для отката, не содержит файлов для миграции "вниз", которые необходимы для возврата изменений базы данных к нужной схеме. Большинство инструментов миграции просто оставляют базу данных в текущем состоянии.

Какие последствия?


  • База данных больше не соответствует текущему коммиту в Git, что нарушает все принципы GitOps.
  • Команды, которым необходимо откатить изменения в базе данных, должны вмешиваться и координировать процесс.

Операторы: Путь GitOps

Паттерн оператора — это нативный для Kubernetes способ расширения API Kubernetes для управления дополнительными ресурсами. Операторы обычно включают два основных компонента: определение пользовательского ресурса (CRD), который определяет новый тип ресурса, и контроллер, который отслеживает изменения этих ресурсов и предпринимает соответствующие действия.


Паттерн оператора идеально подходит для управления состоянием ресурсов, таких как базы данных. Расширяя API Kubernetes новым типом ресурса, представляющим схему базы данных, мы можем управлять изменениями способом, дружественным к GitOps. Специализированный контроллер может отслеживать изменения этих ресурсов и применять необходимые изменения к базе данных, в то время как наивная задача (Job) не может этого сделать.

Оператор Atlas

Atlas — это оператор Kubernetes, который позволяет управлять схемами баз данных непосредственно из кластера Kubernetes. Оператор Atlas расширяет API Kubernetes для поддержки управления схемами баз данных.


Возможности, которые помогают создать решение для управления схемами, подходящее для GitOps:


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

Декларативные vs Версионные потоки

Atlas поддерживает два типа потоков для управления изменениями схемы базы данных: декларативные и версионные. Эти потоки отражены в двух основных ресурсах, которыми управляет оператор Atlas:


Декларативный: AtlasSchema


С помощью AtlasSchema вы определяете желаемое состояние схемы базы данных декларативным образом, а также строку подключения к целевой базе данных.


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

apiVersion: db.atlasgo.io/v1alpha1
kind: AtlasSchema
metadata:
  name: myapp
spec:
  url: mysql://root:pass@mysql:3306/example
  schema:
    sql: |
      create table users (
        id int not null auto_increment,
        name varchar(255) not null,
        email varchar(255) unique not null,
        short_bio varchar(255) not null,
        primary key (id)
      );

Когда ресурс AtlasSchema применяется к кластеру, оператор Atlas вычислит разницу (diff) между базой данных по указанному URL и желаемой схемой, и сгенерирует необходимые миграции, чтобы привести базу данных к нужному состоянию. Каждый раз, когда ресурс AtlasSchema обновляется, оператор пересчитывает разницу и применяет необходимые изменения.


Версионный поток: AtlasMigration


С помощью AtlasMigration вы определяете точную миграцию, которую хотите применить к базе данных. Оператор отвечает за применение всех необходимых миграций, чтобы привести схему базы данных к желаемому состоянию.


Вот пример ресурса AtlasMigration:

apiVersion: db.atlasgo.io/v1alpha1
kind: AtlasMigration
metadata:
  name: atlasmigration-sample
spec:
  url: mysql://root:pass@mysql:3306/example
  dir:
    configMapRef:
      name: "migration-dir" # Ref to a ConfigMap containing the migration files

Оператор Atlas применяет миграции из каталога, указанного в поле dir, к базе данных по адресу url. Как и классические инструменты миграции, Atlas использует таблицу метаданных в целевой базе данных, чтобы отслеживать, какие миграции применены.

Откаты с помощью оператора Atlas

Оператор Atlas спроектирован для обработки откатов в стиле GitOps. Именно здесь раскрывается сила паттерна оператора, так как он может принимать сложные и разумные решения о том, как обрабатывать изменения управляемых ресурсов.


Чтобы откатить изменение схемы в среде, управляемой ArgoCD, достаточно просто вернуть ресурс AtlasSchema или AtlasMigration к предыдущей версии. Затем оператор Atlas проанализирует изменения и сгенерирует необходимые миграции, чтобы вернуть схему базы данных в нужное состояние.

Преимущества паттерна оператора

Выше мы неоднократно говорили о крайних случаях, возникающих при откате изменений схемы базы данных, и пришли к выводу, что они требуют ручного рассмотрения и вмешательства. А что, если мы могли бы автоматизировать этот процесс?


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


  1. Понимание намерения. Оператор может различать миграции вверх и вниз. Сравнивая текущее состояние базы данных с желаемой версией, оператор решает, нужно ли выполнять миграцию вверх или вниз.
  2. Доступ к необходимой информации. Оператор хранит метаданные о последнем выполнении в ConfigMap через Kubernetes API. Эти метаданные позволяют оператору выполнять откат миграции, даже если образ не содержит информацию о текущем состоянии.
  3. Интеллектуальное сравнение (Diffing). Так как оператор построен на базе движка Schema-as-Code от Atlas, он может правильно рассчитывать миграции, даже если база данных находится в несогласованном состоянии.
  4. Проверки безопасности. Оператор может проанализировать миграцию и определить, безопасно ли её применять. Это критическая функция, которая может предотвратить применение рискованных миграций. В зависимости от вашей политики, оператор может даже требовать ручного подтверждения для определённых типов изменений.

Заключение

Мы рассмотрели вызовы, связанные с откатом изменений схемы базы данных в среде GitOps. Обсудили ограничения традиционного подхода с миграциями вверх/вниз и то, как паттерн оператора может быть использован для построения более надёжного и автоматизированного решения.

Ответим на все вопросы по переносу, сборке, оркестрации и развертывании.
Настроим ваш Kubernetes