Деплой и инфра
Capstone: деплой — это цепочка, а авария живёт в стыке
Все блоки в диаграмме релиза были зелёными. Образ собрался и запушился. K8s-Deployment применился без ошибок. Rolling update отрапортовал успех за 40 секунд. И тут запищал пейджер: 30% запросов отдают 502 две минуты. Никто не выкатил баг. Образ был в порядке, манифест в порядке, миграция в порядке — по отдельности. У Deployment не было readinessProbe, поэтому k8s объявлял каждый новый под «готовым» в тот миг, когда стартовал процесс контейнера, ещё до того, как приложение открыло пул к БД. Rolling update послушно перенаправил трафик на поды, которые были живыми, но ещё не обслуживали. Авария не жила ни в одной стадии. Она жила в стыке между двумя верными.
Пайплайн — это один собранный объект, а не семь шагов
Весь трек деплоя шёл по одной стадии за раз: собрать лёгкий multi-stage образ, запушить в реестр, объявить k8s-объекты, выбрать стратегию раскатки, поставить перед сервисом балансировщик, инжектить секреты, закодировать всё как infrastructure-as-code. Озарение capstone в том, что ни одна из этих стадий сама по себе не выкатывает софт. Релиз — это композиция всех их, а у композиции есть эмерджентные режимы отказа, которые не ловят тесты ни одной отдельной стадии.
Думай об этом как о цепочке, где каждое звено передаёт артефакт следующему:
| Стадия | Производит | Стык, который она может сломать |
|---|---|---|
| Multi-stage сборка | Неизменяемый образ + digest | Build-зависимости утекают в runtime → раздутость, CVE |
| Push в реестр | Тегнутый артефакт, готовый к pull | Изменяемый тег :latest → какая сборка реально live? |
| k8s-объекты | Deployment + Service + Ingress + Config/Secret | Нет пробы → «готов» врёт раскатке |
| Стратегия раскатки | Сдвиг трафика старое → новое | Флип поверх несовместимой схемы → старый код 500-ит |
| Балансировщик | Трафик клиента → здоровые бэкенды | L4 не умеет draining → запросы в полёте обрываются |
| Секреты при деплое | Конфиг инжектится в runtime | Секрет вшит в образ → утёк + не ротируется |
| IaC | Воспроизводимое состояние кластера | Drift → окружение, что тестил ≠ что выкатил |
Каждая колонка «стык» — это реальная авария, которую кто-то выкатил. Каждая предполагает, что предыдущая стадия отработала — и всё равно сломалась, потому что контракт между стадиями никто не проверял.
Health-чеки — это клей между «запущен» и «обслуживает»
Самый несущий кусок клея — readiness-проба, потому что она задаёт контракт, на который опирается раскатка. Вся работа rolling update такая: поднять новый под, дождаться, пока он готов, затем погасить старый, по maxUnavailable за раз. Слово «готов» делает тут гигантскую работу. Без readinessProbe k8s рапортует под готовым в тот миг, когда стартовал главный процесс контейнера, — а это до того, как прогрелась JVM, открылся пул соединений, прогрелись кэши. Rolling update видит зелёное, добавляет под в эндпоинты Service, и трафик льётся в процесс, который отдаёт 502 / connection-refused.
Фикс — проба, которая реально дёргает зависимости, плюс surge-арифметика, которая никогда не падает ниже ёмкости:
readinessProbe:
httpGet: { path: /healthz/ready, port: 8080 }
initialDelaySeconds: 5
periodSeconds: 5
strategy:
rollingUpdate:
maxUnavailable: 0 # никогда ниже желаемого числа реплик
maxSurge: 1 # добавить новый под до удаления старогоДве пробы делают две разные работы, и их смешение — отдельная авария. Проба readiness управляет трафиком — провалил её, и под убирают из Service, но оставляют работать. Проба liveness управляет рестартами — провалил её, и kubelet убивает и пересоздаёт контейнер. Заведи медленную зависимость (флапающую БД) в liveness-пробу — и получишь шторм рестартов: каждый под, который не достучался до БД, убивают, что делает проблему БД хуже, а не лучше. Liveness должна проверять «я в дедлоке?», а не «весь ли мой мир здоров?».
Почему это работает
Тонкая ловушка: проба на path: /healthz, которая отдаёт 200, если поднят веб-сервер, не говорит раскатке ничего полезного, потому что веб-сервер поднимается почти мгновенно. Проба обязана падать, пока то, что реально нужно запросу — пул к БД, соединение с кэшем, downstream-клиент — ещё инициализируется. Health-чек, который не может покраснеть во время старта, — это украшение, а не клей.
Стратегия раскатки и миграция — это одно решение
Самый дорогой стык во всей цепочке — между стратегией раскатки и базой данных, потому что флип балансировщика обратим, а изменение схемы обычно нет. Представь blue-green деплой: blue live, green — новая версия, ты флипаешь LB и мгновенно откатываешься, если green ведёт себя плохо. Теперь допустим, релиз green «прибрал» схему — удалил колонку, которую blue всё ещё читает. Флип проходит, green обслуживает нормально. Потом green кидает ошибку, и ты откатываешься на blue — и blue немедленно 500-ит на каждом запросе, потому что нужной колонки больше нет. Твой «мгновенный откат» теперь односторонняя авария.
Дисциплина, которая делает раскатку и миграцию композируемыми, — expand-contract (он же «parallel change»): никогда не выкатывай изменение схемы, несовместимое с кодом, который сейчас работает. Ты разбиваешь одно логическое изменение на отдельные деплои, каждый держит N-1 совместимость (новая схема работает со старым кодом и наоборот):
- Expand — добавь новую колонку/таблицу; оставь старую. Обе версии работают.
- Migrate — перелей данные в новую форму; пиши из приложения в обе (dual-write).
- Contract — только после того, как весь трафик на новом коде, удали старую колонку. Отдельный, более поздний деплой.
Цена реальна: переименование колонки, которое «должно» быть одной миграцией, становится тремя деплоями, размазанными по релизам, плюс dual-write код, который надо не забыть удалить. Выигрыш в том, что любое промежуточное состояние безопасно для отката, поэтому твоя стратегия раскатки сохраняет обратимость, которую обещала.
Нужно переименовать горячую колонку в таблице на 200M строк, сохранив раскатки zero-downtime обратимыми. Выбери подход.
Draining и observability замыкают цепочку
Даже с пробами и безопасными миграциями cutover может убить запросы, которые уже были в полёте. Когда под завершается, k8s убирает его из эндпоинтов Service и шлёт SIGTERM — но эти два события гоняются, и LB может ещё пару мгновений роутить запросы на умирающий под. L7-балансировщик умеет draining: перестаёт слать новые запросы и ждёт настраиваемый таймаут (хороший дефолт — 1.5–2× твоего p99 времени запроса), пока завершатся те, что в полёте. Наивная L4-схема, которая просто рвёт на уровне соединения, может оборвать запрос посреди ответа. Сторона приложения обязана сотрудничать: ловить SIGTERM, перестать принимать новую работу, доделать запросы в полёте, затем выйти — всё внутри terminationGracePeriodSeconds (дефолт 30s), с preStop-паузой, чтобы покрыть гонку с удалением эндпоинта.
И единственная причина, по которой ты узнал, что авария readiness-пробы в Hook была про 502, а не про «успех», — это observability. Exit-код раскатки говорит, что манифест применился; он не говорит про error rate, p99 latency или saturation на новых подах. Несущий сигнал — сравнение golden-метрик новой версии с baseline старой во время раскатки — ровно то, что canary делает автоматически: сдвинуть 5% трафика, понаблюдать error rate и latency пару минут, продвинуть или авто-откатить по метрике, а не по возвращаемому значению команды деплоя.
Rolling update рапортует успех за 40 секунд, но потом 30% запросов 502-ят две минуты. Образ, манифест и миграция по отдельности в порядке. Какая причина наиболее вероятна?
Почему blue-green флип опасен в паре с миграцией, которая удаляет колонку?
Расставь безопасный релиз переименования колонки так, чтобы каждое промежуточное состояние было безопасным для отката:
- 1 Деплой 1 — Expand: добавь новую колонку, оставь старую на месте
- 2 Деплой 2 — выкати код, который dual-write пишет в обе колонки и backfill-ит существующие строки
- 3 Проверь, что все работающие поды на новом коде и читают новую колонку
- 4 Деплой 3 — Contract: удали старую колонку, раз её никто не читает
- 5 Убери теперь мёртвый dual-write код отдельным деплоем
- 01Проведи коллегу через то, почему идеально верный rolling update всё равно может вызвать аварию, и какой один кусок конфигурации это предотвращает.
- 02Объясни expand-contract и почему именно он делает стратегию раскатки и миграцию базы безопасно композируемыми.
Деплой — это не семь независимых шагов; это один собранный объект, и аварии живут в стыках, где две по отдельности верные стадии встречаются под контрактом, который никто не проверял. Readiness-проба — это контракт между «процесс работает» и «приложение может обслуживать», и без неё безупречный rolling update роутит трафик на мёртвые поды. Стратегия раскатки и миграция базы — одно решение: обратимый флип LB в композиции с деструктивным, необратимым изменением схемы даёт состояние, на которое нельзя откатиться, и поэтому expand-contract — держать каждую схему N-1 совместимой через отдельные деплои expand, migrate и contract — это клей, который позволяет им сосуществовать. L7-балансировщик, который умеет draining, и приложение, которое обрабатывает SIGTERM внутри terminationGracePeriodSeconds, держат запросы в полёте живыми сквозь cutover, а метрики раскатки (error rate и p99 против baseline, как автоматически проверяет canary) — это то, что делает «успех» здоровьем, а не просто «применилось». Собери образ лёгким, запушь неизменяемый digest, объяви объекты, инжекть секреты в runtime и закодируй всё как IaC, чтобы окружение, которое ты тестил, было тем, что ты выкатил — тогда цепочка держится, потому что ты заинженерил стыки, а не только звенья.