Теги и digest’ы: mutable label vs immutable id
Каждый раз, когда ты пишешь docker pull postgres:16 или image: redis:7 в docker-compose, ты используешь тег. Тег это удобно: его легко читать, он несёт смысл (postgres:16, python:3.13-slim, airflow:2.10). Проблема в том, что тег это всего лишь mutable указатель — мейнтейнер образа может в любой момент перенаправить его на другой набор слоёв.
В этом уроке разбираемся, почему image:tag не даёт никаких гарантий воспроизводимости, что такое digest и почему в production-пайплайнах Data Engineer всегда pin’ит через image@sha256:....
Тег это not-immutable label
Когда ты делаешь docker pull postgres:16, Docker идёт в registry, говорит “дай мне манифест для тега 16 репозитория library/postgres” и получает в ответ ссылку на конкретный manifest digest. В registry хранится таблица:
postgres:16 → sha256:abc123 (текущий manifest)
postgres:16.6 → sha256:abc123 (тот же, что 16)
postgres:16.6-bookworm → sha256:abc123
postgres:latest → sha256:abc123
Когда мейнтейнеры PostgreSQL выпускают patch-релиз 16.7, они пересобирают образ и перезаливают теги:
postgres:16 → sha256:def456 (теперь это 16.7)
postgres:16.7 → sha256:def456
postgres:16.7-bookworm → sha256:def456
postgres:latest → sha256:def456
postgres:16.6 → sha256:abc123 (этот ОБЫЧНО остаётся, но не гарантировано)
То есть тег postgres:16 через неделю может указывать на другой манифест — и поведение твоего приложения может измениться. Поменялась версия libc, поправили баг, добавили security patch — теоретически может приплыть что угодно.
Worst case реальной истории: в 2019 году тег python:3.7-alpine после обновления базового Alpine стал ломать установку numpy через pip — потому что в alpine 3.10 убрали один из system libs. Команды, у которых был запиннен python:3.7-alpine в CI, утром обнаружили красные сборки на коммитах, которые накануне были зелёными. Никто их код не менял — поменялся образ под mutable тегом.
Digest: контент-адресуемое имя
Криптографические хэши и целостность данныхВ отличие от тега, digest это SHA256-хэш манифеста. Манифест это байты — изменился хоть один байт (порядок слоёв, mediaType, любая мета) — digest изменится. Это значит:
- Один и тот же digest всегда указывает на один и тот же контент. Навсегда. Бит-в-бит.
- Невозможно сделать так, чтобы под тем же digest’ом оказался другой образ — это нарушит криптографию SHA256.
- Если registry получает запрос
library/python@sha256:abc..., он возвращает ровно ту версию, которую ты запросил, даже если тегpython:3.13-slimуже давно перенаправлен на другой digest.
# Pin через digest
docker pull python@sha256:5b7b9c0f6c9e3e8a4f0d8b6a1e2c3d4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1
# Это будет ВСЕГДА бит-в-бит один и тот же образ
# Даже если тег python:3.13-slim сменили на новый билд
Как получить digest
Есть несколько способов:
# 1. После pull -- Docker выводит digest строкой Digest:
docker pull postgres:16
# 16: Pulling from library/postgres
# Digest: sha256:5d3e8b7f0a2c1e4b6d9f8a7c5e3b1f0d9c8b7a6e5d4c3b2a1f0e9d8c7b6a5f4e3
# Status: Image is up to date for postgres:16
# docker.io/library/postgres:16
# 2. docker inspect (показывает RepoDigests -- хэш образа в виде, как он лежит в registry)
docker inspect postgres:16 --format '{{index .RepoDigests 0}}'
# postgres@sha256:5d3e8b7f...
# 3. docker manifest inspect (без pull)
docker manifest inspect postgres:16 | grep -A1 "manifests" | head -10
# Для multi-arch образов покажет manifest list со списком архитектур и их digest'ов
# 4. Прямо в коде compose
# docker-compose.yml:
# services:
# db:
# image: postgres@sha256:5d3e8b7f0a2c1e4b6d9f8a7c5e3b1f0d9c8b7a6e5d4c3b2a1f0e9d8c7b6a5f4e3
Спектр стабильности тегов
Не все теги одинаково “плавающие”. У хороших образов есть convention:
| Тег | Что значит | Меняется? |
|---|---|---|
latest | Самая свежая стабильная сборка | Часто (каждый релиз) |
16 или python:3 | Major version | Часто (каждый patch) |
16.6 или python:3.13 | Minor version | Изредка (security patches) |
16.6.1 или python:3.13.0 | Patch version | Почти никогда |
16-bookworm-20250115 | Конкретный билд с датой | Никогда (если соблюдают convention) |
image@sha256:abc... | Digest | Никогда (immutable by crypto) |
Чем “уже” версия — тем стабильнее тег. Но даже python:3.13.0 теоретически может быть перезалит (мейнтейнер исправил критический баг). Только digest даёт криптографическую гарантию.
Стратегии pinning для Data Engineer
В DE-практике встречается три подхода:
1. Pin через digest везде где можно. Production, CI-pipeline, конфиги Airflow для PythonOperator с custom image. Гарантирует bit-for-bit воспроизводимость на годы вперёд. Минус: некрасиво, требует обновлять руками.
services:
postgres:
image: postgres@sha256:5d3e8b7f0a2c1e4b6d9f8a7c5e3b1f0d9c8b7a6e5d4c3b2a1f0e9d8c7b6a5f4e3
2. Pin через semver-теги + monitoring. postgres:16.6.1-bookworm, redis:7.4.0. Удобно читать, изменяется только по security-update. Совмещается с automated dependency bot’ом (Renovate, Dependabot), который видит новые версии в registry и предлагает PR с обновлением.
services:
postgres:
image: postgres:16.6.1-bookworm # ок для prod при наличии monitoring
3. Floating tag (:latest, :16). Только для local dev и quick prototypes. Никогда в проде, никогда в CI, который должен быть воспроизводим.
services:
postgres:
image: postgres:16 # OK для docker-compose.dev.yml, не для prod
Распространённый workflow: в dev используется postgres:16 (всегда свежий), в стейджинге postgres:16.6.1-bookworm, в проде postgres@sha256:5d3e.... Перед промоушеном в прод resolve тега → digest и pin’ишь. Это позволяет балансировать удобство и воспроизводимость.
Почему :latest опасен
:latest это просто convention — это не что-то особенное в Docker, это просто тег с именем latest, который мейнтейнеры по соглашению используют как alias для “самой свежей стабильной сборки”. Проблемы:
1. Семантика непредсказуема. Что значит latest для apache/airflow? Последний стабильный 2.x? Или новый 3.x? Это зависит от мейнтейнера. У некоторых образов latest это вообще dev-build (например, mongo:latest долго был довольно experimental).
2. Невозможно понять, что у тебя запущено. docker ps покажет app:latest, но это может быть build от прошлой недели или сегодняшний. Нельзя сказать “верните мне ту же версию, что была вчера” — она уже перезатёрта.
3. В compose файлах нет default pull_policy. До Docker Compose v2.30 при docker compose up локально кэшированный образ не перетягивался даже если в registry лежала новая версия. То есть :latest на двух машинах в один момент времени мог означать разное.
4. Кэш CI ломается непредсказуемо. В CI обычно docker pull image:latest сразу перед билдом. Сегодня собралось — завтра упало, потому что image:latest получил breaking change в какой-нибудь системной либе.
Правило простое: в production-конфигах и CI — никогда :latest. В DE-практике часто блокируется policy-инструментами типа Hadolint:
DL3007 warning: Using latest is prone to errors if the image will ever update. Pin the version explicitly to a release tag
Resolve тега в digest для compose
Полезный трюк — resolve тега в digest перед production deploy:
# Получить digest текущего :16 тега
docker pull postgres:16
docker inspect postgres:16 --format '{{index .RepoDigests 0}}'
# postgres@sha256:5d3e8b7f0a2c1e4b6d9f8a7c5e3b1f0d9c8b7a6e5d4c3b2a1f0e9d8c7b6a5f4e3
# Скопировать в production compose
# image: postgres@sha256:5d3e8b7f0a2c1e4b6d9f8a7c5e3b1f0d9c8b7a6e5d4c3b2a1f0e9d8c7b6a5f4e3
# Через 3 месяца хочешь обновить
docker pull postgres:16 # подтянет новый
docker inspect postgres:16 --format '{{index .RepoDigests 0}}'
# postgres@sha256:новый_digest
# вписываешь в compose, тестируешь, выкатываешь
В крупных DE-командах этот процесс автоматизирован: Renovate-bot мониторит теги в registry, открывает PR с обновлением digest’а, запускает CI, мерджится по approval.
Попробуй сам
Сравни поведение тега и digest’а:
# Pull через тег
docker pull alpine:3.21
# Получи digest
ALPINE_DIGEST=$(docker inspect alpine:3.21 --format '{{index .RepoDigests 0}}')
echo $ALPINE_DIGEST
# alpine@sha256:21dc6063fd678b478f57c0e13f47560d0...
# Pull через digest -- получишь ровно тот же image id
docker pull $ALPINE_DIGEST
# Посмотри: оба тега и digest указывают на один image
docker images alpine
# REPOSITORY TAG IMAGE ID CREATED SIZE
# alpine 3.21 aded1e1a5b37 3 weeks ago 7.83MB
# alpine <none> aded1e1a5b37 3 weeks ago 7.83MB (через digest)
# А теперь представь, что мейнтейнер обновил alpine:3.21
# Тег теперь укажет на новый digest, а pull через старый digest вернёт прежний