Главная/Dockerfile/Урок

5.9. Воспроизводимые сборки

Цели

После этого материала вы сможете:

  • объяснить, что такое воспроизводимость сборки и какие её уровни существуют;
  • закрепить базовый образ, системные пакеты и Python-зависимости;
  • объяснить, почему одинаковый коммит может дать разные образы;
  • получить SBOM и provenance для собранного образа;
  • объяснить ограничения полной побитовой воспроизводимости и что делать вместо неё;
  • организовать обновление закреплённых версий, не теряя контроля.

Предварительные знания

Ключевые термины

ТерминОбъяснение
воспроизводимостьСвойство сборки давать одинаковый результат при одинаковых входных данных
pinningЗакрепление конкретной версии зависимости
lock fileФайл с точными версиями всех зависимостей, включая транзитивные
SBOMSoftware Bill of Materials — перечень компонентов образа
provenanceМетаданные о происхождении: чем, когда и из чего собран образ
attestationПодписанное утверждение об образе, прикреплённое к нему в registry
SOURCE_DATE_EPOCHПеременная, задающая фиксированное время для меток файлов

Теория

Три уровня воспроизводимости

Термин используют в разных смыслах, и их полезно различать.

УровеньУтверждениеДостижимо
ФункциональнаяОбраз работает одинаковоПрактически всегда
СоставнаяОбраз содержит те же версии компонентовДостижимо закреплением
ПобитоваяОбраз имеет тот же digestТрудно, редко нужно

Цель для практических задач — составная воспроизводимость: вы точно знаете, какие версии внутри, и можете воссоздать тот же набор. Побитовая требует значительных усилий и нужна в основном там, где важна верифицируемость поставки.

Что делает сборку невоспроизводимой

Источники недетерминизма, перечисленные по частоте:

ИсточникПроявление
Плавающий тег базового образаpython:3.13-slim сегодня и через месяц — разные образы
Незакреплённые Python-зависимостиfastapi без версии ставит текущую
Транзитивные зависимостиВерсия закреплена, но её зависимости — нет
Незакреплённые системные пакетыapt-get install curl ставит версию из текущего индекса
Загрузка по URLСодержимое по ссылке может измениться
Время сборкиМетки времени файлов и слоёв
Порядок файловФайловая система может отдавать файлы в разном порядке
Сетевые ресурсыЗеркала отдают разные версии

Первые четыре решаются закреплением и дают составную воспроизводимость. Остальные касаются побитовой.

Закрепление базового образа

Тег изменяем (урок 3.3). Единственная гарантия — digest:

dockerfile
FROM python:3.13-slim@sha256:bf503bb2243c5aad0aa951544dd60d165f992646441d35dea90893703fc26251

Тег оставляют для читаемости; при расхождении приоритет у digest.

Цена: вы перестаёте получать обновления безопасности автоматически. Это осознанный компромисс — обновление становится явным изменением в репозитории, проходящим ревью и тесты. Автоматизируется инструментами вроде Renovate или Dependabot, создающими pull request с новым digest.

Закрепление Python-зависимостей

Недостаточно закрепить прямые зависимости:

text
requirements.txt:
  fastapi==0.141.1        ← закреплено

  но fastapi требует:
    starlette>=0.42,<0.50 ← не закреплено
    pydantic>=2.9         ← не закреплено

Через месяц выйдет starlette 0.49, и сборка получит другой набор пакетов при том же requirements.txt.

Решение — lock file со всеми версиями, включая транзитивные:

ИнструментФайлКоманда генерации
pip-toolsrequirements.txt из requirements.inpip-compile
uvuv.lock или скомпилированный requirements.txtuv lock / uv pip compile
Poetrypoetry.lockpoetry lock
pip (встроенно)requirements.txt с хэшамиpip freeze

Наиболее строгий вариант — закрепление с хэшами:

text
fastapi==0.141.1 \
    --hash=sha256:a1b2c3d4e5f6...

Тогда pip install --require-hashes откажется устанавливать пакет с другим содержимым — защита от подмены на зеркале.

Подробно инструменты сравниваются в разделе 06.

Закрепление системных пакетов

Возможно, но менее практично:

dockerfile
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: при её установке метки времени в слоях приводятся к заданному значению.

bash
SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) docker build -t app .

Значение берут из времени коммита — тогда сборка одного коммита всегда даёт одинаковые метки.

Это необходимое, но не достаточное условие побитовой воспроизводимости: остаются порядок файлов, недетерминизм компиляторов и содержимое кэшей.

SBOM и provenance

Две категории метаданных, отвечающие на разные вопросы:

SBOMProvenance
ВопросЧто внутри образаКак образ собран
СодержитСписок пакетов с версиямиИсходники, параметры сборки, время, builder
ПрименениеПоиск уязвимых версийПроверка происхождения
ФорматSPDX, CycloneDXSLSA provenance

BuildKit умеет прикреплять их к образу как attestations:

bash
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 выбирает манифест по реальной платформе.


Команды и примеры

Подготовка

bash
mkdir -p /tmp/repro/app && cd /tmp/repro
echo "print('приложение')" > app/main.py
cat > .dockerignore <<'EOF'
Dockerfile*
.dockerignore
__pycache__
EOF

Демонстрация невоспроизводимости

bash
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/^/  /'
text
фактически установленные версии:
  fastapi==0.141.1
  pydantic==2.13.4
  starlette==0.49.2
  uvicorn==0.52.0

Эти версии зафиксированы моментом сборки. Через месяц те же две строки requirements.txt дадут другой набор.

Закрепление зависимостей

bash
# получаем полный список с транзитивными зависимостями
docker run --rm repro:loose pip freeze > requirements.locked.txt
wc -l < requirements.locked.txt
head -6 requirements.locked.txt
text
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: транзитивные зависимости теперь тоже закреплены.

bash
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 "  наборы идентичны"
text
проверка идентичности набора пакетов:
  наборы идентичны

Закрепление базового образа по digest

bash
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
text
digest базового образа: sha256:bf503bb2243c5aad0aa951544dd60d165f992646441d35dea90893703fc26251
приложение

Теперь зафиксированы и базовый образ, и все Python-пакеты.

Хэши пакетов

Максимальная строгость — проверка содержимого каждого пакета:

bash
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
text
#
# This file is autogenerated by pip-compile with Python 3.13
#
annotated-types==0.7.0 \
    --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \
    --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89

С таким файлом:

dockerfile
RUN pip install --require-hashes --no-cache-dir -r requirements.txt

Установка пакета с изменённым содержимым будет отклонена — защита от компрометации зеркала PyPI.

Цена: файл нужно перегенерировать при любом изменении зависимостей, а --require-hashes требует, чтобы все пакеты имели хэши.

SOURCE_DATE_EPOCH

bash
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
text
=== без SOURCE_DATE_EPOCH ===
  ID различаются
=== с SOURCE_DATE_EPOCH ===
  ID совпали
=== метка времени создания образа ===
  2023-11-14T22:13:20Z

Метка времени приведена к заданному значению, и две независимые сборки дали одинаковый Image ID.

На реальном приложении с Python-пакетами совпадения может не быть — из-за .pyc и прочих источников недетерминизма. Проверяйте на своём проекте.

Использование времени коммита:

bash
# в репозитории 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:

bash
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:

bash
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 — через сканер:

bash
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']}\")
"
text
  Python-пакетов: 14
    annotated-types==0.7.0
    anyio==4.12.0
    click==8.3.0
    fastapi==0.141.1
    h11==0.16.0

Практическое применение: при появлении уязвимости в конкретной версии пакета этот список даёт мгновенный ответ, затронут ли образ.

bash
docker buildx rm attest > /dev/null 2>&1 || true

Проверка воспроизводимости

Скрипт, сравнивающий две независимые сборки:

bash
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 "с закреплением"
text
=== сравнение двух независимых сборок ===
  без закрепления        побитово: различается   состав: одинаков
  с закреплением         побитово: различается   состав: одинаков

Интересный результат: состав совпал в обоих случаях, потому что за минуту между сборками ничего не изменилось в PyPI. Разница проявилась бы через недели.

Побитово образы различаются в обоих случаях — из-за меток времени. Это и есть иллюстрация того, что побитовая воспроизводимость требует отдельных усилий.

Обновление закреплённых версий

Закрепление без процесса обновления превращается в накопление уязвимостей. Минимальная процедура:

bash
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
text
=== 1. Текущий базовый образ ===
  sha256:bf503bb2243c5aad0aa951544dd60d165f992646441d35dea90893703fc26251
=== 2. Актуальный digest тега ===
  sha256:bf503bb2243c5aad0aa951544dd60d165f992646441d35dea90893703fc26251
=== базовый образ актуален ===

В реальном проекте эту роль выполняют Renovate или Dependabot: они создают pull request с новым digest, который проходит обычные тесты и ревью.

Уборка

bash
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 к составной воспроизводимости и докажите результат.

Исходный вариант:

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 содержит:

text
fastapi
uvicorn

Требуется:

  1. Закрепить базовый образ по digest, сохранив читаемый тег.
  2. Закрепить все Python-зависимости, включая транзитивные.
  3. Доказать, что две независимые сборки дают идентичный набор пакетов.
  4. Получить перечень компонентов образа (упрощённый SBOM).
  5. Написать скрипт проверки актуальности закреплённого 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.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
#!/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/^/  /'

Ожидаемый вывод:

text
═══ Шаг 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 не закреплён по версии.

Технически это возможно:

dockerfile
RUN apt-get install -y curl=8.14.1-2

Но репозитории Debian хранят только текущие версии пакетов. Через несколько недель выйдет curl 8.14.1-3, а 8.14.1-2 исчезнет из индекса. Сборка сломается:

text
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: можно запускать раз в неделю и получать уведомление о необходимости обновления, не блокируя обычные сборки.

Проверка результата

bash
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 не сохраняетсяСчитают лишнимПри инциденте нет ответа на вопрос «что внутри»

Контрольные вопросы

На понимание:

  1. Чем составная воспроизводимость отличается от побитовой и какая нужна на практике?
  2. Почему закрепления прямых зависимостей недостаточно?
  3. Почему закреплять версии пакетов apt непрактично, а базовый образ по digest — практично?
  4. Что делает SOURCE_DATE_EPOCH и почему этого недостаточно для побитового совпадения?
  5. Чем SBOM отличается от provenance по назначению?

На применение:

  1. Как получить полный список версий, включая транзитивные зависимости?
  2. Как закрепить базовый образ, сохранив читаемость Dockerfile?
  3. Как проверить, что две независимые сборки дают одинаковый состав пакетов?

На диагностику:

  1. Сборка одного и того же коммита месяц назад и сегодня дала образы разного размера. Что проверить?
  2. Сборка внезапно упала с Version '8.14.1-2' for 'curl' was not found, хотя Dockerfile не менялся. Причина?

Краткое резюме

  1. Различают функциональную, составную и побитовую воспроизводимость; практическая цель — составная.
  2. Базовый образ закрепляется по digest; тег оставляют для читаемости.
  3. Закрепление прямых зависимостей недостаточно — нужен lock file с транзитивными.
  4. --require-hashes защищает от подмены содержимого пакета на зеркале.
  5. Версии пакетов apt закреплять непрактично: старые версии исчезают из индекса.
  6. SOURCE_DATE_EPOCH фиксирует метки времени, но не устраняет прочий недетерминизм.
  7. Побитовая воспроизводимость требует значительных усилий и нужна редко.
  8. SBOM отвечает на вопрос «что внутри», provenance — «как собрано».
  9. Attestations требуют containerd image store или драйвера docker-container.
  10. Закрепление без процесса обновления накапливает уязвимости — автоматизируйте обновление.

Официальные источники

ИсточникСсылкаЧто подтверждает
Dockerfile reference: FROMhttps://docs.docker.com/reference/dockerfile/#fromСинтаксис image:tag@digest, приоритет digest
Building best practiceshttps://docs.docker.com/build/building/best-practices/Рекомендация закреплять базовые образы по digest
Reproducible buildshttps://docs.docker.com/build/ci/github-actions/reproducible-builds/SOURCE_DATE_EPOCH, ограничения побитовой воспроизводимости
Build attestationshttps://docs.docker.com/build/metadata/attestations/SBOM и provenance, флаги --sbom и --provenance
SBOM attestationshttps://docs.docker.com/build/metadata/attestations/sbom/Формат, генерация, просмотр
Provenance attestationshttps://docs.docker.com/build/metadata/attestations/slsa-provenance/Уровни mode=min и mode=max
docker buildx imagetools inspecthttps://docs.docker.com/reference/cli/docker/buildx/imagetools/inspect/Просмотр attestations образа
Python Packaging: hash-checking modehttps://pip.pypa.io/en/stable/topics/secure-installs/--require-hashes, защита от подмены пакетов
pip-toolshttps://pip-tools.readthedocs.io/Генерация lock file с --generate-hashes

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление

Markdown на GitHub ↗