Эссе о разработке игр, мышлении и книгах

О миграциях backend

Недавно смотрел чего за последние годы сделано в области решений для миграций схем и данных в базах данных и как-то мне все решения не понравились. По крайней мере из open source ни одного проекта без косяков нюансов не нашёл.

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

Я предполагаю, что вы более-менее понимаете суть миграций в БД и сможете ориентироваться в моих допущениях: явных и неявных.

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

Что такое миграции

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

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

Распространение изменений на зависимые сущности мы и будем называть миграцией.

Для удобства мы храним историю применения миграций. Как минимум, версию последней миграции.

Для примера посмотрим подробнее на базы данных.

Миграции баз данных

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

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

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

Традиционно выделяют:

  • Миграции схемы изменяют только мета-структуру базы: таблицы, колонки, etc.
  • Миграции данных изменяют данные в базе, но не трогают схему. Они необходимы для сложных преобразований, которые не получается оформить в виде sql запроса.

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

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

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

Миграции в широком смысле

Не базой единой жив backend и я специально определил миграции шире.

Кроме базы миграции могут потребоваться для:

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

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

В общем случае, было бы интересно считать миграцией даже обновление версии кода.

Польза от миграций

Выделение миграций в качестве отдельной абстракции позволяет их автоматизировать — делать одним из унифицированных шагов применения изменений.

Автоматизация позволяет:

  • Тестировать изменения, в том числе автоматически.
  • Избегать ошибок из-за человеческого фактора.
  • Поддерживать историю изменения экземпляра проекта: знать что, когда и как менялось.
  • Откатывать изменения, если очень надо. С этим есть практические нюансы, но в целом такая возможность остаётся.
  • Упрощать подготовку тестовых окружений и переключение между ними.

Автоматическое тестирование

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

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

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

Человеческий фактор

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

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

Конечно, это решается чек-листами. Но люди лажают, даже при наличии чек-листов.

История изменений

История полезна, в том числе, для отслеживания причин изменений, проявляющихся только в исторической перспективе. Например, изменения картины метрик.

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

Но в живую я такой дружной дисциплинированности среди разработчиков не видел никогда.

Откат изменений

Будем честны, откатить изменения на проде не всегда возможно:

  • дорого остановить сервис «ещё на сутки»;
  • изменения данных в принципе необратимы;
  • откатывать изменения страшнее, чем написать новый патч.

Но, что не подходит для прода, может подойти для окружения разработчика.

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

Переключение окружений

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

Это не единственный вариант. Можно всегда готовить окружения с нуля, но:

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

Переключения версий проекта в одном окружении решение не идеальное, но относительно дешёвое и работающее. Особенно для небольших команд.

Кроме того.

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

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

Переключение версий окружений — это хорошая фоновая проверка работоспособности миграций.

Нюансы миграций

В том порядке, в котором я их вспоминал — без приоритета.

Нет решений для гетерогенных окружений

Сервисы усложняются, становятся более гетерогенными. Данные сервиса могут быть размазаны по двум, трём и более сущностям.

Ситуация с решениями для миграций сейчас примерно следующая:

  • много решений для миграции схем БД;
  • куда меньше решений с миграцией данных БД;
  • ещё меньше решений для применения миграций к нескольким БД;
  • для миграции данных вне специфических БД и хранилищ решения есть не всегда.

Из-за этого разработчикам приходится проводить миграции в ручном или полуавтоматическом режиме. Как на production, так и на тестовых серверах. Это отнимает время и увеличивает вероятность ошибок.

Простой при применении миграции

Для применения миграций часто приходится останавливать сервис.

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

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

Надо либо создавать две версии кода: с миграцией вперёд и с миграцией обратно, либо готовиться в экстренном порядке дописывать обратные миграции по-живому.

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

Длительные сроки миграций

Как дополнение к предыдущему пункту.

Многие миграции на запущенной в эксплуатацию системе требуют слишком много времени на применение из-за количества данных.

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

Такие миграции сложно тестировать.

Длительные последовательности миграций

Старый проект накапливает огромную историю миграций. Само их применение, например, для тестов, может съедать много времени.

Поэтому историю миграций приходится переписывать:

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

Не может быть идеальной системы миграций

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

Чем меньше команда, чем проще проект и процессы, тем меньше пользы система миграций будут приносить.

Необходимо избегать внешних зависимостей

Миграции выполняются актуальной версией кода.

Старый проект за свою историю меняет много зависимостей: библиотек, сервисов. Если зависимости будут использованы миграциями, то придётся:

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

Оба варианта отнимают время.

Миграции иерархичны

Возможно, из-за этого пока не появилось общее решение.

Для демонстрации идеи, приведу пример иерархии:

  1. Миграция схемы базы данных специфична для БД, применяется только к ней и не имеет внешних зависимостей. Почти ничего не знает о сервисе, который работает с базой.
  2. Миграция данных в хранилище будет зависеть от кода и от схемы данных, поверх которой применяется. Она должна знать о семантике данных проекта.
  3. Миграция данных между хранилищами, или требующая одновременной обработки нескольких хранилищ, должна знать о проекте ещё больше. Она будет зависеть от истории миграций каждого хранилища в отдельности.
  4. Миграция, затрагивающая нескольких сервисов очевидным образом будет зависеть от истории миграций каждого сервиса в отдельности. Например, для миграции сервиса A требуется API новой версии сервиса B, для запуска которой нужны дополнительные миграции сервиса B. Такая миграция должна знать уже не только о сервисе, но и о топологии сети, в которой он находится.

Чем это чревато?

Во-первых, не ясно где хранить информацию об истории применения миграций. Для миграций БД её хранят в БД и это удобно. Но этот подход не переносится на высокие уровни миграций.

Во-вторых, сложно гарантировать атомарность миграций высокого уровня.

В-третьих, неавтоматизированные миграции высоких уровней «ломают» автоматизацию миграций низкого уровня — могут требовать их приостановки.

Например, может потребоваться следующая последовательность миграций:

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

Если у нас автоматизированы миграции схемы, но не автоматизированы миграции данных, мы должны разбить обновление на три этапа:

  • выполнить миграцию схемы данных до версии X;
  • выполнить специализированный скрипт миграции данных;
  • выполнить миграцию схемы данных до версии Y;

Вместо одного этапа «мигрировать до конца».

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

Можно выделить и другие уровни миграций. Например, в Django миграции существуют на уровне модуля Python — приложения Django.

Хранение истории миграций

Историю миграций надо хранить и с этим тоже не всё просто.

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

Не ясно также что конкретно хранить:

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

Альтернатива

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

  • Все изменения схем и форматов контролировать в коде сервисов.
  • Если миграции нужны, использовать только недеструктивные инструменты.

Недеструктивными инструментами, для примера, можно считать:

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

Это действенный подход, но со своими проблемами. Я бы описал его так: круче, но дороже.

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

  • Требуются отлаженные процессы и соответствующая квалификация сотрудников.
  • Внедрение изменений становится дороже и дольше.
  • Ограничивается или пропадает возможность отката изменений. В некоторых случаях это усложняет работу.
  • Усложняется параллельная работа над общими частями проекта.
  • Необходимо контролировать количество одновременно идущих в production миграций и подчищать старые.
  • Ошибки при обновлении чаще приходится исправлять патчами к коду.

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

Что я хочу от системы миграций

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

Масштабируемость

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

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

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

Гибкость

Я бы хотел использовать одну систему миграций для разных технологий: хранилищ, сервисов, языков программирования.

По нескольким причинам.

Неудобно при смене стека менять и все сопутствующие инструменты — изменений и так достаточно.

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

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

Без жёстких требований к технологиям

Как продолжение предыдущих двух пунктов.

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

Например, хранить историю миграций должно быть возможно:

  • в какой-нибудь БД общего назначения;
  • в SQLite, если такой БД не используется;
  • в кастомном хранилище, если разработчик напишет адаптер для него.

Мягкий контроль

Система не должна ограничивать технологии, но должна ограничивать сами миграции.

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

Если разработчик указал дополнительные требования к миграции (например, что сервисы A, B, C должны быть остановлены), они должны быть проконтролированы.

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

Отслеживание согласованности сущностей

Представим, что изменения проскользнули мимо миграций:

  • пришлось ASAP произвести их руками;
  • в окружении разработчика тот сбросил состояние одной и баз.

В этом случае система должна определить нарушение и предупредить об этом.

Детализированность

Необходима информация об актуальном состоянии окружения и истории его изменения:

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

В такой лог могли бы вноситься не только операции «программистов», но и, например:

  • маркетологов, отмечая начало и окончание акций;
  • devops, отмечая изменения в инфраструктуре.

Инструментарий для редактирования миграций

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

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

Это делать не надо

Эти вещи система миграций, на мой взгляд, делать не должна:

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

Какой я вижу систему миграций

Каждая миграция — консольная утилита

Скрипт или что-то компилируемое — не важно. Со стандартным интерфейсом командной строки.

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

Это позволит отвязать систему от технологий проекта:

  • Если вы хотите мигрировать схему базы — пишите простой скрипт на SQL.
  • Если вы хотите сделать хитрую логику на вашем ЯП — пишите на ЯП удобном для этой задачи.

При этом разработчик может взаимодействовать с инфраструктурой наиболее удобным ему образом. Например:

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

Продвинутое управление общим состоянием

Контроль состояния сущностей

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

  • базах данных;
  • специализированных хранилищах;
  • версиях кода;
  • etc.

Это позволит, в том числе, прописывать зависимости миграций между сущностей. Что уберёт риск рассинхронизации запуска миграций.

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

Логирование операций

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

Например: обновление версии кода, без изменения версии данных; любые ручные манипуляции «по-горячему»; изменение топологии сети, роутинга.

Будет полезно:

  • получать лог операций через стандартное API;
  • стримить новые операции в сервисы сбора логов / метрик.

Аналитикам лог изменений тоже будет полезен.

Скрытые миграции

Часть миграций может оказаться нельзя сделать в коде миграции или даже кодом.

Например:

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

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

Веб интерфейс и API

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

Или отдельный сервис для передачи информации о миграциях во внешнюю среду.

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

Но как такую штуку удобно использовать вкупе с множеством окружений разработчиков?

Итого

Если подумать, учитывая предыдущий раздел, я хочу скорее систему логирования и контроля изменений (git? :-D) с обвесками, чем конкретно систему работы с миграциями.

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

Если вы знаете, что такое уже существует, напишите — буду благодарен.