Собери один раз, продвигай везде: артефакты и окружения
Собери артефакт ровно один раз и продвигай тот же неизменяемый, запиненный по digest образ из dev в staging в prod — никогда не пересобирай на каждое окружение. Пересборка из того же коммита — не тот же артефакт; конфиг инжектится в рантайме, а prod закрыт за апрувом.
Staging был зелёным три дня. Релиз-инженер запустил деплой в prod, который — как пайплайн делал всегда — прогнал собственный docker build из ровно того же коммита SHA, что собрал staging. Тот же Dockerfile, тот же коммит, те же зелёные тесты. Prod лёг за девяносто секунд. Постмортем нашёл разницу: за часы между сборкой staging и сборкой prod транзитивная зависимость опубликовала новый патч-релиз, и образ prod зарезолвил его там, где staging запинил старую версию. Два образа были собраны из идентичного исходника и не были одним и тем же бинарником. «Это прошло staging» оказалось предложением про артефакт, которого больше не существовало. Второй поворот ножа случился при откате: деплой целился в :latest, так что откат «к предыдущему образу» вытянул :latest, который к тому моменту перепушили, — вовсе не предыдущий образ. Этот урок про ту единственную дисциплину, что делает оба сценария невозможными: собери артефакт один раз и продвигай ровно этот артефакт везде.
Собери один раз, продвигай многократно
Через десять минут ты увидишь, почему инцидент из вступления был неизбежен — и почему одна строка в пайплайне делает его невозможным навсегда.
Принцип идёт из разделения build/release/run в 12-factor: build превращает исходник в один неизменяемый артефакт (образ контейнера, пакет), release связывает этот артефакт с конкретным конфигом, а run исполняет release. Артефакт производится ровно один раз, затем те же байты движутся через окружения — dev, потом staging, потом prod. Ты никогда не запускаешь второй build под другое окружение.
Причина — и есть весь смысл наличия окружения staging. Если prod пересобирает, то бинарник, который ты тестировал в staging, и бинарник, который ты везёшь в prod, — это две разные сборки, и «это прошло staging» теперь утверждение про другой артефакт. Продвижение делает гарантию буквальной: байты, которые ты тестировал, — это байты, которые ты запускаешь.
# НЕВЕРНО — каждое окружение пересобирает, поэтому каждое запускает (возможно) другой бинарник
# job staging
docker build -t app:staging . # сборка #1
# job prod (часами позже)
docker build -t app:prod . # сборка #2 — НЕ тот же образ, что сборка #1# ВЕРНО — собери ОДИН раз, зафиксируй digest, продвигай ровно этот образ везде
# job сборки (запускается один раз)
docker build -t registry/app:$GIT_SHA .
docker push registry/app:$GIT_SHA
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' registry/app:$GIT_SHA)
# DIGEST теперь registry/app@sha256:abc123... — продвигай ЕГО, никогда не пересобирайПинуй по digest, а не по тегу
Когда ты пишешь image: registry/app:latest в манифесте деплоя — спроси себя: что именно это такое? Ответ меняется каждый раз, когда кто-то перепушивает тег, — и именно эта изменяемость стала вторым провалом из вступительной истории.
Продвижение осмысленно, только если «тот же артефакт» однозначен. Тег вроде :latest или даже :v1.2.3 — это изменяемый указатель: его можно перепушить, чтобы он указывал на другой образ, в любой момент. :latest — классический выстрел в ногу: два деплоя :latest с разницей в минуты могут запустить два разных образа, и нет записи о том, что что-то изменилось. Даже semver-тег можно форс-пушнуть поверх.
Фикс — ссылаться на артефакт по его неизменяемому content digest — registry/app@sha256:... — который является хэшем самого содержимого образа. Digest нельзя перенаправить: sha256:abc и есть тот образ, байт в байт, навсегда. Деплой digest, запиши digest в release, и данный release становится воспроизводимым и аудируемым вплоть до бита.
# Манифест деплоя ссылается на НЕИЗМЕНЯЕМЫЙ digest, а не на плавающий тег
spec:
containers:
- name: app
# НЕ image: registry/app:latest (изменяемый — могут перепушить под тобой)
# НЕ image: registry/app:v1.2.3 (всё ещё перепушиваемый тег)
image: registry/app@sha256:abc123def456... # неизменяемый: ровно этот образ, всегдаКонфиг на окружение, а не артефакт на окружение
Один и тот же артефакт работает в каждом окружении; различается только конфиг. URL баз данных, фича-флаги, секреты и настройки тенанта инжектятся в рантайме — переменные окружения, примонтированные секреты, config maps — а не запекаются в образ. Это правило «config в окружении» из 12-factor, и именно оно позволяет одному артефакту обслуживать все окружения.
Запекание конфига окружения в образ ломает продвижение двумя путями: оно вынуждает пересобирать на каждое окружение (потому что образ prod должен отличаться от образа staging) и протекает секретами prod в артефакт, который лежит в registry. Тот же digest плюс конфиг на окружение — единственная форма, которая держит артефакт неизменяемым, а секреты — вне его.
▸Почему это работает
Почему «пересобрать из того же git-коммита под prod» не эквивалентно продвижению артефакта staging? Потому что сборка — не чистая функция исходного коммита. Версии транзитивных зависимостей, digest базового образа, версии build-инструментов, встроенные таймстампы и слои, скачанные по сети, — всё может различаться между двумя сборками одного коммита с разницей в минуты, ровно тот дрифт, что уронил prod в истории на старте. Так что пересборка может выдать другой бинарник, чем тот, что ты тестировал, обнуляя весь смысл staging. Продвижение точного артефакта по digest везёт протестированные байты; пересборка везёт обнадёживающее приближение, которому случилось разделить хэш коммита.
Продвижение и гейты
Артефакт движется через путь с гейтами: dev авто-деплоится на сборку, staging авто-деплоится и прогоняет интеграционные тесты, prod требует ручного апрува. GitHub Actions моделирует это через environments, несущие обязательных ревьюеров и таймеры ожидания, — окружение prod просто не задеплоится, пока названный апрувер не подпишется. На апруве пайплайн продвигает уже протестированный артефакт; он не пересобирает.
GitOps-инструменты (Argo CD, Flux) делают само продвижение git-коммитом: деплой — это изменение манифеста окружения, двигающее запиненный digest. Это делает каждое продвижение декларативным, ревьюимым в PR, аудируемым в истории git и обратимым через revert коммита — откат становится git revert, который восстанавливает точный предыдущий digest, обходя ловушку отката :latest целиком.
Тебе нужно перенести протестированный релиз из staging в prod с уверенностью, что prod запускает ровно тот код, что прошёл staging. Как ты его выкатишь?
Почему собирать артефакт один раз и продвигать его через окружения, а не пересобирать на каждое окружение?
Почему деплоить registry/app:latest опасно и на что нужно ссылаться вместо этого?
- 01Сформулируй принцип собери-один-раз, продвигай-многократно и объясни, почему пересборка из того же git-коммита не эквивалентна продвижению протестированного артефакта.
- 02Объясни пиннинг по digest против тегов, где живёт конфиг окружения и как prod закрывается гейтом в пайплайне с продвижением.
Дисциплина — собери один раз, продвигай многократно: build превращает исходник в один неизменяемый артефакт ровно один раз, и тот же артефакт — те же байты — продвигается через dev, staging, потом prod, никогда не пересобираясь на окружение. Пересборка — это ловушка, потому что сборка не чистая функция коммита: патчи транзитивных зависимостей, digest (хэш содержимого образа) базового образа и версии build-инструментов могут различаться между двумя сборками одного SHA, так что пересборка prod может повезти бинарник, который staging никогда не тестировал, хоть он и разделяет хэш коммита. Продвижение осмысленно, только если артефакт однозначен, поэтому ты пинуешь по неизменяемому content digest (registry/app@sha256:…), а не по изменяемому тегу вроде :latest или :v1.2.3, который можно перепушить так, чтобы он указывал на другой образ, и который молча ломает и деплои, и откаты. Тот же артефакт обслуживает каждое окружение, потому что конфиг — URL баз данных, флаги, секреты — инжектится в рантайме («config в окружении» из 12-factor), а не запекается в образ, что вынудило бы пересборки на окружение и протекло бы секретами в registry. Артефакт движется через путь с гейтами: dev авто-деплоится, staging авто-деплоится и прогоняет интеграционные тесты, а prod сидит за ручным апрувом (environments GitHub Actions с обязательными ревьюерами и таймерами ожидания), который продвигает уже протестированный digest, а не пересобирает. GitOps делает каждое продвижение git-коммитом, двигающим запиненный digest в манифесте окружения, так что каждый деплой декларативен, ревьюим, аудируем и обратим через revert коммита — который восстанавливает точный предыдущий digest и обходит ловушку отката :latest целиком. Теперь, когда увидишь пайплайн с двумя шагами docker build — для staging и для prod — ты будешь знать, почему «прошло staging» больше не гарантия, и ровно какой один шаг сборки нужно убрать.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.