Про обновление систем «на лету» без их остановки (zero-downtime deployment) написано немало статей и многие аспекты этого подхода является достаточно очевидными. На мой взгляд, наиболее сложная часть деплоймента в этом случае — обновление хранилищ данных, в случае если их контракт (схема) изменился. Именно этот аспект я бы и хотел рассмотреть в этой статье.
Какой бы ни была база данных — с явной схемой данных как реляционные или с произвольной, как NoSQL — схема данных все равно присутствует, пусть даже на уровне приложения. Данные, прочитанные из базы, должны быть понятны клиенту, даже если само хранилице не накладывает никаких ограничений на их структуру.
Предположим, в продакшене уже работает система с определенной структурой данных и терабайтами данных в базе. В новой версии системы нам необходимо структуру немного изменить для реализации новых возможностей или улучшения производительности. Рассмотрим, какие изменения в схеме могут произойти:
- Добавление нового поля
- Удаление поля
- Переименование поля
- Изменения типа поля
- Перенос поля в другую структуру данных (например, в случае денормализации)
Добавление нового поля также как и добавление любого другого объекта базы данных — обратно-совместимое изменение и не требует никаких дополнительных шагов в плане реализации zero-downtime deployment. Достаточно просто применить изменения в базе «на лету», после чего задеплоить новую версию кода, которая использует новые объекты базы данных.
Удаление поля или любого другого объекта базы данных не является обратно-совместимым изменением, но подход к его реализации очень прост — ненужные объекты базы данных должны быть удалены только после того, как новая версия системы полностью задеплоена.
Остальные три типа изменений более сложны в плане обеспечения zero-downtime deployment. В целом, все они могут быть выполнены копированием данных в другие поля/сущности, и удалением «старых» после успешного завершения миграции данных: для переименования можно скопировать данные из старого поля в поле с новым именем, после чего удалить старое поле, изменение типа данных можно сделать вместе с переименованием и т.д. Так или иначе, в течение некоторого периода времени, база данных должна поддерживать как старый так и новый контракты. Есть как минимум два способа выполнить такие изменения «на лету»:
Если база данных поддерживает триггеры
- Создать триггеры, которые копируют данные из старого места в новое на любом изменении/добавлении и установить их на продакшене.
- Применить утилиту для конверсии данных, которая делает то же самое, но для всех записей в базе. Так как триггеры уже установлены, утилита может не делать ничего более сложного, чем просто «фиктивное» обновление каждой записи (UPDATE table SET field = field …). Очень важный момент здесь — что действие по чтению данных из старого места и запись в новое должно быть атомарным и защищенным от потерянных изменений. В зависимости от структуры базы можно воспользоваться либо пессимистической блокировкой через SELECT FOR UPDATE или его аналоги, либо оптимистической, если в таблицах есть поле с версией записи.
- После того как утилита закончит свою работу (в зависимости от объема данных и сложности обновления время выполнения может исчисляться днями) уже можно устанавливать новую версию системы, которая поддерживает новую схему данных. К этому моменту все записи в базе, существовавшие на момент запуска утилиты, будут успешно сконвертированы, а все новые, появившиеся во время ее работы — также сконвертированы триггерами.
- Удалить триггеры и все поля (или другие объекты базы данных), которые больше не нужны.
Если нет возможности использовать триггеры (как в случае со многими NoSQL решениями)
- Создать и задеплоить новую версию приложения (временная версия 1 на рисунке), которая всегда читает из старого поля, но при записи в это поле обновляет как и старое, так и соответствующее новое место (на рисунке «С» — старое, «Н» — новое). Задеплоить эту версию на все узлы, на которых работают экземпляры приложения.
- Применить утилиту, которая копирует данные из старого места в новое. Как и в случае с триггерами необходимо принять меры для предотвращения потерянных изменений.
- Создать и по окончании выполнения утилиты задеплоить еще одну версию приложения (временная версия 2), которая читает данные из нового поля, но пишет по-прежнему в два места. Этот шаг необходим, так как во время последовательного обновления каждого из узлов все равно будет промежуток, когда экземпляры предыдущей версии приложения, читающие старое поле, работают одновременно с новой.
- Создать и по окончании полной развертки предыдущей задеплоить финальную версию, которая уже никак не взаимодействует со старым полем.
- Удалить старые поля.
Второй подход требует создания и установки трех разных версий приложения, что может быть очень неудобно и громоздко. Вместо этого можно воспользоваться feature toggling — заложить логику всех трех версий в одну, но переключать режим в зависимости от конфигурационного параметра, который в идеале можно было бы переключать «на лету». Таким образом, вместо установки каждой последующей версии достаточно будет менять значение параметра (и перезапускать сервис, если обновление конфигруации «на лету» не предусмотрено). После успешного завершения установки финальной версии, весь код, относящийся к обеспечению миграции данных, должен быть полностью удален из рабочей ветки, пусть он даже и будет «жить» на продакшене до следующего обновления системы.
Несложно заметить, что обеспечение zero downtime при обновлении системы — процедура громоздкая и хрупкая, поэтому с ней имеет смысл заморачиваться, только если есть соответствующее требование от бизнеса. Но даже если требования по доступности системы достаточно низкие (например, 99% в год и окно запланированного обновления системы — сутки), конверсия данных, необходимая для установки новой версии, может все равно занять больше. Поэтому, нужно быть заранее готовым к применению подобных решений, если предполагается хранение больших объемов данных.
Альтернативным подходом может быть намеренный отказ от обратно-несовместимых изменений в схеме базы данных, но, к сожалению, на практике он не всегда достижим, посколько зачастую самым эффективным способом улучшить производительность доступа к данным является реструктуризация схемы.
Автор: YuriyIvon