Build once, promote many: неизменяемые артефакты
Собери один раз, пинни артефакт по sha256-дайджесту и продвигай этот дайджест через staging в прод, а конфиг инжектируй в рантайме. Пересборка под окружение тихо отгружает другие байты — «в staging работает, в проде падает» без диффа.
Staging был зелёным неделю. Тот же коммит уехал в прод в пятницу — и сервис ушёл в crash-loop на старте: нативный модуль не грузился против libc, которого в staging он в глаза не видел. Изменений кода между ними не было; git diff пустой. Пайплайн просто запустил docker build ещё раз на деплое, и за часы между двумя сборками base-тег node:22 перезалили с патчем ОС. Staging гонял один набор байтов, прод — другой, и единственным доказательством был дайджест, который никто не удосужился сравнить. Двенадцать часов инцидент-моста, чтобы выяснить: две «идентичные» сборки идентичными не были вовсе.
Одна сборка — одна идентичность: дайджест и есть артефакт
За следующие десять минут ты поймёшь, почему две сборки одного коммита могут породить падение в проде при пустом git diff — и что именно нужно запинить, чтобы это никогда не повторялось.
Модель 12-factor делит деплой на три стадии: build превращает репозиторий кода в исполняемый бандл на конкретном коммите; release соединяет ровно эту сборку с конфигом деплоя; run запускает процесс из релиза. Жёсткое правило — направленность: нельзя изменить код в рантайме так, чтобы это утекло обратно в сборку. «Релиз нельзя изменить после того, как он создан.» Каждый получает уникальный ID, поэтому ты всегда точно знаешь, что запущено.
Build-once-deploy-many — операционное следствие: стадия build выполняется ровно один раз на изменение, и порождает она неизменяемый артефакт. Для контейнера настоящая идентичность артефакта — не его тег, а его дайджест содержимого, sha256-хэш манифеста образа. Тег вроде :latest, :v1.4 или node:22 — это изменяемый указатель; его можно перенаправить на другое содержимое в любой момент. Дайджест адресуем по содержимому: sha256:9b2f… вычисляется из байтов, поэтому любое изменение — пусть на один байт — даёт другой дайджест. Pull по дайджесту гарантирует, что ты получишь ровно тот образ, который собрали и протестировали, на каждой ноде, в каждом окружении. Дайджест — это артефакт; тег — лишь стикер поверх него.
Продвижение (promotion), стало быть, — не пересборка. Это перенос того же дайджеста вперёд: staging провалидировал sha256:9b2f…, значит прод запускает sha256:9b2f…, те же байты, без изменений. А меняется между окружениями конфиг — и он инжектируется в рантайме.
Конфиг — на окружение; байты — нет
Если staging и прод гоняют один артефакт, где живут URL базы, API-ключи, фича-флаги? Не в образе. Фактор config из 12-factor прямолинеен: храни конфиг в окружении, держи строгое разделение конфига и кода, а лакмусовая бумажка — что ты мог бы открыть исходники сборки завтра, не утекнув ни единого секрета. Конфиг — это всё, что меняется между деплоями; артефакт — всё, что не меняется.
release = неизменяемый артефакт (sha256-дайджест) + конфиг на окружение (инжектится в рантайме)Конкретно: тот же дайджест запускается в каждом окружении с конфигом, поданным как переменные окружения, примонтированный ConfigMap (объект конфигурации Kubernetes, монтируемый в контейнер) или секреты из vault — никогда не запечённым в слой. Запеки DATABASE_URL=staging-db в образ — и ты только что создал артефакт только-для-staging: теперь ты вынужден пересобирать под прод, что заново вносит ровно тот дрейф, ради устранения которого принцип и существует.
Rebuild drift: авария с пустым диффом
Вот провал, который стоит больше всего инцидент-часов — именно потому, что смотреть не на что. Когда пайплайн пересобирает артефакт отдельно под каждое окружение, каждая пересборка заново разрешает всё плавающее: подвижный base-тег, незапиненную транзитивную зависимость, latest какого-нибудь build-инструмента, даже преходящее состояние реестра. Популярные base-теги перезаливают часто — ради патчей безопасности ОС тег вроде node:22 или python:3.12 разрешается в другой дайджест в этом месяце, чем в прошлом, при том что строка тега не менялась. Так две сборки одного коммита с разницей в часы могут вшить разные glibc/musl, другой OpenSSL, другой патч рантайма. Бинарники расходятся, а изменения исходника, которое это объяснило бы, нет. Это «в staging работает, в проде падает» в чистейшей и самой бесящей форме.
▸Почему это работает
Почему пустой git diff так сбивает с толку? Потому что дифф покрывает только твой код. Пересборка под окружение ещё и тянет входы, которые ты не версионируешь: базовый образ за плавающим тегом, OS-пакеты за apt-get install без пиннинга версий и любую зависимость с caret-диапазоном, разрешившуюся в новый патч. Ничего из этого нет в репозитории, поэтому артефакты различаются, а исходник байт-в-байт одинаков. Build-once убирает весь класс багов, не давая второй сборке шанса разрешить что-либо иначе — второй сборки просто нет.
Контраст в форме пайплайна:
# ПЛОХО — пересборка под окружение, деплой изменяемого тега
# джоба staging
docker build -t myapp:latest . # разрешает node:22 -> дайджест A
docker push myapp:latest
kubectl set image deploy/app app=myapp:latest # staging тянет A
# джоба прода, часами позже
docker build -t myapp:latest . # разрешает node:22 -> дайджест B (базу перезалили!)
docker push myapp:latest # :latest теперь указывает на B
kubectl set image deploy/app app=myapp:latest # прод тянет B — другие байты, пустой git diff
# ХОРОШО — собрать один раз, поймать дайджест, продвигать его, конфиг в рантайме
docker buildx build --push -t registry/myapp:1.4.0 --metadata-file out.json .
DIGEST=$(jq -r '."containerimage.digest"' out.json)
# DIGEST = sha256:9b2f... — неизменяемая идентичность, пойманная ОДИН раз
kubectl set image deploy/app app=registry/myapp@$DIGEST -n staging # staging гоняет дайджест
# ...staging проходит свои гейты...
kubectl set image deploy/app app=registry/myapp@$DIGEST -n prod # прод гоняет ТОТ ЖЕ дайджест
# конфиг различается по окружению через env vars / ConfigMap / секреты — не перезапекается в образ| Вопрос | Пересборка под окружение + :latest (антипаттерн) | Build-once + продвижение дайджеста |
|---|---|---|
| Сколько раз собирается артефакт? | По разу на окружение (2–4×) | Ровно один раз, на изменение |
| Что опознаёт деплой? | Изменяемый тег (:latest) — может указывать куда угодно | sha256-дайджест — адресуем по содержимому, неизменяем |
| Байты staging и прода идентичны? | Не гарантировано — плавающая база/деп заново разрешаются | Гарантировано — один дайджест тянется везде |
| Где живёт конфиг? | Часто запечён → вынуждает пересборку под окружение | Инжектится в рантайме (env vars / ConfigMap / секреты) |
| Сигнатура отказа | «В staging работает, в проде падает», пустой git diff | Что протестировал — то и отгрузил |
Staging провалидировал сборку. Нужно перенести ровно этот артефакт в прод. Выбери механизм продвижения.
Staging зелёный; тот же коммит уходит в crash-loop в проде. git diff между двумя деплоями пустой. Самая вероятная корневая причина?
Почему продвижение по sha256-дайджесту сильнее, чем продвижение через перенаправление тега :latest?
- 01Объясни rebuild drift от начала до конца: как пересборка под окружение с того же коммита порождает разные бинарники при пустом git diff.
- 02Что значит «release = артефакт + конфиг», и почему конфиг надо инжектить в рантайме, а не запекать в образ?
Build-once-deploy-many гласит, что артефакт порождается ровно один раз на изменение и затем продвигается без изменений через каждое окружение. Его настоящая идентичность — не тег (тег вроде :latest или node:22 — изменяемый указатель, который можно перенацелить на любое содержимое), а его sha256-дайджест содержимого, вычисляемый из байтов образа и потому неизменяемый: тянешь по дайджесту — получаешь ровно то, что собрали и протестировали. Продвижение — это перенос того же дайджеста вперёд, поэтому staging валидирует sha256:…, а прод запускает тот же sha256:…. Конфиг — это часть, что меняется между окружениями, поэтому он живёт вне артефакта и инжектится в рантайме как env vars, ConfigMap или секреты — release = неизменяемый артефакт + конфиг на окружение, а запекание конфига вынудило бы пересборку под окружение. Эта пересборка и есть вся опасность: сборка отдельно под каждое окружение заново разрешает плавающие base-теги (перезалитые ради патчей ОС в новые дайджесты), незапиненные OS-пакеты и caret-депы, так что две сборки идентичного коммита вшивают разные байты. Авария следом — «в staging работает, в проде падает» при пустом git diff — не имеет изменения исходника, которое её объяснило бы, потому что расходящихся входов в репозитории не было. Собирай один раз, пинни и продвигай дайджест, инжекти конфиг в рантайме — и байты, что ты протестировал, и есть байты, что ты отгружаешь. Теперь, когда встретишь инцидент «staging зелёный, прод упал» при пустом диффе — первым делом сравни два sha256-дайджеста. Если они различаются, виновник — rebuild drift, а не твой код.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.