Новая версия приложения ждёт в реестре, в её коде появились запросы к колонке, которой в базе ещё нет. Выкатишь код раньше миграции, и приложение посыплется обращениями к несуществующим таблицам. Выкатишь миграцию слишком рано, и она может оказаться несовместимой с ещё работающими старыми экземплярами. Порядок критичен, и Kubernetes вместе с пакетным менеджером даёт изящный механизм провести миграцию строго перед выкаткой кода. Но самое важное прячется в обработке провала миграции и в поведении при откате. Разберём, как устроены хуки пакетного менеджера, как настроить задание миграции и где подстерегают грабли.
Почему миграцию нельзя запускать как попало
Корень проблемы в синхронизации схемы базы и кода приложения. Миграции обязаны завершиться до выкатки нового кода, который зависит от изменений схемы. Запуск миграции после выкатки приводит к ошибкам, когда новый код пытается обратиться к ещё не существующим таблицам или колонкам. А слишком ранний запуск рискует несовместимостью с уже работающими экземплярами приложения.
Соблазнительное на первый взгляд решение это запускать миграцию прямо при старте сервиса. Кажется логичным: сервис не начнёт работу, пока не накатит миграции. Но это плохая идея по нескольким причинам. При нескольких репликах сразу несколько экземпляров кинутся выполнять миграции одновременно, что грозит гонками и порчей данных. Логику миграции придётся встраивать в само приложение. И провал миграции смешается с проблемами запуска приложения, что затрудняет разбор. Поэтому миграцию выносят в отдельное разовое задание, выполняемое до выкатки.
Как хуки пакетного менеджера встраивают задание в нужный момент
Kubernetes описывает разовую задачу через объект задания, который запускает под, выполняет работу и завершается. Пакетный менеджер позволяет привязать такое задание к определённому моменту жизненного цикла выпуска через хуки. Для миграций нужны два хука: один срабатывает перед первой установкой, другой перед обновлением.
apiVersion: batch/v1
kind: Job
metadata:
name: {{ .Release.Name }}-db-migrate
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.migration.image }}:{{ .Values.migration.tag }}"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ .Values.database.secretName }}
key: url
Аннотация хука говорит, что задание выполняется перед установкой и перед обновлением выпуска. Вес хука определяет порядок: меньшее значение запускается раньше, поэтому отрицательный вес гарантирует, что миграция отработает до создания прочих ресурсов. Политика удаления управляет уборкой старых заданий, чтобы они не копились.
Почему провалившаяся миграция не должна повторяться сама
Тут кроется важнейшее решение, которое многие настраивают неверно. Предел повторов задания стоит выставлять в ноль, чтобы провалившаяся миграция не запускалась повторно автоматически, а требовала вмешательства человека.
Логика железная. Если миграция провалилась, это почти всегда означает что-то серьёзное: конфликт схемы, испорченные данные, недоступную базу, ошибку в самом скрипте миграции. Слепой повтор такой миграции в лучшем случае бесполезен, а в худшем усугубляет порчу, накатывая половину изменений снова и снова. Гораздо безопаснее остановиться, оставить задание в состоянии провала и позвать человека разобраться. Политику перезапуска пода при этом ставят так, чтобы он не перезапускался при падении, и тогда провал миграции честно виден в состоянии задания, а не маскируется бесконечными попытками.
Когда хук задания проваливается, пакетный менеджер помечает весь выпуск как неудавшийся и останавливается. Это и есть желаемое поведение: выкатка нового кода не происходит, потому что миграция, от которой код зависит, не прошла. Приложение остаётся на старой версии, целостность сохраняется.
Как добиться автоматического отката при провале
Само по себе пометить выпуск неудавшимся хорошо, но иногда хочется, чтобы система ещё и сама вернулась в рабочее состояние. Для этого при обновлении используют флаг атомарности.
helm upgrade --install myapp ./myapp-chart --atomic --timeout 5m
Этот флаг включает автоматический откат при любом провале. Если хук миграции упал, выпуск откатывается к предыдущему рабочему состоянию автоматически, без ручного вмешательства. Это превращает провал из ситуации, требующей срочной починки руками, в управляемое событие, после которого система сама вернулась к стабильной версии. Разумно также задавать разумный предел времени, чтобы зависшая миграция не держала выкатку вечно, а по истечении срока считалась проваленной.
Какую ловушку таит откат на предыдущую версию
А вот здесь самая коварная грабля, о которой забывают почти все. Если когда-нибудь понадобится откатить приложение на предыдущую версию командой отката, задание миграции для той версии запустится снова. И это создаёт неочевидную опасность.
Представьте: вы обновились с версии, где была одна схема, на версию с новой схемой, миграция накатила изменения. Теперь вы откатываете приложение назад. Хук перед обновлением срабатывает снова и пытается выполнить миграцию той, старой версии. Но схема в базе уже новая, и попытка мигрировать вниз к старой схеме при откате с большой вероятностью обрушит существующие поды. Иными словами, откат кода не означает автоматического и безопасного отката схемы.
Отсюда вытекает золотое правило миграций в Kubernetes. По умолчанию при обновлении применяется стратегия плавающего обновления, при которой какое-то время одновременно работают поды и старой, и новой версии. Это требует, чтобы все миграции были обратно совместимы хотя бы с предыдущей версией приложения. Миграция не должна ломать работающий старый код: не удалять резко колонку, которую старая версия ещё читает, не переименовывать таблицу, на которую старая версия ещё ссылается. Совместимость со старой версией это не пожелание, а условие безопасной выкатки без простоя.
Почему конфигурацию хука тоже привязывают к хуку
Если миграция берёт настройки из объекта конфигурации, этот объект тоже должен быть хуком, причём созданным раньше задания. Иначе в момент запуска задания-хука нужной ему конфигурации может ещё не существовать, ведь обычные ресурсы создаются после хуков.
apiVersion: v1
kind: ConfigMap
metadata:
name: db-migrations-config
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-10"
"helm.sh/hook-delete-policy": hook-succeeded
data:
DB_NAME: {{ .Values.db.name }}
Здесь вес конфигурации меньше, чем у задания, поэтому она создаётся раньше и к моменту запуска миграции уже на месте. Это типичный приём: вес хуков выстраивает их в нужную последовательность, от подготовки окружения до собственно миграции.
Как не запутаться с уборкой и диагностикой заданий
Политика удаления хука определяет, когда старые задания убираются. Удобное сочетание это удалять задание перед созданием нового и удалять успешно завершённые. Тогда кластер остаётся чистым от накопившихся заданий, но при провале последнее упавшее задание остаётся на месте, и его логи можно изучить.
Есть и тонкость с диагностикой, на которую жалуются. Иногда задание завершается успешно, а пакетный менеджер всё равно сообщает о превышении предела повторов. Причина обычно в неверно выставленном пределе повторов или в гонке состояний. Поэтому при странном поведении первым делом проверяют аннотации хука на самом задании и убеждаются, что предел повторов не задран. Полезно также задавать время жизни задания после завершения, чтобы успешные задания сами убирались через заданный срок, не засоряя пространство имён.
При разборе провала смотрят логи пода задания: именно там видно, на каком шаге миграция споткнулась. Поскольку при правильной настройке провалившееся задание не удаляется и не перезапускается, его состояние и логи доступны для спокойного анализа.
Какие ещё подходы существуют и когда их брать
Хуки пакетного менеджера это не единственный путь. Kubernetes предлагает несколько шаблонов запуска миграций перед выкаткой. Контейнеры подготовки блокируют старт пода, пока миграция не завершится. Хуки пакетного менеджера запускают задание перед установкой выпуска. Самописные контроллеры оркестрируют последовательность миграции и выкатки. Правильный выбор зависит от инструмента выкатки и стратегии обновления.
Подход через контейнер подготовки внутри самого пода приложения имеет тот же изъян, что и миграция при старте сервиса: при нескольких репликах каждая попытается мигрировать. Поэтому для централизованной разовой миграции хук пакетного менеджера с одним заданием обычно удобнее. Если же выкаткой управляет инструмент непрерывной доставки на основе git, миграцию нередко оформляют отдельным шагом конвейера перед синхронизацией приложения, добиваясь того же порядка другими средствами.
Какой стратегии придерживаться
Запуск миграций перед выкаткой через хуки пакетного менеджера решает задачу синхронизации схемы и кода надёжно, если соблюсти несколько правил. Оформлять миграцию отдельным заданием, а не запускать при старте сервиса и не размазывать по репликам. Привязывать задание к хукам перед установкой и перед обновлением с отрицательным весом, чтобы оно шло раньше прочих ресурсов. Выставлять предел повторов в ноль и запрет на перезапуск пода, чтобы провал требовал вмешательства человека, а не повторялся вслепую. Включать автоматический откат при обновлении, чтобы провал миграции возвращал систему в рабочее состояние. И, что важнее всего, держать все миграции обратно совместимыми с предыдущей версией, помня, что откат кода запускает миграцию заново и небезопасный спуск схемы обрушит старые поды.
Главная мысль в том, что миграция это не безобидный довесок к выкатке, а операция, провал которой должен останавливать всё и звать человека, а не тихо повторяться. Тот, кто настраивает бесконечные повторы и забывает про обратную совместимость, рано или поздно получает наполовину накатанную схему и упавшие поды в момент отката. А тот, кто останавливает выкатку при первом же провале миграции и держит схему совместимой, получает предсказуемый порядок: сначала база приходит в нужное состояние, и лишь потом на неё встаёт код, который на это состояние рассчитывает.