Практика Continuous Deployment подразумевает полную автоматизацию поставки изменений в продакшн при публикации их в основной ветке репозитория. Она приводит к высокой частоте релизов, что означает, что их необходимо делать бесшовно, иначе они будут снижать доступность сервиса.
Бесшовные релизы - задача нетривиальная и требует осторожного соблюдения определённого подхода. Благо, он уже обкатан на привычных всем REST-API, но для контекста мы это рассмотрим подробнее. Далее мы рассмотрим применение этого подхода к релизам схемы БД с сопутствующим инструментарием и то, как это сказывается на процессах в организации.
Оглашённое требование осторожности в сочетании с человеческим фактором неизбежно приводит к сбоям, а потому этот фактор необходимо минимизировать. Минимизируют его, как правило, лишь двумя способами: автоматизацией процессов и реорганизацией системы с целью часть процессов исключить вовсе. В данной статье мы коснёмся обоих способов с акцентом на первом.
Основные идеи в этой статье опираются на принципы первичности БД относительно приложений. Если вы с этой темой не знакомы, ожидайте в статье серьёзной смены привычной парадигмы.
В качестве БД в примерах мы подразумеваем PostgreSQL, однако основные идеи статьи универсальны.
Бесшовные релизы на примере REST API
Представим себе систему, состоящую из трёх сервисов. Два из них пользуются третьим. Это означает, что обратно-несовместимые изменения в API третьего сервиса могут привести к поломкам в остальных двух.
Эта система уже находится в продакшн, и у нас возникла необходимость сделать релиз, который требует изменения API третьего сервиса. Как это сделать, не cломав ничего, и без необходимости одновременно выкатывать обновления для всех трёх сервисов с простоем?
Подход
Устоявшийся подход для таких ситуаций - делать релиз сервиса с поддержкой как старого, так и нового API с последующим постепенным переводом потребителей на новое API.
То есть, помимо ресурса /v1
со старым API у нас возникает ресурс /v2
с новым. При этом, старое API адаптирует обновлённый сервис под прежний функционал. Сделать такой релиз без простоев не составляет проблем.
Очевидно, что поддерживать таким образом различные версии обременительно и является сковывающим фактором для развития сервиса. Поэтому после такого релиза стартует этап перевода всех потребителей сервиса на новое API. Выпускать потребителей без простоя тоже не составляет проблем.
В итоге мы добираемся до стадии, когда все потребители /v1
исчезли и у нас возникает возможность снять данное обременение с нашего сервиса, удалив весь код, обеспечивающий данную версию API. Делаем очередное развёртывание сервиса с поддержкой только /v2
всё так же без простоев.
Для чего я это рассказал? Дело в том, что ничего в данной тактике не привязывает нас к непосредственно REST. Она универсальна и применима ко всему, что имеет интерфейс для взаимодействия. Далее мы рассмотрим применение неё для выпуска обновлений схемы БД.
Применяем к БД
Представим себе, что в уже развёрнутой в продакшн БД нам нужно переименовать одну из таблиц. Как осуществить релиз так, чтобы запущенные приложения не поломались, и без простоев как БД так и приложений?
Для этого точно так же потребуется разделить данную трансформацию на несколько релизов. Как вариант, можно сделать так:
Добавить в схеме поддержку таблицы с новым названием, создав проекцию на старую таблицу:
begin;
create view new_table as select * from old_table;
commit;
Перевести все приложения на использование только нового названия таблицы.
Удалить проекцию и переименовать старую таблицу в новую:
begin;
drop view new_table;
alter table old_table rename to new_table;
commit;
Как видите, по сути, мы осуществили те же самые действия, как и в случае с REST. На первом шаге мы добавили поддержку нового функционала, сохранив обратную совместимость с прежним. Далее перевели всех пользователей на использование только нового функционала. И потом избавились от функционала старого.
Для трансформации БД мы использовали миграции. Обновление продовой БД с помощью миграций можно автоматизировать, встроив в CI/CD-конвейер с помощью такого инструментария, как liquibase, flyway, sqitch. Это устоявшаяся практика и на эту тему вы найдёте множество статей, поэтому заострять на этом внимание не будем.
Самое сложное сокрыто во втором пункте. Для его реализации необходимо пройтись по всем запросам в приложениях и изменить названия. Если у нас лишь одно приложение и все запросы живут в изолированном слое и уже покрыты тестами, опирающимися на миграции, это может пройти гладко, но в остальных случаях нас ждут трудности и риски выхода в прод с поломанной интеграцией с БД.
Тестирование интеграции с БД
Итак, мы выявили необходимость плотно тестировать слой интеграции приложений с БД при обратно-несовместимых изменениях в её схеме.
Идеальный набор тестов позволит нам убедиться, что запросы:
исполняются без ошибок от БД
оперируют на ожидаемых приложением типах данных в параметрах и результатах
эффективно используют индексы
производят ожидаемые результаты по содержанию
приводят к ожидаемым изменениям в данных, если являются мутирующими
Звучит трудоёмко, поэтому, наверняка, на практике мы будем срезать углы. В общем-то и список этот отсортирован в соответствии с убывающей вероятностью реализации.
Критически важно - чтобы ошибок от БД не было и чтобы приложение не останавливалось из-за того, что, скажем, получает int4
там, где ожидает uuid
. Отсутствие же ошибок для нас исключит самые вероятные причины для сбоев от опечаток до ссылок на несуществующие таблицы и колонки в запросах.
Отсутствие индекса часто можно пережить, так как, скорее всего, приложение, хоть и станет работать медленнее, но не остановится совсем, да и среагировать на это можно оперативно, если у вас настроен мониторинг.
Чтобы убедиться, что запросы ведут себя так, как мы ожидаем, нужны сложные тесты, требующие симуляции множества комплексных сценариев по принципу "если такая-то запись в БД имеет такое-то значение, то вызов данного запроса приведёт к таким-то последствиям". Они, несомненно, могут оказаться важны, но писать их кропотливо и они часто перекрываются функциональными тестами на уровне API приложения или компоненты.
Так вот, самые важные из перечисленных проверок возможно полностью автоматизировать без необходимости писать какие-либо тесты, и осуществлять эти проверки возможно за доли секунды.
Автоматизация проверок по подходу DB-first
Давайте задумаемся: какую минимальную информацию потребовалось бы передать коллеге, если бы мы хотели его попросить проверить SQL-запросы на корректность и совместимость со схемой БД? Ответ очевиден: нужны сами запросы и описание схемы БД. Раз этой информации достаточно для человека, то должно быть достаточно и для программы автоматизации данной задачи.
С помощью миграций возможно воссоздать БД с соответствующей структурой, а, имея под рукой все запросы, которые против неё планируется делать, возможно исполнить каждый и удостовериться в соответствии схеме, достать метаданные о параметрах и результатах и провести анализ с помощью запросов вроде EXPLAIN
.
В итоге, полностью автоматически можно удостовериться, что запросы:
исполняются без ошибок от БД
оперируют на ожидаемых приложениями типах данных в параметрах и результатах
эффективно используют индексы
Отлично! Но теперь мы имеем дело с набором запросов, полностью отделённых от кода приложения. Встаёт вопрос: как это всё интегрировать и как выстраивать работу?
Генерация кода
Информации, получаемой из миграций и набора запросов, достаточно и для того, чтобы сгенерировать SDK, исполняющее данные запросы и транслирующее их в соответствующие типы параметров и результатов.
И вот лишь часть возможностей, которые с этим открываются:
SDK возможно генерировать для любого языка программирования, имеющего драйвер PostgreSQL. То есть, предоставляя файлы SQL, на выходе вы можете получить готовые интеграции для изобилия языков, которые могут применяться в вашей компании: от вездесущего Java до экзотичного Haskell.
Потенциально, работать с таким SDK должно быть значительно проще, чем с любым фреймворком, так как оно по определению заточено под домен вашей БД и не имеет задачи подходить к другим, а значит домен уже и сложности меньше.
По той же причине оно может принципиально сократить возможности стрелять себе в ногу для пользователей, оставляя возможными только сценарии использования, соответствующие домену.
Проблема Object-relational Impedance Mismatch самоустраняется, так как запросы один в один проецируются в методы или процедуры целевого языка, – ведь что такое запрос, если не параметризуемая процедура, возвращающая результаты?
Сгенерированный SDK мы подключаем как библиотеку в свой проект и впредь общаемся с БД из приложения через него. Изменения запросов осуществляем в коде запросов и перегенерируем SDK. Когда добавляем миграцию - тоже перегенерируем SDK. Никогда SDK сами не редактируем.
Таким образом, мы получаем чёткую границу между кодом БД и кодом приложения. SQL-запросы живут вместе с миграциями схемы, а не размешаны в коде приложения. Приложение же опирается на артефакты кодогенератора, который работает, опираясь на код SQL.
В итоге мы получаем следующий процесс поставки изменений.
Процесс поставки изменений по ролям
¹ Push в master - это процедура PR/MR со своими проверками на ревью и в CI, в случае успеха которых происходит влитие изменений в master. Это может быть и прямой push в эту ветку. Всё зависит от политик в организации.
² Хранилище библиотек - это Artifactory, Maven, NuGet, Hackage, S3, Git-репозиторий или что угодно, специфичное для вашего языка и организации.
На диаграмме выше видно чёткое выделение зоны ответственности за БД на процессном уровне. Также мы уже успели отметить, что на уровне кода это разделение тоже очевидно.
Конечно, обе роли разработчиков могут заполняться одними и теми же людьми. Однако, теперь у нас возникает возможность передать разработку БД в зону ответственности специалистов, которые разбираются в её тонкостях, а не поверхностно, как большинство разработчиков приложений. За счёт одного этого действия мы одновременно ускорим разработку и сократим частоту инцидентов, связанных с некорректным использованием БД. При этом, от специалистов БД не будет требоваться знание чего-либо о языке программирования, для которого будет генерироваться SDK.
Также обращаю внимание, что SDK может генерироваться для множества языков программирования приложений одновременно, что даёт возможность оптимизировать работу сразу для нескольких отделов, если они пользуются одной БД.
Предметно
До этого момента рассуждение в статье шло в гипотетическом пространстве "а что если бы мы работали по такому-то подходу и обладали необходимым инструментарием". Дело в том, что я представляю компанию dbfirst.ru, в которой такой инструментарий и разрабатывается. Теперь давайте рассмотрим предметное решение обсуждаемой проблемы с его помощью.
Примеры использования dbFirst для проверок в CI
В этой секции мы создадим проект и рассмотрим применение компилятора dbfirst
для выявления ошибок в различных ситуациях и то, как эти проверки позволили бы нам построить надёжный CI/CD-пайплайн.
Создание проекта
Создадим новый репозиторий, в нём создадим конфигурационный файл проекта по пути ./project.dbfirst-v1.yaml
со следующим содержанием:
# Неймспейс для ваших проектов
space: my-space
# Название данного проекта
name: music-catalogue
# Версия данного проекта
version: 1.0.0
Также по пути ./migrations/1.sql
создадим первую миграцию:
create table "artists" (
"id" int4 not null generated always as identity primary key,
"name" text not null,
"genre" text null
);
И пару запросов:
./queries/insert-artist.sql
:
insert into artists (name, genre)
values ($name, $genre)
returning id
./queries/select-artist-by-id.sql
:
select name, genre
from artist
where id = $id
Прогоним компилятор dbfirst
и посмотрим на результат:
> dbfirst
relation "artist" does not exist
Context:
queries/select-artist-by-id
Компилятор обнаружил ошибку в одном из запросов. Исправим запрос ./queries/select-artist-by-id.sql
:
select name, genre
from artists
where id = $id
Прогоним компилятор заново и посмотрим на результат:
> dbfirst
Ok
Таким образом мы увидели первую ценность: компилятор помогает избежать ошибок в процессе написания запросов.
Генерация кода
Дополним конфигурационный файл ./project.dbfirst-v1.yaml
до следующего состояния:
space: my-space
name: music-catalogue
version: 1.0.0
# Какие SDK генерировать
artifacts:
- java-jdbc-v1
- haskell-hasql-v1
И прогоним компилятор заново:
> dbfirst
Ok
Взглянем в возникшую папку artifacts
и обнаружим в ней готовые SDK для Java и Haskell.
Для примера, вот так будет выглядеть один из сгенерированных методов в SDK для Java:
public InsertArtistResultRow insertArtist(Optional<String> genreParam, String nameParam) throws SQLException {
insertArtistStatement.setString(1, nameParam);
if (genreParam.isPresent()) {
String genreParamPresent = genreParam.get();
insertArtistStatement.setString(2, genreParamPresent);
} else {
insertArtistStatement.setNull(2, Types.VARCHAR);
}
insertArtistStatement.execute();
try (ResultSet resultSet = insertArtistStatement.getGeneratedKeys()) {
if (resultSet.next()) {
int idCol = resultSet.getInt(1);
return new InsertArtistResultRow(idCol);
} else {
throw new SQLException("Not a single row produced");
}
}
}
Так для Haskell:
insertArtist :: Statement Model.InsertArtistParams Model.InsertArtistResultRow
insertArtist =
Statement sql encoder decoder True
where
sql =
"insert into artists (name, genre)\n\
\values ($1, $2)\n\
\returning id"
encoder =
(Model.insertArtistParamsName >$< (Encoders.param (Encoders.nonNullable Encoders.text))) <> (Model.insertArtistParamsGenre >$< (Encoders.param (Encoders.nullable Encoders.text)))
decoder =
Decoders.singleRow $
Model.InsertArtistResultRow <$> (Decoders.column (Decoders.nonNullable Decoders.int4))
Проверка, что изменения миграций не ломают существующих клиентов
Представим, что мы назвали таблицу artists
по ошибке, и теперь хотим переименовать её в artist
, но БД уже в продакшн, и нам нужен бесшовный релиз. Та самая ситуация, которую мы уже описывали выше.
Представим, что произойдёт, если мы станем решать эту проблему в лоб, просто переименовав таблицу. Добавим следующую миграцию в файле ./migrations/2.sql
:
alter table artists rename to artist;
Запустим компилятор:
> dbfirst
relation "artists" does not exist
Context:
queries/insert-artist
Иными словами, компилятор нам сообщает, что мы нарушили контракт, ожидаемый запросами, и, как следствие, прежде сгенерированными SDK. Соответственно, помимо помощи при разработке, это нам открывает возможность удостоверяться при Continuous Deployment, что мы не поломаем клиентов, перед накатыванием миграции в прод.
Исправим миграцию ./migrations/2.sql
на:
create view artist as select * from artists;
И прогоним компилятор:
> dbfirst
Ok
Представим, что на этом месте наш CD накатил миграцию.
Далее обновим клиентов. Для этого изменим запросы:
./queries/insert-artist.sql
:
insert into artist (name, genre)
values ($name, $genre)
returning id
./queries/select-artist-by-id.sql
:
select name, genre
from artist
where id = $id
И прогоним компилятор:
> dbfirst
Ok
На данном месте CD опубликовал наши SDK, и мы получили возможность уйти обновлять приложения-клиенты. По завершении этой процедуры подчищаем в БД:
./migrations/3.sql
:
begin;
drop view artist;
alter table artists rename to artist;
commit;
Слушаем компилятор:
> dbfirst
Ok
И тут происходит очередное автоматическое накатывание схемы.
Проверка типов
Если вы обратили внимание, в сгенерированном коде типы данных параметров и результатов были выведены автоматически. На вход мы лишь предоставили миграции и нетипизированные запросы. С одной стороны, это удобно. С другой, это означает, что компилятор нам не сможет помочь определить изменение типов в схеме. Рассмотрим эту проблему предметно и найдём решение ей.
Добавим очередную намеренно инвазивную миграцию, меняющую тип колонки genre
с text
на int8
:
./migrations/4.sql
:
begin;
alter table artist
drop column genre;
alter table artist
add column genre int8;
commit;
Прогоняем компилятор и видим:
> dbfirst
Ok
Но для нас это совсем не ОК. Если мы выкатим такую миграцию в прод, все запущенные клиенты начнут выдавать ошибки при попытке исполнить запрос insert-artist
, так как в колонку genre
они будут подставлять текстовое значение, когда БД будет ожидать int8
. Аналогичные проблемы нас ждут и с результатом запроса select-artist-by-id
.
Как заставить компилятор определять и такие ошибки?
Явные сигнатуры
Способ, который работает уже сейчас - самим жёстко фиксировать типы данных параметров и результатов в запросах.
Например, если бы наш запрос ./queries/insert-artist.sql
выглядел следующим образом:
insert into artist (name, genre)
values ($name :: text, $genre :: text)
returning id :: int8
то компилятор выдал бы нам следующую ошибку:
column "genre" is of type integer but expression is of type text
Context:
queries/insert-artist
Однако, из-за наличия неявных конвертаций в Postgres строгость таких проверок не абсолютная. Например, если бы мы написали id :: int2
вместо id :: int8
, компилятор бы не заметил в этом проблем. Стоит заметить, правда, что и приложение бы не выдавало ошибок в рантайме, так что поставленная задача решается. Однако, это стоит учитывать, так как подобные конвертации могут привести к обрезанию значений.
Ещё один нюанс: компилятор не может принудить указывать сигнатуры, а потому указываем мы их или нет, остаётся на откуп дисциплине.
Генерация контракта
Мы рассматриваем возможность разработки функционала генерации файлов контракта, в котором бы хранилась информация о сигнатурах запросов с учётом nullability и, возможно, дополнительная мета-информация о проекте. Подразумевается, что это позволило бы проводить строгие проверки проекта на соответствие ожидаемому контракту, который, в частности, можно было бы использовать в CI/CD.
Если у вас есть мнение или идеи на этот счёт, прошу высказаться в комментариях.
Проверка производительности
Этот функционал пока находится в планах. Задумывается прогонять на все запросы с выборками explain
и выявлять seq-scan
. Также, на будущее мы прорабатываем возможность заполнения таблиц генерируемыми данными для более сложного анализа производительности.
Если у вас есть идеи на этот счёт, тоже призываю вас к обсуждению в комментариях.
Проверка совместимости на старте и readiness-пробы
Этот функционал тоже находится в планах. Задумывается встраивать в SDK процедуру, которая будет осуществлять проверку БД на совместимость, а именно, на соответствие ожиданиям всех запросов в SDK.
Такую проверку можно будет осуществлять на стадии инициализации приложения и блокировать направление на него трафика, если проверка не проходит, например, встроив её в readiness-пробу для Kubernetes, если вы пользуетесь им, или для его аналога. Если не пользуетесь, то такая проверка, как минимум, позволит вам быть оповещённым о грядущих проблемах на самых ранних стадиях.
Также эту проверку можно осуществлять и когда приложение уже принимает трафик, чтобы защититься от неожиданных изменений схемы в процессе работы приложения.
Пожалуйста, тоже оцените важность такой доработки в комментариях.
Развитый процесс поставки изменений
В завершение, взглянем на то, до чего можно было бы развить наш конвейер CI/CD с использованием инструментария dbFirst. Это пример, задача которого подсветить огромные возможности и гибкость в автоматизации процессов и повышения надёжности вашей системы.
О dbfirst
Описанным инструментарием вы можете начать пользоваться уже сейчас. Для этого заходите на dbfirst.ru.
Также мы оказываем консалтинг-услуги по разработке слоя интеграции с БД и внедрению подхода DB-First в компании. Если в вашей компании несколько сервисов взаимодействует с одной БД, мы можем кардинально ускорить вашу разработку.
Для прямой связи со мной пишите в Telegram: @wormholio.
Комментарии (8)
vagon333
17.04.2024 08:26+1Отслеживание изменений в БД (схема и данные), хранение в version control system и поставка на production среду не проблема. Есть опыт использования разных продуктов и подходов.
Реальные сложности с откатом изменений.У вас красивый Flow Diagram для накатки изменений.
А есть ли у вас такой же Flow Diagram для отката изменений с Production и восстановления предыдущей версии базы данных?pkokoshnikov
17.04.2024 08:26+2В целом же предлагают использовать обратносовместимые изменения при релизе. Поэтому по идее откатывать ничего не нужно. А есть какие нибудь инструменты с поддержкой отката?
vagon333
17.04.2024 08:26+1А есть какие нибудь инструменты с поддержкой отката?
Есть инструменты, в которые заложена опция для отката на предыдущую версию, но сам код отката нужно писать вручную.
Откат - процедура сложная и в некоторых случаях невозможная.
Поэтому и поинтересовался, может у вас появилась волшебная идея.pkokoshnikov
17.04.2024 08:26Нашёл у liquibase и flyway в enterprise. Буду иметь ввиду. Но в целом конечно в автоматическом режиме выглядит конечно рискованно у liquibase.
mojojojo Автор
17.04.2024 08:26Спасибо за вопрос!
Как вы сами заметили, процедура эта в некоторых случаях невозможна. Данные из удалённой таблицы откатом миграции не вернёшь. В чём тогда польза?
Я не вижу никакой, кроме способа для успокоения себя держать под рукой готовую миграцию, устраняющую последствия от накатываемой. Ну, если так уж сомневаетесь в конкретной миграции (настолько её не протестировали), держите такую миграцию наготове в MR/PR и замерджите и задеплойте через CD, если потребуется, как следующую миграцию. В большинстве же случаев вы сможете по факту написать необходимую миграцию, устраняющую последствия не многим медленнее.
Обременять разработку и автоматизацию необходимостью постоянно поддерживать обратный путь миграций, который, к тому же, мало кем полноценно тестируется, а применяется на деле практически никогда, кажется мне бессмысленным. Замечу, что я не один с таким мнением. Например, автор Postgraphile запилил альтернативный мигратор с такой же аргументацией.
Помимо сказанного замечу, что с проверками обратной совместимости, описанными в статье, вероятность наступления ситуаций, когда вам экстренно потребуется делать откат, снижается радикально.
xztv
А в чем преимущество генерации SDK из SQL по сравнению с использованием ORM с поддержкой построения авто-миграций и генерации запросов?
Плюс как вы отслеживаете актуальность версии SDK: есть две команды, одна использует версию 1.0, вторая 1.1, выпускаете версию 1.2, в которой переименовываете табличку, а какой-то из клиентов все еще использует старое название?
pkokoshnikov
Я насколько понял, этот инструмент покрывает кейс использования бд в нескольких сервисах. В целом вроде бы интересно.
mojojojo Автор
Спасибо за вопросы!
Как обеспечить обратную совместимость в авто-миграции ORM? Это то, что необходимо для бесшовных релизов и Continuous Deployment. Они должны быть такими, чтобы одновременно могла работать и старая и новая версия приложения.
Миграции через ORM усложняют деплой, если вы разворачиваете несколько инстансов приложения. Какой инстанс должен накатывать миграции?
ORM вас приковывает к одному языку программирования в качестве клиента. Иначе получите хаос в зависимостях. Например: ORM в Java имплицитно определяет схему БД, клиент БД на Python эту схему должен либо выводить из кода Java, либо из развёрнутой БД. Менять схему будет сверхдорого.
ORM ограничивает вас до примитивных запросов. Для серьёзного использования возможностей БД нужен доступ к SQL.
ORM забирает на себя контроль за производительность запросов. В нетривиальных случаях, как правило, это сбоит.
Сперва маленькая ремарка: версию
1.2
с переименованием таблицы я бы скорее назвал2.0
, квалифицируя это как обратно-несовместимое изменение по SemVer.Озвученная вами проблема решается теми же способами, как в REST-API переход с
/v1
на/v2
. Если это внутри компании, то все пользователи вам должны быть известны и можно решить коммуникацией. Дать срок на переход и дождаться отмашки, что ребята готовы. Выкатывать переименование таблички, пока есть важные системы, к этому не готовые, конечно, нельзя.Этот процесс возможно и автоматизировать различными способами. Можно, например, ввести реестр актуально используемых версий SDK, заполнять его в CI на стороне клиентов. Но звучит как overkill.
Можно косвенно мониторить актуальное использование по статистике скачиваний артефактов из какого-нибудь artifactory. В компаниях, где идёт активная разработка, как правило, актуальные артефакты скачиваются множество раз в день.