Теги двигаются, дайджесты — нет: пиннинг и подпись для проверяемой supply chain
Теги двигаются, дайджесты — нет: тег это изменяемая метка, дайджест это sha256 манифеста и фиксирует байты. Подпись (cosign) привязывает утверждение к дайджесту как referring-артефакт. Деплой по дайджесту и проверка подписи, иначе доверяешь тому, на что тег указал последним.
Таймлайн постмортема был из тех, что никто не хочет писать. В 14:02 разработчик поправил опечатку в комментарии Dockerfile, пересобрал и запустил docker push internal/checkout:prod — тот самый тег, на который пиннился прод-кластер. Он не знал, что кластер использует :prod напрямую. Пересборка ночью подтянула свежий базовый образ; новая база подняла TLS-библиотеку до версии с регрессией, агрессивно закрывавшей keep-alive соединения. Деплой не триггерился, PR не мёржился, апрув не запрашивался — но за следующий час, пока узлы пересоздавали поды и пере-тянули :prod, половина флота молча переехала на сломанный образ, а другая осталась на старом. Error rate чекаута полез лесенкой, что никто не мог скоррелировать с изменением, потому что в чейнджлоге изменения не было. Тег :prod уехал из-под работающей системы. Починка, уехавшая той ночью, была одним правилом: прод ссылается на дайджест, internal/checkout@sha256:…, а signature-гейт отказывает любому образу, чей дайджест не подписан релизным ключом.
Дайджест — это хеш манифеста
Почему пиннинг на дайджест гарантирует неизменяемость, а пиннинг на тег — нет? Ответ в том, как дайджест производится. Дайджест не назначается, он вычисляется: sha256:<hex>, где hex это SHA-256 канонических байтов манифеста. У этого точное следствие — дайджест покрывает манифест, а манифест перечисляет дайджест config и каждый дайджест слоя, каждый из которых покрывает свои байты. Так что один дайджест манифеста транзитивно пиннит весь образ: измени любой слой — дайджест слоя меняется, контент манифеста меняется, дайджест манифеста меняется. internal/checkout@sha256:9f3c… поэтому называет одно конкретное дерево байтов, которое не может быть ничем иным. У тега нет такого свойства: internal/checkout:prod — это запись в изменяемом индексе реестра, мапящая строку prod на некий дайджест манифеста, и PUT /v2/.../manifests/prod перезаписывает этот маппинг. Реестр даже скажет тебе текущий дайджест тега — HEAD /v2/<name>/manifests/prod возвращает дайджест манифеста в заголовке ответа Docker-Content-Digest — ровно так CI должен разрешать тег в дайджест в момент релиза и больше на тег не смотреть.
# разреши только что собранный тег в неизменяемый дайджест, затем деплой ИМЕННО его
digest=$(docker buildx imagetools inspect internal/checkout:prod \
--format '{{.Manifest.Digest}}') # sha256:9f3c…
kubectl set image deploy/checkout app=internal/checkout@${digest}▸Почему это работает
Почему ‘просто не пере-пушь prod’ недостаточно? Потому что нужное свойство — не обещание процесса, а структурная гарантия. Политика тегов живёт в головах людей и соглашениях CI; она ломается в момент, когда кто-то запускает docker push руками, второй пайплайн переиспользует тег или зеркало пере-тегирует upstream. Reference по дайджесту не может быть неверным даже в принципе — нет операции, делающей @sha256:9f3c… другими байтами, поэтому сам reference обеспечивает то, что политика лишь просила. Это та же причина, по которой контент-адресация бьёт имена всюду в стеке: имена — утверждения, хеши — факты.
Подпись привязывает утверждение к дайджесту
Дайджест фиксирует, какие байты ты прогоняешь, но ничего не говорит о том, кто поручился за них — любой, кто может пушить, может создать дайджест. Подпись закрывает этот разрыв. С cosign ты подписываешь дайджест образа (не тег — подпись тега бессмысленна, ведь тег двигается), производя подпись плюс опциональные claims (SBOM, provenance-аттестацию). В современной OCI-модели они хранятся обратно в том же реестре как referring-артефакты: подпись сама по себе — маленький манифест, чьё поле subject это подписываемый дайджест, находимая через referrers API (GET /v2/<name>/referrers/<digest>), что перечисляет все артефакты, ссылающиеся на данный образ. Проверка тогда: разрешить тег→дайджест, забрать referrers для этого дайджеста и проверить, что подпись доверенным ключом (или, с keyless cosign, доверенной OIDC-личностью, записанной в transparency log Rekor) покрывает ровно этот дайджест.
cosign sign --key release.key internal/checkout@sha256:9f3c…
cosign verify --key release.pub internal/checkout@sha256:9f3c… # гейт: ненулевой выход блокирует деплойПорядок несущий: ты проверяешь против дайджеста, который собираешься прогнать, а не против тега. Если проверишь :prod, а затем задеплоишь :prod, движение тега между шагами значит, что ты проверил один образ, а прогнал другой — TOCTOU (time-of-check-to-time-of-use, дыра между моментом проверки и моментом использования) дыра. Проверка и деплой одного дайджеста её закрывают: проверенное и прогоняемое бит-в-бит идентичны по построению.
Почему это хребет supply chain
Три senior-следствия. Первое: deploy-reference должны быть дайджестами — инцидент из Hook (ручной docker push в :prod, молча раскативший регрессию без записи изменения) структурно невозможен, когда работающий reference это @sha256:…, ведь ни один push не перенацелит дайджест. Второе: signature-гейт должен проверять дайджест, который ты прогоняешь, не тег, и в идеале enforce-ить на admission (admission-контроллер Kubernetes вроде sigstore policy-controller, отвергающий неподписанные дайджесты), чтобы гарантия держалась даже когда человек обходит CI. Третье: у изменяемости есть blast radius: движение тега без audit trail (пере-пуш :latest) может раскатать непреднамеренный образ по всему флоту за минуты, что нужны подам на пересоздание, и поскольку нет события деплоя, твой инструмент корреляции изменений слеп — лесенка ошибок при пустом чейнджлоге. Вместе эти три образуют цепочку: пиннинг дайджеста фиксирует байты, подпись делает апрув аудируемым, admission-enforcement держит оба условия даже при человеческой ошибке. Убери любое звено — цепочка рвётся ровно в нём. Пиннинг плюс подпись превращают «мы доверяем тому, кто пушнул тег последним» в «мы прогоняем ровно эти подписанные байты», и это вся разница между именем, в чью верность ты надеешься, и фактом, что можешь доказать.
- 01Объясни точно, почему дайджест неизменяем, а тег нет, и как CI должен превратить один в другой.
- 02Почему нужно проверять подпись на дайджесте, который деплоишь, и что ломается, если проверять тег?
Теги двигаются, дайджесты — нет — и целый класс молчаливых прод-инцидентов схлопывается, как только команда это усваивает. Дайджест — это SHA-256 канонических байтов манифеста, и поскольку манифест перечисляет дайджесты config и слоёв, один дайджест манифеста транзитивно пиннит каждый байт образа; @sha256:… не может называть ничего иного. Тег — обратное: изменяемая запись в индексе реестра, что PUT на endpoint manifests перезаписывает, так что :prod или :latest называет то, что было запушено последним. CI превращает свежесобранный тег в стабильный reference, читая заголовок Docker-Content-Digest из HEAD на endpoint manifests (или imagetools inspect), затем деплоит этот дайджест и забывает тег. Подпись добавляет недостающее измерение — кто поручился: cosign подписывает дайджест (никогда тег, что ручался бы ничем фиксированным), и подпись садится в тот же реестр как referring-артефакт, чей subject это тот дайджест, находимый через referrers API. Проверка должна целить в дайджест, который ты собираешься прогнать, ведь проверка тега и затем его деплой пере-разрешают тег дважды и открывают TOCTOU-окно, в которое пере-пуш может проехать. Выигрыш структурный, не процедурный: деплой по дайджесту делает случайный docker push в :prod неспособным двинуть идущую раскатку, signature-гейт (в идеале на admission) делает неподписанные байты непрогоняемыми даже когда человек пропускает CI, а лесенка ошибок при пустом чейнджлоге — тег, уехавший из-под живой системы — перестаёт быть возможной. Имена — утверждения, в чью верность надеешься; дайджесты и подписи — факты, что можешь доказать. Теперь, когда увидишь лесенку ошибок при пустом чейнджлоге, первый вопрос — какой тег сдвинулся и какие узлы ещё не пере-тянули его.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.