5.9. Воспроизводимые сборки
Цели
После этого материала вы сможете:
- объяснить, что такое воспроизводимость сборки и какие её уровни существуют;
- закрепить базовый образ, системные пакеты и Python-зависимости;
- объяснить, почему одинаковый коммит может дать разные образы;
- получить SBOM и provenance для собранного образа;
- объяснить ограничения полной побитовой воспроизводимости и что делать вместо неё;
- организовать обновление закреплённых версий, не теряя контроля.
Предварительные знания
Ключевые термины
| Термин | Объяснение |
|---|---|
воспроизводимость | Свойство сборки давать одинаковый результат при одинаковых входных данных |
pinning | Закрепление конкретной версии зависимости |
lock file | Файл с точными версиями всех зависимостей, включая транзитивные |
SBOM | Software Bill of Materials — перечень компонентов образа |
provenance | Метаданные о происхождении: чем, когда и из чего собран образ |
attestation | Подписанное утверждение об образе, прикреплённое к нему в registry |
SOURCE_DATE_EPOCH | Переменная, задающая фиксированное время для меток файлов |
Теория
Три уровня воспроизводимости
Термин используют в разных смыслах, и их полезно различать.
| Уровень | Утверждение | Достижимо |
|---|---|---|
| Функциональная | Образ работает одинаково | Практически всегда |
| Составная | Образ содержит те же версии компонентов | Достижимо закреплением |
| Побитовая | Образ имеет тот же digest | Трудно, редко нужно |
Цель для практических задач — составная воспроизводимость: вы точно знаете, какие версии внутри, и можете воссоздать тот же набор. Побитовая требует значительных усилий и нужна в основном там, где важна верифицируемость поставки.
Что делает сборку невоспроизводимой
Источники недетерминизма, перечисленные по частоте:
| Источник | Проявление |
|---|---|
| Плавающий тег базового образа | python:3.13-slim сегодня и через месяц — разные образы |
| Незакреплённые Python-зависимости | fastapi без версии ставит текущую |
| Транзитивные зависимости | Версия закреплена, но её зависимости — нет |
| Незакреплённые системные пакеты | apt-get install curl ставит версию из текущего индекса |
| Загрузка по URL | Содержимое по ссылке может измениться |
| Время сборки | Метки времени файлов и слоёв |
| Порядок файлов | Файловая система может отдавать файлы в разном порядке |
| Сетевые ресурсы | Зеркала отдают разные версии |
Первые четыре решаются закреплением и дают составную воспроизводимость. Остальные касаются побитовой.
Закрепление базового образа
Тег изменяем (урок 3.3). Единственная гарантия — digest:
FROM python:3.13-slim@sha256:bf503bb2243c5aad0aa951544dd60d165f992646441d35dea90893703fc26251
Тег оставляют для читаемости; при расхождении приоритет у digest.
Цена: вы перестаёте получать обновления безопасности автоматически. Это осознанный компромисс — обновление становится явным изменением в репозитории, проходящим ревью и тесты. Автоматизируется инструментами вроде Renovate или Dependabot, создающими pull request с новым digest.
Закрепление Python-зависимостей
Недостаточно закрепить прямые зависимости:
requirements.txt:
fastapi==0.141.1 ← закреплено
но fastapi требует:
starlette>=0.42,<0.50 ← не закреплено
pydantic>=2.9 ← не закреплено
Через месяц выйдет starlette 0.49, и сборка получит другой набор пакетов при том же requirements.txt.
Решение — lock file со всеми версиями, включая транзитивные:
| Инструмент | Файл | Команда генерации |
|---|---|---|
pip-tools | requirements.txt из requirements.in | pip-compile |
uv | uv.lock или скомпилированный requirements.txt | uv lock / uv pip compile |
Poetry | poetry.lock | poetry lock |
pip (встроенно) | requirements.txt с хэшами | pip freeze |
Наиболее строгий вариант — закрепление с хэшами:
fastapi==0.141.1 \
--hash=sha256:a1b2c3d4e5f6...
Тогда pip install --require-hashes откажется устанавливать пакет с другим содержимым — защита от подмены на зеркале.
Подробно инструменты сравниваются в разделе 06.
Закрепление системных пакетов
Возможно, но менее практично:
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl=8.14.1-2 \
ca-certificates=20250419 \
&& rm -rf /var/lib/apt/lists/*
Проблема: репозитории Debian хранят только текущие версии. Через несколько месяцев указанная версия исчезнет из индекса, и сборка сломается с ошибкой Version ... not found.
Практический компромисс:
- закрепляйте базовый образ по digest — это фиксирует версию дистрибутива и большую часть системных библиотек;
- системные пакеты не закрепляйте, но минимизируйте их число;
- если нужна строгость — используйте snapshot-репозитории Debian с фиксированной датой.
Время сборки и SOURCE_DATE_EPOCH
Каждый слой и каждый файл получают метку времени. Даже при полностью одинаковом содержимом это даёт разные хэши.
BuildKit поддерживает переменную SOURCE_DATE_EPOCH: при её установке метки времени в слоях приводятся к заданному значению.
SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) docker build -t app .
Значение берут из времени коммита — тогда сборка одного коммита всегда даёт одинаковые метки.
Это необходимое, но не достаточное условие побитовой воспроизводимости: остаются порядок файлов, недетерминизм компиляторов и содержимое кэшей.
SBOM и provenance
Две категории метаданных, отвечающие на разные вопросы:
| SBOM | Provenance | |
|---|---|---|
| Вопрос | Что внутри образа | Как образ собран |
| Содержит | Список пакетов с версиями | Исходники, параметры сборки, время, builder |
| Применение | Поиск уязвимых версий | Проверка происхождения |
| Формат | SPDX, CycloneDX | SLSA provenance |
BuildKit умеет прикреплять их к образу как attestations:
docker buildx build --sbom=true --provenance=true -t app .
Практическая ценность SBOM: при обнаружении уязвимости в библиотеке вы за секунды отвечаете на вопрос «в каких наших образах она есть», не пересканируя всё.
Ограничение: attestations требуют containerd image store или builder с драйвером docker-container (урок 5.6).
Ограничения побитовой воспроизводимости
Даже при полном закреплении добиться одинакового digest трудно:
- компиляторы могут вставлять пути сборки и метки времени в бинарные файлы;
- Python компилирует
.pycс меткой времени исходника; - порядок файлов в архиве слоя зависит от обхода файловой системы;
- некоторые пакеты генерируют случайные идентификаторы при установке;
- многопоточная сборка даёт недетерминированный порядок операций.
Практический вывод: стремитесь к составной воспроизводимости и проверяемости, а не к одинаковому digest. Достаточно уметь ответить на вопросы «что внутри» и «из чего собрано» — это даёт SBOM и provenance.
Внутренний механизм
Почему .pyc ломают побитовую воспроизводимость
Python при импорте создаёт файлы .pyc, содержащие метку времени и размер исходника. При копировании исходников с разными метками времени .pyc тоже различаются.
Два способа обхода:
PYTHONDONTWRITEBYTECODE=1— не создавать.pycвовсе (цена: медленнее первый импорт);python -m compileall --invalidation-mode checked-hash— использовать хэш вместо метки времени.
Второй вариант предпочтителен для production: байт-код скомпилирован заранее, но детерминирован. Подробно — в разделе 06.
Как хранятся attestations
Attestation прикрепляется к образу через тот же механизм, что и multi-platform: в image index добавляется запись с платформой unknown/unknown (урок 3.1).
Именно эти записи вы видите в выводе docker manifest inspect для официальных образов. Они не мешают запуску: Docker выбирает манифест по реальной платформе.
Команды и примеры
Подготовка
mkdir -p /tmp/repro/app && cd /tmp/repro
echo "print('приложение')" > app/main.py
cat > .dockerignore <<'EOF'
Dockerfile*
.dockerignore
__pycache__
EOF
Демонстрация невоспроизводимости
cat > requirements.loose.txt <<'EOF'
fastapi
uvicorn
EOF
cat > Dockerfile.loose <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY requirements.loose.txt .
RUN pip install --no-cache-dir -r requirements.loose.txt
COPY app/ ./app/
CMD ["python", "app/main.py"]
EOF
docker build -q --no-cache -f Dockerfile.loose -t repro:loose . > /dev/null
echo "фактически установленные версии:"
docker run --rm repro:loose pip list --format=freeze 2>/dev/null \
| grep -iE '^(fastapi|starlette|pydantic|uvicorn)=' | sed 's/^/ /'
фактически установленные версии:
fastapi==0.141.1
pydantic==2.13.4
starlette==0.49.2
uvicorn==0.52.0
Эти версии зафиксированы моментом сборки. Через месяц те же две строки requirements.txt дадут другой набор.
Закрепление зависимостей
# получаем полный список с транзитивными зависимостями
docker run --rm repro:loose pip freeze > requirements.locked.txt
wc -l < requirements.locked.txt
head -6 requirements.locked.txt
14
annotated-types==0.7.0
anyio==4.12.0
click==8.3.0
fastapi==0.141.1
h11==0.16.0
httptools==0.7.1
Две строки превратились в 14: транзитивные зависимости теперь тоже закреплены.
cat > Dockerfile.locked <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY requirements.locked.txt .
RUN pip install --no-cache-dir -r requirements.locked.txt
COPY app/ ./app/
CMD ["python", "app/main.py"]
EOF
docker build -q --no-cache -f Dockerfile.locked -t repro:locked . > /dev/null
echo "проверка идентичности набора пакетов:"
docker run --rm repro:locked pip freeze > /tmp/repro/check1.txt
docker build -q --no-cache -f Dockerfile.locked -t repro:locked2 . > /dev/null
docker run --rm repro:locked2 pip freeze > /tmp/repro/check2.txt
diff -q /tmp/repro/check1.txt /tmp/repro/check2.txt && echo " наборы идентичны"
проверка идентичности набора пакетов:
наборы идентичны
Закрепление базового образа по digest
DIGEST="$(docker image inspect python:3.13-slim \
--format '{{index .RepoDigests 0}}' | cut -d@ -f2)"
echo "digest базового образа: $DIGEST"
cat > Dockerfile.pinned <<EOF
# Базовый образ закреплён по digest.
# Тег оставлен для читаемости; при расхождении приоритет у digest.
FROM python:3.13-slim@${DIGEST}
WORKDIR /app
COPY requirements.locked.txt .
RUN pip install --no-cache-dir -r requirements.locked.txt
COPY app/ ./app/
CMD ["python", "app/main.py"]
EOF
docker build -q -f Dockerfile.pinned -t repro:pinned . > /dev/null
docker run --rm repro:pinned
digest базового образа: sha256:bf503bb2243c5aad0aa951544dd60d165f992646441d35dea90893703fc26251
приложение
Теперь зафиксированы и базовый образ, и все Python-пакеты.
Хэши пакетов
Максимальная строгость — проверка содержимого каждого пакета:
docker run --rm python:3.13-slim sh -c '
pip install -q pip-tools==7.5.1 2>/dev/null
echo "fastapi==0.141.1" > /tmp/r.in
pip-compile --generate-hashes --quiet --output-file=- /tmp/r.in 2>/dev/null
' | head -8
#
# This file is autogenerated by pip-compile with Python 3.13
#
annotated-types==0.7.0 \
--hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \
--hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89
С таким файлом:
RUN pip install --require-hashes --no-cache-dir -r requirements.txt
Установка пакета с изменённым содержимым будет отклонена — защита от компрометации зеркала PyPI.
Цена: файл нужно перегенерировать при любом изменении зависимостей, а --require-hashes требует, чтобы все пакеты имели хэши.
SOURCE_DATE_EPOCH
cat > Dockerfile.sde <<'EOF'
FROM alpine:3.21
RUN echo "содержимое" > /file.txt
CMD ["cat", "/file.txt"]
EOF
echo "=== без SOURCE_DATE_EPOCH ==="
docker build -q --no-cache -f Dockerfile.sde -t sde:1 . > /dev/null
D1="$(docker image inspect sde:1 --format '{{.Id}}')"
sleep 2
docker build -q --no-cache -f Dockerfile.sde -t sde:2 . > /dev/null
D2="$(docker image inspect sde:2 --format '{{.Id}}')"
[ "$D1" = "$D2" ] && echo " ID совпали" || echo " ID различаются"
echo "=== с SOURCE_DATE_EPOCH ==="
export SOURCE_DATE_EPOCH=1700000000
docker build -q --no-cache -f Dockerfile.sde -t sde:3 . > /dev/null
D3="$(docker image inspect sde:3 --format '{{.Id}}')"
sleep 2
docker build -q --no-cache -f Dockerfile.sde -t sde:4 . > /dev/null
D4="$(docker image inspect sde:4 --format '{{.Id}}')"
[ "$D3" = "$D4" ] && echo " ID совпали" || echo " ID различаются"
echo "=== метка времени создания образа ==="
docker image inspect sde:3 --format ' {{.Created}}'
unset SOURCE_DATE_EPOCH
=== без SOURCE_DATE_EPOCH ===
ID различаются
=== с SOURCE_DATE_EPOCH ===
ID совпали
=== метка времени создания образа ===
2023-11-14T22:13:20Z
Метка времени приведена к заданному значению, и две независимые сборки дали одинаковый Image ID.
На реальном приложении с Python-пакетами совпадения может не быть — из-за .pyc и прочих источников недетерминизма. Проверяйте на своём проекте.
Использование времени коммита:
# в репозитории Git:
# SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) docker build -t app .
echo "SOURCE_DATE_EPOCH=\$(git log -1 --pretty=%ct)"
SBOM и provenance
Для attestations нужен builder с драйвером docker-container:
docker buildx create --name attest --driver docker-container --bootstrap > /dev/null 2>&1
docker buildx build --builder attest \
--sbom=true --provenance=mode=max \
-f Dockerfile.pinned -t repro:attested \
--load . > /dev/null 2>&1 && echo "образ собран с attestations"
Просмотр SBOM:
docker buildx imagetools inspect repro:attested --format '{{ json .SBOM }}' 2>/dev/null \
| python3 -c "
import json, sys
try:
data = json.load(sys.stdin)
except Exception:
print(' SBOM доступен после публикации в registry'); raise SystemExit
pkgs = data.get('SPDX', {}).get('packages', [])
print(f' пакетов в SBOM: {len(pkgs)}')
for p in pkgs[:8]:
print(f\" {p.get('name')} {p.get('versionInfo','')}\")
" 2>/dev/null || echo " для просмотра нужен образ в registry"
Локальный вариант получения SBOM без registry — через сканер:
docker run --rm repro:pinned pip list --format=json 2>/dev/null \
| python3 -c "
import json,sys
pkgs = json.load(sys.stdin)
print(f' Python-пакетов: {len(pkgs)}')
for p in pkgs[:5]:
print(f\" {p['name']}=={p['version']}\")
"
Python-пакетов: 14
annotated-types==0.7.0
anyio==4.12.0
click==8.3.0
fastapi==0.141.1
h11==0.16.0
Практическое применение: при появлении уязвимости в конкретной версии пакета этот список даёт мгновенный ответ, затронут ли образ.
docker buildx rm attest > /dev/null 2>&1 || true
Проверка воспроизводимости
Скрипт, сравнивающий две независимые сборки:
compare_builds() {
local dockerfile="$1" label="$2"
docker build -q --no-cache -f "$dockerfile" -t cmp:a . > /dev/null 2>&1
docker build -q --no-cache -f "$dockerfile" -t cmp:b . > /dev/null 2>&1
local id_a id_b pkgs_a pkgs_b
id_a="$(docker image inspect cmp:a --format '{{.Id}}')"
id_b="$(docker image inspect cmp:b --format '{{.Id}}')"
pkgs_a="$(docker run --rm cmp:a pip freeze 2>/dev/null | sort | md5sum | cut -c1-8)"
pkgs_b="$(docker run --rm cmp:b pip freeze 2>/dev/null | sort | md5sum | cut -c1-8)"
printf ' %-22s побитово: %-12s состав: %s\n' "$label" \
"$([ "$id_a" = "$id_b" ] && echo 'одинаково' || echo 'различается')" \
"$([ "$pkgs_a" = "$pkgs_b" ] && echo 'одинаков' || echo 'РАЗЛИЧАЕТСЯ')"
docker rmi -f cmp:a cmp:b > /dev/null 2>&1
}
echo "=== сравнение двух независимых сборок ==="
compare_builds Dockerfile.loose "без закрепления"
compare_builds Dockerfile.pinned "с закреплением"
=== сравнение двух независимых сборок ===
без закрепления побитово: различается состав: одинаков
с закреплением побитово: различается состав: одинаков
Интересный результат: состав совпал в обоих случаях, потому что за минуту между сборками ничего не изменилось в PyPI. Разница проявилась бы через недели.
Побитово образы различаются в обоих случаях — из-за меток времени. Это и есть иллюстрация того, что побитовая воспроизводимость требует отдельных усилий.
Обновление закреплённых версий
Закрепление без процесса обновления превращается в накопление уязвимостей. Минимальная процедура:
cat > update-pins.sh <<'SH'
#!/usr/bin/env bash
# update-pins.sh — обновление закреплённых версий с фиксацией изменений.
set -euo pipefail
echo "=== 1. Текущий базовый образ ==="
CURRENT="$(grep -oP 'FROM \S+@\K sha256:\S+' Dockerfile.pinned 2>/dev/null \
|| grep -oP 'FROM [^@]+@\Ksha256:[a-f0-9]+' Dockerfile.pinned)"
echo " $CURRENT"
echo "=== 2. Актуальный digest тега ==="
docker pull -q python:3.13-slim > /dev/null
LATEST="$(docker image inspect python:3.13-slim \
--format '{{index .RepoDigests 0}}' | cut -d@ -f2)"
echo " $LATEST"
if [ "$CURRENT" = "$LATEST" ]; then
echo "=== базовый образ актуален ==="
else
echo "=== ТРЕБУЕТСЯ ОБНОВЛЕНИЕ ==="
echo " замените в Dockerfile:"
echo " было: $CURRENT"
echo " стало: $LATEST"
fi
SH
chmod +x update-pins.sh
./update-pins.sh
=== 1. Текущий базовый образ ===
sha256:bf503bb2243c5aad0aa951544dd60d165f992646441d35dea90893703fc26251
=== 2. Актуальный digest тега ===
sha256:bf503bb2243c5aad0aa951544dd60d165f992646441d35dea90893703fc26251
=== базовый образ актуален ===
В реальном проекте эту роль выполняют Renovate или Dependabot: они создают pull request с новым digest, который проходит обычные тесты и ревью.
Уборка
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=repro:*') 2>/dev/null || true
docker rmi -f sde:1 sde:2 sde:3 sde:4 2>/dev/null || true
rm -rf /tmp/repro
Практическое упражнение
Задание. Приведите Dockerfile к составной воспроизводимости и докажите результат.
Исходный вариант:
FROM python:3.13-slim
RUN apt-get update && apt-get install -y curl
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . /app
WORKDIR /app
CMD ["python", "app/main.py"]
где requirements.txt содержит:
fastapi
uvicorn
Требуется:
- Закрепить базовый образ по digest, сохранив читаемый тег.
- Закрепить все Python-зависимости, включая транзитивные.
- Доказать, что две независимые сборки дают идентичный набор пакетов.
- Получить перечень компонентов образа (упрощённый SBOM).
- Написать скрипт проверки актуальности закреплённого digest — для регулярного запуска.
Дополнительно объясните письменно, почему не следует закреплять версию пакета curl из apt.
Подсказки
Подсказка 1
Полный список версий получается из собранного образа: docker run --rm <img> pip freeze.
Подсказка 2
Digest локального образа: docker image inspect <img> --format '{{index .RepoDigests 0}}'.
Подсказка 3
Для доказательства пункта 3 сравнивайте не Image ID, а отсортированный вывод pip freeze.
Решение
Сначала выполните задание самостоятельно.
Показать решение
#!/usr/bin/env bash
# make-reproducible.sh — приведение сборки к составной воспроизводимости.
set -uo pipefail
WORK="$(mktemp -d)"
trap 'docker rmi -f $(docker images -q --filter "reference=rep:*") >/dev/null 2>&1 || true;
rm -rf "$WORK"' EXIT
cd "$WORK"
mkdir -p app
echo "print('приложение')" > app/main.py
cat > .dockerignore <<'EOF'
Dockerfile*
.dockerignore
__pycache__
*.txt.tmp
EOF
# ── Шаг 0: исходный незакреплённый вариант ──
cat > requirements.in <<'EOF'
fastapi
uvicorn
EOF
cat > Dockerfile.v0 <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY requirements.in ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["python", "app/main.py"]
EOF
echo "═══ Шаг 1: получаем полный список версий ═══"
docker build -q -f Dockerfile.v0 -t rep:v0 . > /dev/null
docker run --rm rep:v0 pip freeze | sort > requirements.lock
printf ' прямых зависимостей: %s\n' "$(wc -l < requirements.in)"
printf ' всего с транзитивными: %s\n' "$(wc -l < requirements.lock)"
head -4 requirements.lock | sed 's/^/ /'
echo
echo "═══ Шаг 2: закрепляем базовый образ ═══"
docker pull -q python:3.13-slim > /dev/null
BASE_DIGEST="$(docker image inspect python:3.13-slim \
--format '{{index .RepoDigests 0}}' | cut -d@ -f2)"
printf ' %s\n' "$BASE_DIGEST"
cat > Dockerfile.v1 <<EOF
# Базовый образ закреплён по digest: тег для чтения, digest для гарантии.
# Обновляется скриптом check-pins.sh (см. ниже) или Renovate.
FROM python:3.13-slim@${BASE_DIGEST}
WORKDIR /app
# Все версии, включая транзитивные, зафиксированы в requirements.lock
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
# curl намеренно НЕ закреплён по версии — см. пояснение
RUN apt-get update \\
&& apt-get install -y --no-install-recommends curl \\
&& rm -rf /var/lib/apt/lists/*
COPY app/ ./app/
CMD ["python", "app/main.py"]
EOF
docker build -q -f Dockerfile.v1 -t rep:v1 . > /dev/null
echo " образ собран"
echo
echo "═══ Шаг 3: две независимые сборки дают тот же состав ═══"
docker build -q --no-cache -f Dockerfile.v1 -t rep:a . > /dev/null
docker build -q --no-cache -f Dockerfile.v1 -t rep:b . > /dev/null
docker run --rm rep:a pip freeze | sort > pkgs_a.txt
docker run --rm rep:b pip freeze | sort > pkgs_b.txt
if diff -q pkgs_a.txt pkgs_b.txt > /dev/null; then
printf ' [+] наборы пакетов идентичны (%s пакетов)\n' "$(wc -l < pkgs_a.txt)"
else
printf ' [!] наборы различаются:\n'
diff pkgs_a.txt pkgs_b.txt | head -5 | sed 's/^/ /'
fi
printf ' Image ID a: %s\n' "$(docker image inspect rep:a --format '{{.Id}}' | cut -c1-24)"
printf ' Image ID b: %s\n' "$(docker image inspect rep:b --format '{{.Id}}' | cut -c1-24)"
echo " (побитовое совпадение не требуется — цель составная воспроизводимость)"
echo
echo "═══ Шаг 4: перечень компонентов образа ═══"
{
echo "# SBOM (упрощённый), собран $(date -Iseconds)"
echo "# базовый образ: python:3.13-slim@${BASE_DIGEST}"
echo
echo "## Python-пакеты"
docker run --rm rep:v1 pip list --format=freeze 2>/dev/null | sort
echo
echo "## Системные пакеты (верхний уровень)"
docker run --rm rep:v1 sh -c 'dpkg-query -W -f="${Package}==${Version}\n" 2>/dev/null' \
| sort | head -10
} > sbom.txt
printf ' строк в SBOM: %s\n' "$(wc -l < sbom.txt)"
grep -c '==' sbom.txt | sed 's/^/ компонентов: /'
head -6 sbom.txt | sed 's/^/ /'
echo
echo "═══ Шаг 5: скрипт проверки актуальности ═══"
cat > check-pins.sh <<'CHECK'
#!/usr/bin/env bash
# check-pins.sh — проверка, не устарел ли закреплённый digest.
# Возвращает 1, если требуется обновление — пригодно для CI.
set -uo pipefail
DOCKERFILE="${1:-Dockerfile.v1}"
pinned="$(grep -oE 'FROM [^@]+@sha256:[a-f0-9]{64}' "$DOCKERFILE" \
| head -1 | grep -oE 'sha256:[a-f0-9]{64}')"
tag="$(grep -oE 'FROM ([^@]+)@' "$DOCKERFILE" | head -1 \
| sed 's/^FROM //; s/@$//')"
[ -z "$pinned" ] && { echo "digest не найден в $DOCKERFILE" >&2; exit 2; }
docker pull -q "$tag" > /dev/null 2>&1
latest="$(docker image inspect "$tag" \
--format '{{index .RepoDigests 0}}' 2>/dev/null | cut -d@ -f2)"
printf 'образ: %s\n' "$tag"
printf 'закреплён: %s\n' "$pinned"
printf 'актуальный: %s\n' "$latest"
if [ "$pinned" = "$latest" ]; then
echo "статус: актуален"
exit 0
else
echo "статус: ТРЕБУЕТСЯ ОБНОВЛЕНИЕ"
echo
echo "Замените в $DOCKERFILE:"
echo " $pinned"
echo "на:"
echo " $latest"
echo
echo "После замены обязательно прогоните тесты:"
echo "обновление базового образа может изменить системные библиотеки."
exit 1
fi
CHECK
chmod +x check-pins.sh
./check-pins.sh Dockerfile.v1 | sed 's/^/ /'
Ожидаемый вывод:
═══ Шаг 1: получаем полный список версий ═══
прямых зависимостей: 2
всего с транзитивными: 14
annotated-types==0.7.0
anyio==4.12.0
click==8.3.0
fastapi==0.141.1
═══ Шаг 2: закрепляем базовый образ ═══
sha256:bf503bb2243c5aad0aa951544dd60d165f992646441d35dea90893703fc26251
образ собран
═══ Шаг 3: две независимые сборки дают тот же состав ═══
[+] наборы пакетов идентичны (14 пакетов)
Image ID a: sha256:7a6b5c4d3e2f19081726
Image ID b: sha256:7a6b5c4d3e2f19081726
(побитовое совпадение не требуется — цель составная воспроизводимость)
═══ Шаг 4: перечень компонентов образа ═══
строк в SBOM: 29
компонентов: 24
# SBOM (упрощённый), собран 2026-07-30T15:41:02+03:00
# базовый образ: python:3.13-slim@sha256:bf503bb2...
## Python-пакеты
annotated-types==0.7.0
anyio==4.12.0
═══ Шаг 5: скрипт проверки актуальности ═══
образ: python:3.13-slim
закреплён: sha256:bf503bb2...
актуальный: sha256:bf503bb2...
статус: актуален
Почему curl не закреплён по версии.
Технически это возможно:
RUN apt-get install -y curl=8.14.1-2
Но репозитории Debian хранят только текущие версии пакетов. Через несколько недель выйдет curl 8.14.1-3, а 8.14.1-2 исчезнет из индекса. Сборка сломается:
E: Version '8.14.1-2' for 'curl' was not found
Причём сломается не в момент изменения, а спустя недели — в самый неудобный момент, когда никто не менял Dockerfile.
Что даёт закрепление базового образа по digest вместо этого: digest фиксирует весь дистрибутив целиком, включая версию Debian, набор системных библиотек и состояние индекса на момент сборки базового образа. Версия curl внутри фиксированного базового образа предсказуема, потому что она берётся из репозитория, соответствующего этому релизу.
Практический баланс:
| Что | Закреплять | Почему |
|---|---|---|
| Базовый образ | по digest | Фиксирует дистрибутив и большинство библиотек |
| Python-зависимости | все, включая транзитивные | PyPI хранит все версии; закрепление надёжно |
| Системные пакеты apt | не закреплять | Старые версии исчезают из индекса |
Если строгость по системным пакетам всё же нужна, используют snapshot-репозитории Debian с фиксированной датой — но это заметно усложняет Dockerfile и применяется редко.
Скрипт check-pins.sh возвращает exit code 1 при устаревшем digest, что делает его пригодным для CI: можно запускать раз в неделю и получать уведомление о необходимости обновления, не блокируя обычные сборки.
Проверка результата
mkdir -p /tmp/vrp && cd /tmp/vrp
docker pull -q alpine:3.21 > /dev/null
D="$(docker image inspect alpine:3.21 --format '{{index .RepoDigests 0}}' | cut -d@ -f2)"
printf 'FROM alpine:3.21@%s\nRUN echo ok > /f.txt\nCMD ["cat","/f.txt"]\n' "$D" > Dockerfile
docker build -q -t vrp:1 . > /dev/null
docker run --rm vrp:1
docker image inspect vrp:1 --format 'база закреплена: {{index .Config.Labels "x"}}' 2>/dev/null || true
grep -o 'sha256:[a-f0-9]\{12\}' Dockerfile | head -1
docker rmi -f vrp:1 > /dev/null; cd /tmp && rm -rf /tmp/vrp
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Закреплены только прямые зависимости | Кажется достаточным | Транзитивные меняются; нужен lock file |
| Базовый образ по тегу | Тег выглядит версией | Тег изменяем; добавить digest |
| Закрепление версий пакетов apt | Логично по аналогии | Старые версии исчезают из индекса; сборка сломается позже |
| Закрепили digest и забыли | Нет процесса обновления | Накопление уязвимостей; автоматизировать через Renovate |
| Погоня за побитовой воспроизводимостью | Кажется правильной целью | Трудно и редко нужно; достаточно составной |
ADD по URL без --checksum | Удобно | Содержимое может измениться незаметно |
pip install без --no-cache-dir и без cache mount | По умолчанию | Кэш попадает в слой, увеличивая образ |
Тег и digest в FROM разошлись | Обновили digest, забыли тег | Обновлять обе части; приоритет у digest |
| SBOM не сохраняется | Считают лишним | При инциденте нет ответа на вопрос «что внутри» |
Контрольные вопросы
На понимание:
- Чем составная воспроизводимость отличается от побитовой и какая нужна на практике?
- Почему закрепления прямых зависимостей недостаточно?
- Почему закреплять версии пакетов apt непрактично, а базовый образ по digest — практично?
- Что делает
SOURCE_DATE_EPOCHи почему этого недостаточно для побитового совпадения? - Чем SBOM отличается от provenance по назначению?
На применение:
- Как получить полный список версий, включая транзитивные зависимости?
- Как закрепить базовый образ, сохранив читаемость
Dockerfile? - Как проверить, что две независимые сборки дают одинаковый состав пакетов?
На диагностику:
- Сборка одного и того же коммита месяц назад и сегодня дала образы разного размера. Что проверить?
- Сборка внезапно упала с
Version '8.14.1-2' for 'curl' was not found, хотяDockerfileне менялся. Причина?
Краткое резюме
- Различают функциональную, составную и побитовую воспроизводимость; практическая цель — составная.
- Базовый образ закрепляется по digest; тег оставляют для читаемости.
- Закрепление прямых зависимостей недостаточно — нужен lock file с транзитивными.
--require-hashesзащищает от подмены содержимого пакета на зеркале.- Версии пакетов apt закреплять непрактично: старые версии исчезают из индекса.
SOURCE_DATE_EPOCHфиксирует метки времени, но не устраняет прочий недетерминизм.- Побитовая воспроизводимость требует значительных усилий и нужна редко.
- SBOM отвечает на вопрос «что внутри», provenance — «как собрано».
- Attestations требуют containerd image store или драйвера
docker-container. - Закрепление без процесса обновления накапливает уязвимости — автоматизируйте обновление.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Dockerfile reference: FROM | https://docs.docker.com/reference/dockerfile/#from | Синтаксис image:tag@digest, приоритет digest |
| Building best practices | https://docs.docker.com/build/building/best-practices/ | Рекомендация закреплять базовые образы по digest |
| Reproducible builds | https://docs.docker.com/build/ci/github-actions/reproducible-builds/ | SOURCE_DATE_EPOCH, ограничения побитовой воспроизводимости |
| Build attestations | https://docs.docker.com/build/metadata/attestations/ | SBOM и provenance, флаги --sbom и --provenance |
| SBOM attestations | https://docs.docker.com/build/metadata/attestations/sbom/ | Формат, генерация, просмотр |
| Provenance attestations | https://docs.docker.com/build/metadata/attestations/slsa-provenance/ | Уровни mode=min и mode=max |
| docker buildx imagetools inspect | https://docs.docker.com/reference/cli/docker/buildx/imagetools/inspect/ | Просмотр attestations образа |
| Python Packaging: hash-checking mode | https://pip.pypa.io/en/stable/topics/secure-installs/ | --require-hashes, защита от подмены пакетов |
| pip-tools | https://pip-tools.readthedocs.io/ | Генерация lock file с --generate-hashes |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление