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.

Насколько публикация полезна?

Нажмите на звезду, чтобы оценить!

Средняя оценка 0 / 5. Количество оценок: 0

Оценок пока нет. Поставьте оценку первым.

Поделиться: Telegram ВКонтакте