Хуки: pre/post install, upgrade, Job-миграции

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

Хук (hook) — обычный ресурс Kubernetes (чаще всего Job), помеченный аннотацией helm.sh/hook, который Helm выполняет в заданной фазе релиза, а не вместе с основными ресурсами.

Зачем хуки, если есть обычные ресурсы

На первый взгляд кажется, что Job-миграцию можно просто положить в templates/ как любой другой ресурс — Helm её создаст, Kubernetes выполнит. Проблема в порядке и координации. Обычные ресурсы релиза Helm применяет в один присест, отсортировав по типу (сначала Namespace и CRD, потом ConfigMap и Secret, затем Deployment и так далее), но он не ждёт, пока Job-миграция отработает, прежде чем поднять новый Deployment с кодом. В итоге миграция и новый код стартуют практически одновременно, и код может обратиться к колонке, которой ещё нет. Хук решает ровно это: он вырван из общего потока и встроен в конкретную фазу с гарантией ожидания. «Сделай X, дождись успеха, и только потом продолжай» — вот суть хука, недостижимая обычным ресурсом.

Стоит сразу зафиксировать границу применимости. Хуки — это императивный «побочный эффект» внутри декларативного деплоя, и злоупотреблять ими не стоит. Если действие можно выразить декларативно (через сам ресурс, через init-контейнер, через готовность зависимости), почти всегда лучше так и сделать. Хуки оправданы там, где нужна именно межресурсная последовательность с ожиданием: миграции БД, прогрев кеша, регистрация во внешней системе, дымовые тесты после выката. Чем меньше у вас хуков, тем предсказуемее релиз.

Точки хуков

ХукКогда срабатывает
pre-installдо создания ресурсов при install
post-installпосле создания ресурсов при install
pre-upgradeдо применения изменений при upgrade
post-upgradeпосле применения при upgrade
pre-delete / post-deleteдо/после удаления при uninstall
testпри helm test

Классика: миграция БД через pre-upgrade Job

Миграции схемы должны пройти до того, как поднимется новая версия кода. Идеальный кейс для pre-upgradepre-install для первой установки):

apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-migrate
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["python", "manage.py", "migrate"]

Helm запустит этот Job, дождётся его завершения, и только при успехе продолжит upgrade. Если миграция упала — upgrade останавливается, новый код не выкатывается. Это спасает от ситуации «код ждёт колонку, которой ещё нет».

Разберём аннотации построчно, потому что каждая здесь не случайна. pre-install,pre-upgrade — два события сразу: первый деплой пройдёт мимо pre-upgrade (апгрейдить нечего), поэтому без pre-install миграция при самой первой установке не запустится. backoffLimit: 0 и restartPolicy: Never — критичны: миграция должна выполниться ровно один раз, без автоматических перезапусков; иначе Job, упавший на середине, попытается повторить миграцию поверх частично применённой схемы. hook-weight: "0" задаёт порядок, если хуков несколько. А before-hook-creation в политике удаления решает проблему имени: Job в Kubernetes иммутабелен, и второй upgrade не сможет создать Job с тем же именем, пока старый не удалён.

Что считается «успехом» хука

Helm определяет завершение хука по типу ресурса. Для Job успех — это статус Complete (нужное число успешных завершений по completions), провал — Failed. Для Pod — фаза Succeeded. Для большинства остальных ресурсов (например, ConfigMap-хук, создающий разовую конфигурацию) Helm просто создаёт объект и считает хук успешным сразу — ждать там нечего. Время ожидания управляется тем же --timeout, что и основной деплой: если миграция длится дольше таймаута, Helm сочтёт хук провалившимся, даже если в БД она в итоге докрутится. Для долгих миграций таймаут поднимают осознанно — лучше явно дать «миграция может идти до 30 минут», чем ловить ложный провал на десятой минуте.

Веса: порядок нескольких хуков

Если хуков в одной фазе несколько, порядок задаёт helm.sh/hook-weight (строка-число, по возрастанию). Меньший вес выполняется раньше:

    "helm.sh/hook-weight": "-5"   # выполнится раньше, чем вес 0

Политика удаления хуков

helm.sh/hook-delete-policy управляет уборкой Job-ов хуков:

before-hook-creationудалить прошлый хук перед созданием нового (иначе Job с тем же именем конфликтует)
hook-succeededудалить после успеха (чисто)
hook-failedудалить после провала (но тогда не посмотреть логи!)

Частая связка — before-hook-creation,hook-succeeded: убирает прошлый Job и чистит за успешным, но оставляет упавший для разбора логов.

Как хуки работают под капотом

Ресурсы с аннотацией helm.sh/hook Helm исключает из обычного набора манифестов релиза — они не применяются вместе со всеми. Вместо этого Helm в нужной фазе: сортирует хуки по весу, создаёт их по очереди, ждёт готовности/завершения каждого (для Job — успешного завершения), затем применяет политику удаления. Важное следствие: хуки не участвуют в трёхстороннем слиянии и не откатываются при rollback — они вне обычного жизненного цикла ресурсов. Поэтому хук-миграция, которая уже изменила БД, при откате релиза сама по себе не отменится; продумывайте обратную совместимость миграций.

Главный архитектурный вывод: миграции должны быть обратимыми

Из того, что хуки вне rollback, следует практический принцип, который ломает многим картину мира: откат релиза не откатывает базу. Вы нажали helm rollback, поды с новым кодом сменились на старые, но колонка, которую добавила миграция, никуда не делась — и хорошо, если старый код её просто игнорирует. А если миграция удалила колонку, на которую опирается старый код, то откат вернёт сломанный код в сломанную против него схему. Отсюда дисциплина «expand and contract»: разносите изменение схемы и изменение кода на разные релизы. Сначала релиз, который добавляет новую колонку, но код умеет жить и без неё (expand). Потом релиз, где код начинает её использовать. И только спустя время, убедившись, что откатываться не придётся, — релиз, который удаляет старое (contract). При такой схеме любой одиночный откат безопасен, потому что соседние версии кода совместимы с одной и той же схемой.

Тот же принцип объясняет, почему упавший хук опасно оставлять без разбора и почему важно мониторить его логи. Если pre-upgrade миграция упала на полпути — часть DDL применилась, часть нет — БД оказывается в промежуточном состоянии, которого не было ни в одной ревизии Helm. Helm тут не помощник: он знает только про свои снимки манифестов, а не про содержимое таблиц. Поэтому миграционный инструмент (Alembic, Flyway, Django migrations) должен сам уметь накатывать изменения транзакционно или идемпотентно, чтобы повторный запуск хука после починки довёл схему до конца, а не сломал её ещё больше.

Частые ошибки

  • Гонка хуков и кода. Без --wait на основном деплое и при неверной фазе миграция может пойти параллельно новому коду. Используйте pre-upgrade, чтобы гарантировать «миграция до кода».
  • Удаление упавшего хука. С hook-failed логи провала исчезнут; для отладки оставляйте упавший Job.
  • Ожидание отката миграции. Rollback релиза не откатывает то, что хук уже сделал в БД.

Итог

  • Хук — ресурс с аннотацией helm.sh/hook, выполняемый в заданной фазе; типичный кейс — pre-upgrade Job-миграция.
  • hook-weight задаёт порядок, hook-delete-policy — уборку; оставляйте упавший хук для логов.
  • Хуки вне трёхстороннего слияния и rollback — миграции делайте обратимыми/совместимыми.
Проверьте себя
1. Какой хук подходит для миграции БД перед выкатом нового кода?
Apost-install
Bpre-upgrade (и pre-install для первой установки)
Ctest
Dpost-delete
2. Зачем нужна политика hook-delete-policy с before-hook-creation?
AУскоряет Job
BУдаляет прошлый Job-хук с тем же именем перед созданием нового, избегая конфликта
CШифрует Job
DОткатывает миграцию
3. Откатится ли изменение БД, сделанное хук-миграцией, при helm rollback?
AДа, автоматически
BНет — хуки вне трёхстороннего слияния и rollback; миграции делайте совместимыми
CТолько с --atomic
DЗависит от веса