open atlas
↑ К треку
CI/CD-пайплайны CICD · 03 · 03

Build once, promote many: неизменяемые артефакты

Собери один раз, пинни артефакт по sha256-дайджесту и продвигай этот дайджест через staging в прод, а конфиг инжектируй в рантайме. Пересборка под окружение тихо отгружает другие байты — «в staging работает, в проде падает» без диффа.

CICD Senior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

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?

Вспомните перед уходом
  1. 01
    Объясни rebuild drift от начала до конца: как пересборка под окружение с того же коммита порождает разные бинарники при пустом git diff.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.