Долгие миграции на старте сервиса — это не startup-проблема. Это ошибка в архитектуре релиза

от автора

Когда сервис поднимается по 8-15 минут, команда почти всегда начинает крутить одни и те же ручки: увеличивает initialDelaySeconds, добавляет startupProbe, поднимает progressDeadlineSeconds, иногда переносит миграцию в initContainer и считает, что стало «по-кубернетесному». Обычно это не лечение. Это способ аккуратнее завернуть проблему в YAML. Если тяжёлая миграция живёт внутри старта приложения, вы связали жизненный цикл Pod, rollout Deployment и поведение базы в один общий узел. А такие узлы в проде рвутся не там, где их ждут.

Есть очень узнаваемая картина. Новый релиз выкатывается нормально на staging, а в production внезапно «висит». kubectl get pods показывает что-то вроде Running, часть Pod’ов даже не выглядит аварийной, но трафик на новые экземпляры не идёт. В CI завис kubectl rollout status. Через несколько минут появляются разговоры про сеть, про registry, про «кластер тупит», иногда про нехватку CPU. А потом выясняется простая вещь: при старте сервис делает миграцию схемы, а миграция не просто создаёт индекс или добавляет колонку, а ещё думает, лочит, ждёт, перепроверяет, а иногда и бежит по полтаблицы.

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

Kubernetes здесь, кстати, ни в чём не виноват. По его модели Pod может находиться в фазе Running, если контейнер уже создан и хотя бы один основной процесс стартовал; это не означает, что приложение реально готово обслуживать трафик. За это отвечает не фаза Pod, а readiness. Отдельно есть startupProbe: если он задан, kubelet не запускает readiness и liveness до тех пор, пока startup probe не пройдёт успешно. То есть сам Kubernetes довольно честно разделяет «процесс запустился», «приложение ещё инициализируется» и «экземпляр готов принимать трафик».

Проблема начинается там, где мы используем эту честную механику как оправдание плохой связки. Команда говорит: «Ну да, сервис стартует долго, потому что миграция. Сейчас настроим startupProbe на 20 минут — и всё будет хорошо». Нет, не будет. startupProbe действительно нужен для приложений с длинной инициализацией, чтобы Kubernetes не убивал контейнер слишком рано. Но он не превращает тяжёлую миграцию в хороший релизный паттерн. Он лишь даёт контейнеру больше времени оставаться в переходном состоянии.

Самый вредный эффект тут даже не в длительности старта как таковой. Самый вредный эффект — в том, что вы размножаете миграцию вместе с репликами приложения. В Deployment три реплики? Значит, три Pod’а потенциально пытаются войти в один и тот же участок логики: проверить версию схемы, дождаться локов, применить DDL (Data Definition Language — язык определения данных), запустить backfill, подождать завершения, а потом уже стать ready. Один Pod победит, второй будет ждать, третий упрётся в таймаут или в блокировку. Снаружи это выглядит как «релиз застрял», внутри — как очень дорогой способ использовать базу данных в роли распределённого lock manager без явного признания этого факта.

Именно поэтому такие релизы часто ломаются странно. Не в лоб, а через побочные эффекты. Старые Pod’ы ещё держат трафик, новые не становятся ready, rollout висит, HPA в это время может попытаться добавить реплики, потому что нагрузка не падает, а вы получаете ещё больше стартующих экземпляров, ещё больше конкуренции за один и тот же участок миграции. Deployment считает rollout прогрессирующим, когда новые Pod’ы становятся ready или available; если этого не происходит достаточно долго, он помечает rollout как застрявший с ProgressDeadlineExceeded, а kubectl rollout status завершится с ошибкой. Сам по себе Deployment при этом магически вас не спасает: он в основном сообщает факт застревания, а не лечит первопричину.

Есть ещё одна популярная «починка» — вынести миграцию в initContainer. На вид это уже выглядит аккуратнее: приложение не знает про SQL, entrypoint чистый, DevOps доволен. Но по сути вы почти ничего не изменили. initContainer обязан завершиться успешно до запуска основного контейнера, Pod не станет Ready, пока init-контейнеры не отработают, а если Pod будет перезапущен, init-контейнеры выполнятся снова. То есть вы по-прежнему привязали изменение схемы к жизненному циклу каждого конкретного Pod, просто перенесли это в другой раздел манифеста.

Вот типичный анти-паттерн в самом сжатом виде:

containers:  - name: app    image: <image>    command: ["sh", "-c", "./migrate && ./service"]

Или чуть более «приличная» версия:

initContainers:  - name: migrate    image: <image>    command: ["./migrate"]containers:  - name: app    image: <image>    command: ["./service"]

Обе схемы страдают одной и той же болезнью: состояние базы становится частью bootstrap-пути каждого экземпляра сервиса. Это удобно до тех пор, пока миграции маленькие. Потом база вырастает, таблицы становятся горячими, backfill занимает минуты или часы, а релиз внезапно превращается в операцию над shared state, замаскированную под обычный rolling update.

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

Первое — изменение схемы: добавить колонку, индекс, новую таблицу, расширить enum, ввести новый nullable field, подготовить новую структуру. Второе — изменение данных: пересчитать старые записи, переложить payload, заполнить новое поле по историческим данным, мигрировать десятки миллионов строк. Схемные изменения ещё можно делать в релизном окне, если они спроектированы совместимо. Тяжёлый backfill почти никогда не должен жить на критическом пути старта сервиса.

Отсюда вытекает главный взрослый принцип: rollout приложения и изменение данных должны быть развязаны. Не «желательно», не «по возможности», а именно развязаны. Иначе вы не обновляете сервис. Вы играете в лотерею между временем старта Pod, блокировками в БД и бюджетом доступности релиза.

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

Схемные миграции, которые нужны до старта новой версии, выносятся в отдельный шаг релиза. Не в Deployment, а в явный процесс: pipeline stage, отдельный release task, Kubernetes Job, внешний мигратор — не так важно. Важно, что он один, у него есть собственный lifecycle, отдельные логи, понятный retry semantics и явный момент завершения. Для таких задач Job подходит по модели лучше Deployment, потому что он описывает одноразовую задачу, которая должна выполниться до конца, а не долгоживущий сервис. Kubernetes ровно так его и определяет: Job — это run-to-completion workload, который при неуспешном выполнении Pod’а перезапустит задачу до достижения успешного завершения.

Например, так:

apiVersion: batch/v1kind: Jobmetadata:  name: <service>-schema-migratespec:  backoffLimit: 3  template:    spec:      restartPolicy: Never      containers:        - name: migrate          image: <image>          command: ["./migrate", "up"]          envFrom:            - secretRef:                name: <db-credentials>

А сам Deployment после этого должен уметь делать две вещи.

Первая — стартовать быстро и предсказуемо. Не «быстро по сравнению с прошлым кварталом», а быстро настолько, чтобы время старта было похоже на время старта приложения, а не на длительность DDL плюс backfill плюс ожидание локов.

Вторая — честно отказываться работать на несовместимой схеме. Не пытаться «дотянуть» базу до нужного состояния из каждого Pod’а, а проверить минимальную требуемую версию схемы и завершиться с понятной ошибкой, если она не достигнута. Это важный, хоть и неочевидный сдвиг мышления: приложение не должно быть мигратором по умолчанию. Оно должно быть потребителем контракта. Если контракт не готов, экземпляр должен упасть быстро и явно.

Тут многие начинают нервничать: «А если миграция прошла, а новый код ещё не выкатился? Или наоборот?» Именно поэтому нормальные релизы со схемой строятся на forward/backward compatibility, а не на надежде, что всё обновится атомарно. Сначала вы делаете expand: добавляете новые структуры так, чтобы старый и новый код могли жить рядом. Потом выкатываете код, который умеет работать и со старой, и с новой формой данных. Потом, отдельно и не на старте сервиса, делаете backfill. И только когда старый путь точно вымер, можно делать contract: удалять старые поля, ограничения и переходный код.

Это скучнее, чем одна кнопка «релиз», зато не заставляет сервис быть одновременно web-приложением, мигратором, планировщиком backfill-задач и coordinator’ом распределённой блокировки.

Отдельно стоит сказать о probes, потому что именно там часто пытаются «починить» последствия архитектурной ошибки. startupProbe нужен, когда приложение реально долго инициализируется: греет кэш, поднимает JVM, ждёт внутренние зависимости, выполняет контролируемый bootstrap. readinessProbe нужен, чтобы не пускать трафик в экземпляр раньше времени. livenessProbe нужен, чтобы перезапускать реально зависший процесс. Все три вещи полезны. Но ни одна из них не должна быть прикрытием для тяжёлой миграции на критическом пути старта.

Нормальная конфигурация может выглядеть так:

containers:  - name: app    image: <image>    ports:      - containerPort: 8080    startupProbe:      httpGet:        path: /startup        port: 8080      periodSeconds: 5      failureThreshold: 24    readinessProbe:      httpGet:        path: /ready        port: 8080      periodSeconds: 5      failureThreshold: 3    livenessProbe:      httpGet:        path: /live        port: 8080      periodSeconds: 10      failureThreshold: 3

Но смысл здесь не в самих цифрах. Смысл в том, что /startup должен отвечать на вопрос «процесс закончил инициализацию?», /ready — «экземпляр может безопасно принимать трафик?», а не «успел ли я за это время применить 17 SQL-скриптов и пролить 40 миллионов записей через ORM».

Самая неприятная часть этой темы в том, что на маленьком проекте анти-паттерн долго выглядит разумным. Таблицы небольшие, миграции короткие, одна реплика, релизы редкие — всё работает. И именно поэтому команды привыкают к мысли, что «сервис сам делает migrate on startup» — это зрелость и автоматизация. Обычно это не зрелость. Это кредит, который кажется бесплатным, пока база маленькая. Потом кредит начинает взиматься во время каждого напряжённого релиза, а платёж приходит в самых неудобных формах: зависшие rollout’ы, непредсказуемые restart loops, таймауты readiness, перегретая база и очень нервный on-call.

Если хочется одного практического правила, которое действительно работает, оно звучит так: всё, что может занять существенно дольше обычного старта процесса, не должно жить на пути старта каждой реплики. Особенно если это касается shared state. Особенно если это DDL. Особенно если рядом rolling update.

И есть ещё один полезный вывод, который обычно понимают поздно. Чем дольше миграция на старте, тем меньше ваш релиз похож на обновление stateless-сервиса и тем больше — на распределённую транзакцию без нормального координатора. А распределённые транзакции, как известно, лучше не маскировать под kubectl apply.

ссылка на оригинал статьи https://habr.com/ru/articles/1029754/