Dockerfile кажется простым файлом из нескольких инструкций, но именно он определяет размер образа, скорость сборки и площадь атаки. Одна и та же команда docker build может собрать образ на 1,2 ГБ с компилятором и тестами внутри или на 90 МБ, где остались только runtime-зависимости и непривилегированный пользователь. Разберём многоступенчатую сборку на Python-приложении, кэширование, передачу секретов, запуск без root и проверку образа сканером.
Зачем нужна многоступенчатая сборка
В классическом одноступенчатом Dockerfile сборочные инструменты остаются в финальном слое. Компилятор, заголовочные файлы, кэш пакетов и тесты увеличивают размер и дают атакующему лишние программы. Многоступенчатая сборка делит процесс на стадии: в первой ставятся зависимости и собирается код, во вторую копируется только результат.
- меньше размер образа и быстрее доставка на узлы;
- меньше пакетов, а значит, меньше CVE;
- сборочные секреты и инструменты не попадают в финальный слой;
- финальная стадия проще для аудита и воспроизведения.
Граница между стадиями задаётся инструкцией FROM ... AS имя. Всё, что не скопировано через COPY --from, в итоговый образ не попадёт. Это не магия: если приложение тащит зависимости из системы, их нужно установить и в финальную стадию.
Каркас Dockerfile
Ниже рабочий пример для приложения на Python. В builder устанавливаются зависимости в отдельный префикс, а также компилируется байт-код. Финальная стадия получает только пакеты и код, работает от отдельного пользователя и содержит healthcheck.
# syntax=docker/dockerfile:1.7
FROM python:3.13-slim AS builder
WORKDIR /src
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --prefix=/install -r requirements.txt
COPY app ./app
RUN python -m compileall -q app
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY --from=builder /install /usr/local
COPY --from=builder /src/app ./app
RUN groupadd --system app \
&& useradd --uid 10001 --gid app --no-create-home --shell /usr/sbin/nologin app
USER 10001:10001
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD python -c "import http.client; c=http.client.HTTPConnection('127.0.0.1',8000,timeout=3); c.request('GET','/health'); assert c.getresponse().status == 200"
CMD ["python", "-m", "app"]
Обратите внимание на --prefix=/install: зависимости не смешиваются с системным Python, и их можно перенести одной директорией. Тег 3.13-slim понятен человеку, но для воспроизводимости лучше закрепить базовый образ по digest. Получить актуальный digest можно командой docker buildx imagetools inspect python:3.13-slim, а обновлять его стоит автоматически, а не вручную раз в год.
Контекст сборки и .dockerignore
Перед сборкой Docker отправляет демону весь контекст — текущий каталог. Если в нём лежат .git, node_modules, логи и локальные файлы окружения, сборка замедляется, а секреты могут попасть в слой через неосторожный COPY . .. Файл .dockerignore решает обе проблемы.
.git
.gitignore
.env
*.log
node_modules
tests
docs
docker-compose*.yml
Dockerfile*
Проверяйте, что именно попадает в образ: docker history показывает слои, но не содержимое удалённых файлов. Если секрет был скопирован в слой, удаление в следующей инструкции не помогает — слой остаётся в истории. Поэтому секреты передаются только через BuildKit secrets, а не через ARG или ENV.
Кэш и секреты без следов в слоях
BuildKit умеет монтировать кэш пакетного менеджера и секреты на время выполнения команды. Монтирование не создаёт слой, поэтому токен реестра или конфиг pip не остаётся в образе. Для повторяемых сборок полезно выгружать кэш в registry.
RUN --mount=type=secret,id=pipconf,target=/etc/pip.conf \
pip install --prefix=/install -r requirements.txt
docker buildx build \
--secret id=pipconf,src="$HOME/.config/pip/pip.conf" \
--cache-from type=registry,ref=registry.example.com/app:cache \
--cache-to type=registry,ref=registry.example.com/app:cache,mode=max \
--tag registry.example.com/app:1.4.0 --push .
Секрет доступен только на время шага и только внутри сборки. Если зависимость требует токен во время запуска, это уже не секрет сборки, а секрет приложения — его место в менеджере секретов или в переменных окружения контейнера, но не в Git.
Запуск без root и с урезанными правами
Инструкция USER в Dockerfile — обязательный минимум. Даже если процесс скомпрометирован, он не сможет писать в системные каталоги. Дополнительно отключают получение новых привилегий, сбрасывают capabilities и переводят корневую файловую систему в режим только для чтения.
docker run -d --name app \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
--cap-drop=ALL --security-opt=no-new-privileges \
-p 127.0.0.1:8000:8000 registry.example.com/app:1.4.0
Режим --read-only требует, чтобы приложение не писало в собственный каталог. Если ему нужен кэш или загрузки, выделите отдельный volume или tmpfs и явно опишите это в документации. Публикация порта на 127.0.0.1 защищает сервис от прямого доступа из сети, если перед ним стоит reverse proxy.
Сканирование и состав образа
Сборка — не конец процесса. Dockerfile проверяют линтером, образ — сканером уязвимостей, а состав фиксируют в SBOM. Это позволяет быстро ответить на вопрос, какой сервис затронут очередной CVE в библиотеке.
docker run --rm -i hadolint/hadolint < Dockerfile
trivy image --severity HIGH,CRITICAL --ignore-unfixed registry.example.com/app:1.4.0
syft registry.example.com/app:1.4.0 -o spdx-json > app.spdx.json
Не все находки требуют немедленного патча: важно, используется ли уязвимая функция и доступна ли она извне. Но HIGH и CRITICAL в runtime-зависимостях лучше закрывать в течение регламентированного срока. Пересобирайте образы регулярно, даже если код не менялся: базовый слой тоже получает обновления.
Чек-лист перед публикацией
- Финальная стадия не содержит компиляторов, git и тестовых файлов.
- Базовый образ закреплён по digest, обновление автоматизировано.
- Секреты передаются через
--mount=type=secret, а не черезARG. - В Dockerfile есть
USERс числовым UID и healthcheck. .dockerignoreисключает.git,.envи логи.- Контейнер запускается с
--read-only,--cap-drop=ALLиno-new-privileges. - Образ прошёл hadolint, trivy и имеет SBOM.
- Тег образа неизменяемый: версия или digest, а не
latest.
Итог
Хороший Dockerfile — это не только команды сборки, но и дисциплина: разделить стадии, не тащить инструменты в runtime, не оставлять секреты в слоях и запускать процесс от непривилегированного пользователя. Многоступенчатая сборка уменьшает образ и поверхность атаки, BuildKit ускоряет повторные сборки, а сканирование и SBOM делают обновления управляемыми. Эти меры не требуют экзотических инструментов и работают в любом CI.