Предисловие
Статья получилась большой: практик много, и каждая из них важна по-своему. Я собрал её как набор best practices: не все пункты нужны каждому проекту, но почти каждый пункт однажды всплывает на ревью, в CI или после неприятного инцидента.
Я старался писать для разных грейдов: от базовых ошибок вроде COPY . ., latest и root-пользователя до продовых тем вроде BuildKit, секретов, SBOM, подписи образов и защиты цепочки поставки ПО.
Поэтому язык подачи здесь намеренно сухой, прямой и инженерный: без долгих заходов, без воды и без пересказа документации ради пересказа. Я хотел сделать не обзорную статью, а рабочую памятку, к которой можно вернуться при написании, ревью или доработке Dockerfile.
Чтобы в статье было легче ориентироваться, я разбил её на смысловые блоки. Ниже оглавление: нажали на нужный пункт — сразу перешли к соответствующему разделу.
Оглавление:
Контекст сборки,
.dockerignore, копирование файлов и безопасное получение внешних данныхЗапуск процесса:
CMD,ENTRYPOINT, PID 1, SIGTERM и модель «один контейнер — один сервис»Runtime-поведение: healthcheck, порты, volumes, конфиги, логи, ресурсы и особенности Gunicorn
Цепочка поставки ПО, registry, подпись, сканирование, линтинг, тестирование и CI/CD
Отдельно буду рад вашим дополнениям в комментариях: практическим кейсам, спорным моментам, личному опыту, ошибкам, которые всплывали в реальной эксплуатации, и альтернативным подходам. Я читаю обратную связь и при необходимости обновляю материал: уточняю формулировки, исправляю неточности и добавляю полезные замечания, если они делают статью сильнее и точнее.
Зачем вообще думать о Dockerfile
Dockerfile — это не просто инструкция о том, как запустить приложение. Это описание будущей продакшен-среды: какие пакеты попадут внутрь, от какого пользователя будет работать процесс, какие секреты могут случайно остаться в слоях, насколько быстро образ будет собираться в CI/CD и насколько предсказуемо он поведёт себя через месяц.
Хороший Dockerfile должен решать три задачи одновременно:
Воспроизводимость — одна и та же версия Dockerfile должна собирать предсказуемый образ.
Минимальный размер и быстрые сборки — в образе должно быть только то, что нужно приложению.
Безопасность — меньше лишнего ПО, меньше привилегий, меньше шансов утечки секретов и проще контроль уязвимостей.
1. Базовый образ, версии и управляемое обновление
Базовый образ задаётся инструкцией FROM, и именно он сильнее всего влияет на размер, безопасность, скорость сборки и удобство будущего контейнера. Плохая привычка — брать полноценную Ubuntu/Debian/CentOS «на всякий случай», а потом получить внутри контейнера кучу ненужных утилит, библиотек и потенциальных уязвимостей.
Правило простое: используйте самый маленький и подходящий базовый образ, который реально умеет запускать ваше приложение.
Плохо:
FROM ubuntu:22.04 RUN apt-get update && apt-get install -y python3 python3-pip curl vim git
Лучше:
FROM python:3.12-slim
Ещё лучше для некоторых приложений:
FROM gcr.io/distroless/python3-debian12
1.1. Выбирайте специализированный образ под задачу
Для приложения на Node.js берите node, для Python — python, для Java — eclipse-temurin, amazoncorretto или другой поддерживаемый JDK/JRE-образ, для Nginx — nginx, для PostgreSQL — postgres.
Специализированные образы уже содержат нужный runtime, поэтому вам не приходится руками собирать окружение из случайных пакетов. Это уменьшает размер, снижает количество лишних зависимостей и делает образ понятнее для сопровождения.
При выборе смотрите на четыре вещи:
образ должен быть официальным или от доверенного поставщика;
он должен регулярно обновляться;
он должен иметь понятный Dockerfile или понятную цепочку поставки;
он должен быть достаточно маленьким, но не ценой нестабильности.
1.2. Используйте slim, alpine, distroless и scratch осознанно
Минимальный образ — это не всегда самый маленький. У каждого варианта есть цена.
slim
slim-образы обычно основаны на Debian, но содержат меньше лишних пакетов. Важное преимущество — там остаётся glibc, поэтому они часто лучше подходят для Python, Java, Node.js и приложений с нативными зависимостями.
Часто это лучший компромисс:
FROM python:3.12-slim
alpine
Alpine очень маленький, но использует musl вместо glibc. Из-за этого некоторые Python/C/C++-зависимости могут собираться дольше, работать иначе или требовать дополнительных пакетов.
Alpine хорош, когда:
приложение уже проверено на Alpine;
зависимости не конфликтуют с
musl;размер образа действительно критичен.
Но Alpine не стоит выбирать автоматически только потому, что он маленький.
distroless
Distroless-образы содержат runtime и минимальный набор библиотек, но почти не содержат системных инструментов: shell, package manager, curl, vim и т.д. Это уменьшает поверхность атаки: если злоумышленник попадёт внутрь контейнера, у него будет меньше готовых инструментов.
Минус — сложнее отлаживать: нельзя просто зайти в контейнер и выполнить bash.
Пример для Go:
FROM golang:1.22 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /app ./cmd/app FROM gcr.io/distroless/static-debian12 COPY --from=builder /app /app USER nonroot:nonroot ENTRYPOINT ["/app"]
scratch
scratch — полностью пустой образ. Он подходит только для статически собранных бинарников, которым не нужны shell, libc, сертификаты, timezone data и прочие системные файлы.
FROM scratch COPY --from=builder /app /app ENTRYPOINT ["/app"]
Используйте scratch, только если понимаете, какие файлы нужны приложению во время выполнения.
1.3. Не используйте latest в production
latest — это совсем не про стабильную версию. Это просто тег, который может измениться в зависимости от обновлений. Сегодня node:latest может указывать на одну версию Node.js, завтра — на другую. В CI/CD это превращается в лотерею: Dockerfile не менялся, а сборка внезапно сломалась.
Плохо:
FROM node:latest
Лучше:
FROM node:24.16.0-slim
Ещё строже:
FROM node@sha256:<digest>
Для прода лучше фиксировать:
версию runtime:
node:24.16.0,python:3.12.13,golang:1.22.2;вариант образа:
slim,alpine,bookworm,bullseye;при повышенных требованиях — digest через
sha256.
Тег можно перезаписать, digest — нет. Поэтому digest даёт максимальную воспроизводимость.
1.4. Обновляйте базовые образы осознанно, а не случайно
Отказ от latest не означает «никогда не обновляться». Наоборот: образы нужно регулярно пересобирать и обновлять, чтобы получать security-патчи. Разница в том, что обновление должно быть контролируемым.
Хорошая стратегия:
использовать стабильные/LTS-версии;
отслеживать окончание поддержки выбранной версии;
регулярно пересобирать образы;
сканировать их на CVE;
обновлять digest или версию после проверки.
Плохая стратегия:
FROM python:latest
Хорошая стратегия:
FROM python:3.12.13-slim-bookworm
А затем отдельным процессом обновлять до следующего актуального patch-релиза или нового digest, проверять тесты и сканирование, и только потом выкатывать.
1.5. Не делайте автоматический upgrade всех системных пакетов
Команды вроде apt-get upgrade, yum update, apk upgrade внутри Dockerfile часто делают сборку менее предсказуемой. Сегодня они поставят один набор пакетов, завтра — другой. Кроме того, вы можете незаметно получить новые компоненты, которые не проверялись SCA/сканерами.
Речь не о том, что security-патчи не нужны. Нужны. Но лучше получать их через обновление базового образа, регулярную пересборку и контролируемое обновление, а не через случайный apt-get upgrade в каждом build без фиксации и проверки.
Плохо:
RUN apt-get update && apt-get upgrade -y
Лучше:
RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ && rm -rf /var/lib/apt/lists/*
В средах с жёсткими требованиями фиксируйте версии пакетов:
RUN apt-get update && apt-get install -y --no-install-recommends \ cowsay=3.03+dfsg1-6 \ && rm -rf /var/lib/apt/lists/*
Если пакет тянет зависимость, её тоже нужно учитывать при анализе уязвимостей.
1.6. Устанавливайте только нужные пакеты
Каждый установленный пакет — это:
дополнительный размер;
новые CVE;
больше времени на сборку и pull/push;
больше инструментов внутри контейнера, которыми может воспользоваться злоумышленник.
Поэтому не ставьте в runtime-образ vim, git, gcc, make, curl, wget, bash, если приложение без них работает.
Для Debian/Ubuntu почти всегда используйте:
RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ && rm -rf /var/lib/apt/lists/*
--no-install-recommends не даёт пакетному менеджеру поставить рекомендованные, но не обязательные зависимости.
2. Build context, .dockerignore, копирование файлов и безопасное получение внешних данных
Этот блок объединяет всё, что попадает в образ извне: файлы проекта, архивы, зависимости, внешние URL, секреты в контексте сборки и выбор между COPY и ADD. Главная идея: в образ должно попадать только то, что нужно, из понятного источника и проверенным способом.
2.1. Используйте .dockerignore
Docker перед сборкой отправляет контекст сборки демону. Если контекст — это корень проекта, туда могут попасть .git, node_modules, .env, ключи, логи, кеши, артефакты сборки и локальные настройки IDE.
.dockerignore решает сразу несколько задач:
уменьшает контекст сборки;
ускоряет сборку;
уменьшает риск утечки секретов;
предотвращает лишнюю инвалидацию кеша;
не даёт случайно скопировать мусор в образ.
Пример базового .dockerignore:
.git .gitignore .vscode/ .idea/ .env .env.* *.log __pycache__/ *.pyc node_modules/ coverage/ dist/ build/ .cache/ .DS_Store .aws/ .ssh/
Для Go/Java/компилируемых проектов часто лучше использовать allowlist-подход: сначала игнорировать всё, а потом разрешить только нужное.
* !go.mod !go.sum !cmd/ !internal/ !pkg/
Но важно не перегнуть: .dockerignore должен соответствовать языку и сборке. Если Java-образу нужен только готовый .jar, то лучше копировать только его, а не весь проект.
2.2. Не копируйте весь проект без необходимости
Антипаттерн:
COPY . .
Иногда это нормально, но часто это слишком широко. Такая команда копирует всё, что осталось в build context после .dockerignore. Если .dockerignore неполный, в образ попадут лишние файлы.
Лучше копировать явно:
COPY package.json package-lock.json ./ RUN npm ci COPY src/ ./src/
Или для Java:
COPY target/app.jar /app/app.jar
Смысл: Dockerfile должен копировать ровно то, что нужно для сборки или запуска.
2.3. Используйте COPY по умолчанию, а ADD — когда его возможности действительно нужны
COPY — лучший выбор по умолчанию, когда нужно просто перенести локальные файлы из контекста сборки в образ. Он делает одну понятную вещь: копирует файлы и директории. Поэтому для исходников, lock-файлов, конфигов, собранных .jar, бинарников и других локальных артефактов чаще всего нужен именно COPY.
COPY package.json package-lock.json ./ COPY src/ ./src/
Такой Dockerfile проще читать и ревьюить: сразу понятно, что мы берём файлы из контекста сборки и кладём их в образ. Никакой загрузки по сети, автоматической распаковки или неявной логики здесь нет.
ADD умеет больше:
копировать локальные файлы;
распаковывать локальные tar-архивы;
скачивать файлы по URL;
работать с Git-источниками;
проверять checksum для удалённых ресурсов;
управлять распаковкой tar-архивов через
--unpack.
Из-за этой многофункциональности ADD не стоит использовать как замену COPY для обычного копирования.
Плохой вариант — использовать ADD для удалённого файла без проверки:
ADD https://example.com/app.tar.gz /app
Такой Dockerfile не говорит читателю, какую именно версию артефакта мы ожидаем получить, и не проверяет его контрольную сумму. Источник вроде бы указан, но доверие к содержимому остаётся неявным.
Лучше, если удалённый артефакт публичный и у вас есть фиксированная SHA-256-сумма:
# syntax=docker/dockerfile:1 ARG TOOL_VERSION=1.2.3 ADD --checksum=sha256:<expected_sha256> \ --unpack=true \ https://example.com/tool-${TOOL\_VERSION}.tar.gz /usr/local/bin/
В этом случае ADD делает именно то, что нужно: скачивает удалённый артефакт, проверяет его контрольную сумму и, если это tar-архив, распаковывает его в нужное место. При этом не нужно ставить в build-стадию curl, tar, sha256sum и shell только ради одной загрузки.
У такого подхода есть ещё один плюс: BuildKit лучше понимает удалённый артефакт как отдельный вход сборки. Это помогает с кешем и особенно заметно в multi-platform сборках, где лишние команды внутри RUN могут внезапно начать выполняться через QEMU для не-нативной архитектуры.
Но ADD --checksum / ADD --unpack=true — не универсальная замена RUN.
Если загрузка требует авторизации, токенов, кастомных заголовков, GPG-проверки или более сложной логики, тогда RUN остаётся хорошим вариантом. Только секреты в таком случае нельзя передавать через ARG или ENV; лучше использовать BuildKit secrets.
Итого правило получается такое:
локальные файлы из контекста сборки —
COPY;локальный tar-архив, который вы осознанно хотите распаковать, —
ADD;публичный удалённый архив с фиксированной SHA-256-суммой —
ADD --checksumи при необходимостиADD --unpack=true;закрытая загрузка, токены, кастомные заголовки, GPG-проверка или сложная логика —
RUNвместе с BuildKit secrets.
Главная мысль не в том, что ADD плохой. Плохой — неявный и непроверенный источник данных. Если источник понятный, checksum зафиксирован, а поведение Dockerfile очевидно, ADD вполне может быть правильным инструментом.
2.4. Скачивайте внешние файлы безопасно
Опасный антипаттерн:
RUN curl -fsSL http://example.com/install.sh | sh
Проблемы здесь сразу три:
используется небезопасный канал или непроверенный источник;
скачанный скрипт сразу выполняется;
не проверяется подпись или checksum.
Если это публичный архив и достаточно проверки по SHA-256, используйте ADD --checksum и при необходимости ADD --unpack=true, как в примере выше. Так Dockerfile явно показывает внешний источник, ожидаемую контрольную сумму и поведение при распаковке.
Если загрузка требует токена, нестандартных заголовков, GPG-проверки или любой дополнительной логики, ADD уже не всегда подходит. В таком случае лучше оставить явный RUN: скачать артефакт, проверить его и удалить временные файлы в той же инструкции. Секреты при этом не стоит передавать через ARG или ENV — их безопаснее монтировать только на время сборки через BuildKit.
# syntax=docker/dockerfile:1 ARG TOOL_VERSION=1.2.3 ARG TOOL_SHA256=<expected_sha256> RUN --mount=type=secret,id=download_token <<'EOF' set -eu TOKEN="$(cat /run/secrets/download_token)" curl -fsSLo /tmp/tool.tar.gz \ -H "Authorization: Bearer ${TOKEN}" \ "https://example.com/tool-${TOOL_VERSION}.tar.gz" echo "${TOOL_SHA256} /tmp/tool.tar.gz" | sha256sum -c - tar -xzf /tmp/tool.tar.gz -C /usr/local/bin rm -f /tmp/tool.tar.gz EOF
Здесь важны две вещи: секрет живёт только во время конкретного RUN, а временный архив удаляется в той же инструкции, где был создан.
Если добавляете сторонний apt/yum/apk-репозиторий, проверяйте его GPG-ключи и источник. Не делайте из Dockerfile цепочку доверия к случайному URL.
3. Слои, порядок инструкций, кеширование и BuildKit
Docker-образ состоит из слоёв. Инструкции RUN, COPY, ADD создают новые слои. Если слой изменился, пересобирается он и всё, что находится ниже. Поэтому порядок инструкций, объединение команд и работа с кешем напрямую влияют на скорость сборки, размер образа и безопасность.
3.1. Располагайте инструкции так, чтобы работал кеш
Частая ошибка — сначала скопировать весь код, а потом установить зависимости.
Плохо:
COPY . . RUN npm install
Любое изменение в коде сбросит кеш установки зависимостей.
Лучше:
COPY package.json package-lock.json ./ RUN npm ci COPY . .
Общий принцип:
Сначала базовый образ.
Потом системные зависимости.
Потом lock-файлы и манифесты зависимостей.
Потом установка зависимостей.
Потом исходный код.
В самом конце — то, что меняется чаще всего.
Для Python:
COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY . .
Для Go:
COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o /app ./cmd/app
Для Node.js:
COPY package*.json ./ RUN npm ci COPY . .
3.2. Объединяйте связанные команды в один RUN
Если вы создадите лишний мусор в одном слое, а удалите его в другом, размер образа всё равно может остаться большим: данные уже попали в нижний слой.
Плохо:
RUN apt-get update RUN apt-get install -y curl RUN rm -rf /var/lib/apt/lists/*
Лучше:
RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/*
Правило: создали временный файл, кеш или индекс пакетов — удалите его в той же инструкции RUN.
Для разных пакетных менеджеров:
# Debian/Ubuntu RUN apt-get update && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* # Alpine RUN apk add --no-cache curl # Yum/DNF RUN dnf install -y curl \ && dnf clean all \ && rm -rf /var/cache/dnf
3.3. Минимизируйте слои, но не превращайте Dockerfile в нечитаемый монолит
Сокращение числа слоёв полезно, но это не единственная цель. Если объединить весь Dockerfile в один огромный RUN, его будет трудно читать, поддерживать и кешировать.
Хороший подход:
объединять логически связанные команды;
держать отдельными шаги, которые выгодно кешировать;
не тратить много времени на микрооптимизацию builder-стадии, если она не попадает в финальный образ;
сортировать списки пакетов по алфавиту для читаемости и удобных diff.
Пример:
RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ libpq5 \ && rm -rf /var/lib/apt/lists/*
3.4. Проверяйте, что реально лежит в слоях
Не доверяйте ощущению, что вы удалили файл. Проверяйте образ.
Полезные команды:
docker history my-image:tag
docker image inspect my-image:tag
Также удобно использовать инструменты анализа слоёв вроде dive. Они показывают, какие файлы добавлены, изменены или удалены в каждом слое, и помогают найти мусор, секреты, кеши и тяжёлые зависимости.
3.5. Используйте BuildKit
BuildKit — современный backend сборки Docker. Он умеет эффективнее строить граф зависимостей, параллелить независимые стадии, использовать cache mounts, secret mounts и bind mounts.
Включить можно так:
DOCKER_BUILDKIT=1 docker build -t myapp .
Или использовать docker buildx.
Cache mounts
BuildKit позволяет кешировать зависимости, не запекая кеш в слой образа.
Python:
# syntax=docker/dockerfile:1.8 RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txt
Node.js:
# syntax=docker/dockerfile:1.8 RUN --mount=type=cache,target=/root/.npm \ npm ci
Apt:
# syntax=docker/dockerfile:1.8 RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && apt-get install -y --no-install-recommends curl
Bind mounts на этапе сборки
Иногда файл нужен только для команды сборки, но не должен попадать в образ. BuildKit позволяет смонтировать его временно.
Например, вместо копирования requirements.txt в runtime-стадию можно использовать bind mount:
# syntax=docker/dockerfile:1.8 RUN --mount=type=bind,source=requirements.txt,target=/tmp/requirements.txt,readonly \ --mount=type=cache,target=/root/.cache/pip \ pip install --no-cache-dir -r /tmp/requirements.txt
Это помогает уменьшить количество слоёв и не оставлять лишние файлы в образе.
Heredoc для сложных RUN
Когда в RUN одна-две команды, обычный вариант с && и переносами строк читается нормально:
RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/*
Но если команда сборки становится длиннее, появляются cache mounts, secret mounts, bind mounts, несколько проверок и условная логика, Dockerfile быстро превращается в набор обратных слешей. В таких местах удобнее использовать heredoc:
# syntax=docker/dockerfile:1 RUN --mount=type=cache,target=/root/.cache/pip <<EOF set -eux python -m pip install --upgrade pip pip install -r requirements.txt python -m compileall /app EOF
Heredoc не делает сборку безопасной сам по себе, но делает сложные build-шаги читаемее. Это важно не только для красоты: такой код проще ревьюить, проще менять и проще отлаживать.
Здесь тоже нужен баланс. Если внутри RUN уже живёт полноценный сценарий на десятки строк, с функциями и сложными условиями, возможно, его лучше вынести в отдельный скрипт и явно скопировать в builder-стадию. Но для средних по размеру build-шагов heredoc часто намного удобнее классической записи через \.
3.6. Используйте кеш в CI/CD правильно
Без кеша Docker-сборки в CI могут быть медленными. Но кеш не должен ломать безопасность и воспроизводимость.
Практики:
используйте BuildKit/buildx cache;
используйте
--cache-fromи--cache-to, если runner одноразовый;кешируйте зависимости через
--mount=type=cache;не кешируйте секреты;
не полагайтесь на кеш как на единственный способ получить актуальные security-патчи;
периодически делайте чистую пересборку и сканирование.
Пример с buildx:
docker buildx build \ --cache-from=type=registry,ref=registry.example.com/myapp:buildcache \ --cache-to=type=registry,ref=registry.example.com/myapp:buildcache,mode=max \ -t registry.example.com/myapp:${GIT_SHA} \ --push \ .
3.7. Рассмотрите Podman/Buildah как альтернативу Docker в CI
В CI не всегда удобно или безопасно использовать классический Docker-in-Docker. В таких случаях можно рассмотреть podman/buildah: они умеют собирать образы по Dockerfile/Containerfile-синтаксису и часто лучше ложатся в rootless-сценарии.
Отдельно полезна работа с registry-based cache через --cache-from и --cache-to. Идея такая: кеш слоёв хранится не только локально на раннере, а в registry. Это особенно полезно, когда CI-runner одноразовый и каждый pipeline стартует с пустой машины.
Пример для Podman/Buildah:
podman build \ --layers \ --cache-from registry.example.com/myapp/buildcache \ --cache-to registry.example.com/myapp/buildcache \ -t registry.example.com/myapp:${GIT_SHA} \ .
Аналогичный подход есть и в Docker Buildx/BuildKit через --cache-from и --cache-to. Для CI важно не столько “Docker или Podman”, сколько поддержка переносимого кеша, который можно сохранять в registry и переиспользовать между пайпланами.
Когда Podman/Buildah может быть особенно уместен:
в rootless CI-сборках;
когда не хочется поднимать Docker daemon внутри job;
когда инфраструктура уже использует Podman;
когда нужен daemonless-подход;
когда cache нужно хранить в registry и переиспользовать между одноразовыми раннерами.
3.8. Когда docker buildx build уже мало: buildx bake
Для простого сервиса обычно хватает обычной команды docker buildx build. Но со временем она может превратиться в длинную строку с платформами, target-ами, кешами, аргументами, тегами, SBOM, provenance и разными вариантами образа.
В таких случаях можно посмотреть в сторону docker buildx bake. Это способ описать сборку декларативно: не собирать огромную CLI-команду руками, а вынести цели, платформы, аргументы и кеши в отдельный файл.
Минимальный пример:
variable "GIT_SHA" { default = "dev" } group "default" { targets = ["app"] } target "app" { dockerfile = "Dockerfile" context = "." platforms = ["linux/amd64", "linux/arm64"] tags = ["registry.example.com/myapp:${GIT_SHA}"] cache-from = ["type=registry,ref=registry.example.com/myapp:buildcache"] cache-to = ["type=registry,ref=registry.example.com/myapp:buildcache,mode=max"] }
Запуск:
GIT_SHA=${GIT_SHA} docker buildx bake
bake особенно полезен, когда нужно собирать несколько вариантов образа: обычный runtime-образ, debug-образ, образ с санитайзерами, отдельные target-ы для тестов, несколько платформ или несколько связанных артефактов.
Но для маленького проекта это может быть лишним усложнением. Если вся сборка помещается в одну понятную команду docker buildx build, не нужно тащить bake только ради красоты. Он начинает окупаться там, где сборка уже стала отдельной конфигурацией, а не одной командой в CI.
4. Multi-stage build и языковые оптимизации
Multi-stage build отделяет среду сборки от среды запуска. В builder-стадии могут быть компиляторы, SDK, dev-зависимости, кеши и исходники. В финальном образе должны остаться только runtime и нужные артефакты.
Эта практика одновременно уменьшает размер образа, снижает поверхность атаки и делает runtime-образ чище.
4.1. Используйте multi-stage build
Плохо:
FROM golang:1.22 WORKDIR /app COPY . . RUN go build -o app ./cmd/app CMD ["./app"]
Так в образе останутся Go toolchain, исходники и лишние файлы.
Лучше:
FROM golang:1.22 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /app ./cmd/app FROM gcr.io/distroless/static-debian12 COPY --from=builder /app /app USER nonroot:nonroot ENTRYPOINT ["/app"]
Multi-stage особенно полезен для:
Go, Rust, C/C++;
Java-приложений, где build-стадия собирает jar;
Python, если нужно собрать wheels или venv;
Node.js, если нужно отделить dev-зависимости от production-зависимостей;
frontend-сборок, где Node.js нужен только для сборки статических файлов.
4.2. Для Python собирайте wheels или переносите virtualenv из builder-стадии
В Python-проектах некоторые зависимости требуют компиляции. Компилятор нужен во время сборки, но не нужен в runtime.
Вариант с wheel-файлами:
FROM python:3.12-slim AS builder WORKDIR /build RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt FROM python:3.12-slim WORKDIR /app COPY --from=builder /wheels /wheels COPY requirements.txt . RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \ && rm -rf /wheels COPY . . CMD ["python", "main.py"]
Вариант с virtualenv:
FROM python:3.12-slim AS builder RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt FROM python:3.12-slim ENV PATH="/opt/venv/bin:$PATH" COPY --from=builder /opt/venv /opt/venv WORKDIR /app COPY . . CMD ["python", "main.py"]
Virtualenv внутри контейнера обычно не нужен, потому что контейнер уже изолирует окружение. Но в multi-stage он бывает удобен: можно собрать окружение в builder-стадии и перенести его целиком в runtime.
4.3. Ускоряйте Python-сборки современными инструментами, но не ломайте предсказуемость
Для Python можно использовать uv и другие быстрые установщики зависимостей. Это ускоряет сборки, особенно в CI. Но ускорение не должно отменять базовые правила:
фиксируйте зависимости lock-файлом;
используйте кеш BuildKit;
не кладите кеш в финальный образ;
отделяйте build-зависимости от runtime-зависимостей;
не копируйте секреты и лишние файлы.
Пример идеи:
# syntax=docker/dockerfile:1.8 RUN --mount=type=cache,target=/root/.cache/uv \ uv pip install --system -r requirements.txt
4.4. Для multi-platform сборок не гоняйте build-стадию через QEMU без необходимости
Multi-stage build отделяет сборку от запуска, но при multi-platform сборках появляется ещё одна ловушка. Если собирать образ сразу под несколько архитектур, например linux/amd64 и linux/arm64, builder может начать выполнять не-нативные стадии через эмуляцию. Работать будет, но иногда очень медленно.
Особенно больно это проявляется в компилируемых проектах: Go, Rust, C, C++, иногда Java с нативными зависимостями. Нативная для раннера архитектура собирается быстро, а вторая внезапно начинает жить внутри QEMU и превращает пайплайн в ожидание.
Если язык и сборочная система поддерживают кросс-компиляцию, build-стадию лучше запускать на архитектуре builder-а, а внутри неё собирать артефакт под целевую архитектуру.
Пример для Go:
# syntax=docker/dockerfile:1 FROM --platform=$BUILDPLATFORM golang:1.22 AS builder WORKDIR /src ARG TARGETOS ARG TARGETARCH COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download COPY . . RUN --mount=type=cache,target=/root/.cache/go-build <<EOF set -eux CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ go build -o /out/app ./cmd/app EOF FROM gcr.io/distroless/static-debian12 COPY --from=builder /out/app /app USER nonroot:nonroot ENTRYPOINT ["/app"]
Ключевая строка здесь:
FROM --platform=$BUILDPLATFORM golang:1.22 AS builder
Она заставляет builder-стадию запускаться на платформе машины, которая выполняет сборку. А TARGETOS и TARGETARCH используются уже для компиляции под нужную целевую платформу.
Смысл простой: финальный образ должен быть под target-платформу, но тяжёлую build-логику лучше выполнять нативно, если это возможно. Для небольших проектов разница может быть незаметной, но для больших C/C++/Rust/Go-сборок это иногда разница между несколькими минутами и часом ожидания.
5. Секреты: build-time, runtime и защита от утечек в слоях
Секреты нельзя передавать через ENV, ARG, COPY, RUN echo ... и нельзя оставлять в файлах, которые попадают в контекст сборки.
Плохо:
ARG SSH_PRIVATE_KEY ENV API_TOKEN=super-secret COPY . .
Почему плохо:
секрет может попасть в слой;
секрет может быть виден через
docker history;переменные окружения можно увидеть через inspect/runtime;
удаление файла в следующем слое не удаляет его из предыдущего слоя.
5.1. BuildKit secrets для этапа сборки
# syntax=docker/dockerfile:1.8 RUN --mount=type=secret,id=npm_token \ NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
Сборка:
docker build \ --secret id=npm_token,src=.npm_token \ -t myapp .
Секрет монтируется только на время конкретного RUN и не сохраняется в слой.
5.2. Runtime secrets
Для runtime используйте:
Docker secrets;
Kubernetes Secrets;
Secret Manager/Vault;
переменные окружения только если это приемлемо для вашей модели угроз;
mounted secret files.
И обязательно исключайте .env, .aws, .ssh, приватные ключи и локальные конфиги через .dockerignore.
6. Пользователь, UID, права файлов и неизменяемость контейнера
Этот блок объединяет практики, связанные с принципом наименьших привилегий. Контейнер не должен работать от root без необходимости, приложение не должно иметь возможность переписывать собственный код, а файловая система должна быть устроена так, чтобы контейнер мог запускаться в разных runtime-средах.
6.1. Запускайте приложение не от root
По умолчанию контейнер часто запускает процесс от root. Это удобно, но плохо для безопасности. Если приложение скомпрометировано, root внутри контейнера увеличивает риск эскалации, особенно при volume, Docker socket, capabilities или ошибках конфигурации.
Создайте пользователя и переключитесь на него:
RUN addgroup --system app && adduser --system --ingroup app app USER app
Для Debian/Ubuntu:
RUN groupadd -r app && useradd -r -g app -d /nonexistent -s /usr/sbin/nologin app USER app
Для Alpine:
RUN addgroup -S app && adduser -S app -G app USER app
Если базовый образ уже содержит non-root пользователя, используйте его. Например, в Node.js-образах часто есть пользователь node.
6.2. Не привязывайтесь жёстко к одному UID
В некоторых окружениях, например OpenShift, контейнер может запускаться с произвольным UID. Если Dockerfile рассчитан только на конкретного пользователя и конкретный UID, приложение может не иметь доступа к нужным директориям.
Плохо:
RUN mkdir /app-tmp && chown -R app:app /app-tmp USER app ENV TMP_DIR=/app-tmp
Если runtime запустит контейнер другим UID, запись в /app-tmp может сломаться.
Лучше:
ENV TMP_DIR=/tmp
Практики:
пишите временные файлы в
/tmp, где это уместно;делайте файлы приложения доступными на чтение, если это безопасно;
не требуйте владения файлами для выполнения;
проверяйте запуск с произвольным UID;
не решайте проблемы прав запуском от root.
6.3. Делайте исполняемые файлы неизменяемыми для runtime-пользователя
Не всегда нужно делать пользователя приложения владельцем кода. Часто приложению достаточно прав на чтение и выполнение.
Плохой паттерн:
COPY --chown=app:app . /app USER app
Если приложение или злоумышленник получит возможность писать в /app, он сможет изменить исполняемые файлы или entrypoint-скрипты.
Лучше:
COPY . /app RUN chmod -R a-w /app && chmod +x /app/entrypoint.sh USER app
Идея: код и исполняемые файлы принадлежат root, runtime-пользователь может их читать/исполнять, но не изменять. Отдельные директории для записи создаются явно: /tmp, /var/cache/myapp, /data и т.д.
6.4. Если на старте нужны root-действия, используйте gosu/su-exec, а не sudo
Иногда entrypoint должен сначала сделать root-действие: например, поправить ownership volume, а потом запустить приложение от обычного пользователя.
В таком случае не стоит запускать само приложение через sudo. Лучше использовать gosu или su-exec, потому что они не создают лишнюю цепочку процессов и помогают сохранить модель «один основной процесс».
Пример:
#!/bin/sh set -e if [ "$1" = "myapp" ]; then chown -R app:app /data exec gosu app "$@" fi exec "$@"
Важно: gosu — не универсальная замена sudo. Он уместен именно в entrypoint-сценариях, где нужно выполнить минимальные root-действия и затем заменить процесс на приложение.
7. Запуск процесса: CMD, ENTRYPOINT, PID 1, SIGTERM и модель «один контейнер — один сервис»
Контейнер должен корректно запускаться, принимать сигналы остановки, завершаться без потери состояния и не превращаться в мини-виртуальную машину с несколькими независимыми сервисами внутри.
7.1. Используйте exec-форму CMD и ENTRYPOINT
Docker поддерживает две формы.
Shell-форма:
CMD "python app.py"
Exec-форма:
CMD ["python", "app.py"]
Почти всегда лучше exec-форма. При shell-форме Docker запускает /bin/sh -c ..., и shell становится PID 1. Из-за этого сигналы могут не доходить до приложения, а завершение контейнера становится менее предсказуемым.
Плохо:
ENTRYPOINT python app.py
Лучше:
ENTRYPOINT ["python", "app.py"]
7.2. Понимайте разницу между CMD и ENTRYPOINT
ENTRYPOINT — это основная команда контейнера. Её сложнее случайно переопределить.
CMD — это команда по умолчанию или аргументы по умолчанию. Её легко заменить при docker run.
Хороший паттерн:
ENTRYPOINT ["gunicorn", "config.wsgi", "-b", "0.0.0.0:8000", "-w"] CMD ["4"]
По умолчанию будет:
gunicorn config.wsgi -b 0.0.0.0:8000 -w 4
А при запуске можно поменять только аргумент:
docker run myapp 8
То есть ENTRYPOINT задаёт что запускать, а CMD — с какими параметрами по умолчанию.
7.3. Правильно обрабатывайте SIGTERM и PID 1
В Kubernetes и Docker при остановке контейнер сначала получает SIGTERM, затем после окончания времени, выделенного на корректное завершение — SIGKILL. Если приложение не получает SIGTERM, оно не сможет корректно закрыть соединения, завершить запросы, записать состояние или освободить ресурсы.
Частая проблема:
ENTRYPOINT ["/app/start.sh"]
А внутри start.sh:
python app.py
В этом случае shell-скрипт остаётся PID 1 и может не прокинуть сигнал приложению.
Правильно:
#!/bin/sh set -e exec python app.py
exec заменяет shell процессом приложения, и приложение становится PID 1.
Если приложению нужен init-процесс, используйте tini или аналогичный минимальный init, который прокидывает сигналы и убирает zombie-процессы.
7.4. Запускайте один основной процесс на контейнер
Контейнер лучше проектировать вокруг одного сервиса: один web-server, один worker, один database process и т.д. Это упрощает:
масштабирование;
логирование;
healthcheck;
graceful shutdown;
обновления;
переиспользование;
отладку.
Плохо: один контейнер запускает Nginx, приложение, cron и базу данных.
Лучше: отдельный контейнер для каждого сервиса, а связь между ними через сеть, volumes, очередь или оркестратор.
Исключения бывают: sidecar-паттерны, init-процессы, тесно связанные вспомогательные процессы. Но это должно быть осознанное архитектурное решение, а не привычка запихнуть всё внутрь.
8. Runtime-поведение: healthcheck, порты, volumes, конфиги, логи, ресурсы и особенности Gunicorn
Dockerfile — это только часть истории. Образ должен нормально жить во время выполнения: отвечать на проверки, не открывать лишние точки входа, не хранить изменяемые данные внутри себя, писать логи наружу и корректно работать под ограничениями CPU/memory.
8.1. Добавляйте HEALTHCHECK, если образ запускается в Docker/Docker Swarm
Docker считает контейнер живым, пока жив основной процесс. Но процесс может зависнуть, уйти в дедлок, перестать отвечать или возвращать 500.
Пример:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD wget -qO- http://127.0.0.1:8000/health || exit 1
Или:
HEALTHCHECK CMD curl --fail http://127.0.0.1:8000/health || exit 1
Но не стоит устанавливать curl только ради healthcheck, если можно сделать проверку средствами приложения или минимальной утилитой, которая уже есть в образе.
Для Kubernetes помните: Dockerfile HEALTHCHECK не заменяет livenessProbe, readinessProbe и startupProbe. В Kubernetes проверки лучше описывать в манифестах.
8.2. Не открывайте лишние порты
Каждый порт — потенциальная точка входа. В Dockerfile инструкция EXPOSE не публикует порт наружу сама по себе, она скорее документирует намерение.
Хорошо:
EXPOSE 8080
Но публикация делается при запуске:
docker run -p 8080:8080 myapp
Практики:
указывайте только те порты, которые реально нужны;
не запускайте SSH внутри контейнера;
не публикуйте порты без необходимости;
для локальной разработки при необходимости биндуйте на
127.0.0.1, а не на все интерфейсы.
8.3. Осторожно используйте volumes и bind mounts
Bind mount может затереть содержимое директории внутри контейнера. Например, если в образе уже есть /app, а вы монтируете туда текущую директорию, содержимое /app из образа будет скрыто.
Опасный запуск:
docker run -v $(pwd):/app myapp
Это удобно для разработки, но может ломать продакшен поведение.
Практики:
для данных используйте именованные тома;
для конфигов монтируйте конкретные файлы, а не весь корень проекта;
не храните изменяемые данные внутри образа;
не используйте volume как способ доставить секреты без контроля прав;
проверяйте, что volume не требует root-владения.
8.4. Не храните environment-specific конфиги в образе
Образ должен быть одинаковым для dev, staging и production. Отличаться должны настройки запуска: переменные окружения, секреты, config maps, mounted config files.
Плохо:
COPY config.prod.yml /app/config.yml
Лучше:
COPY config.example.yml /app/config.example.yml
А реальный продакшен-конфиг передавать на runtime через оркестратор.
8.5. Пишите логи в stdout/stderr
Контейнеризованное приложение не должно писать основные логи только в файл внутри контейнера. Логи должны идти в stdout/stderr, чтобы Docker, Kubernetes и внешняя система логирования могли их собирать.
Плохо:
myapp --log-file /var/log/myapp.log
Лучше:
myapp --log-format json
Или настройка приложения:
LOG_TO_STDOUT=true
Файлы логов внутри контейнера усложняют ротацию, сбор и диагностику.
8.6. Ограничивайте CPU и memory на этапе запуска
Это не совсем Dockerfile-практика, но важная часть продакшен-контейнеров. Если контейнеру не задать лимиты, он может съесть память или CPU хоста и повлиять на другие сервисы.
Docker:
docker run --cpus=2 --memory=512m myapp
Docker Compose:
services: app: image: myapp:1.0.0 deploy: resources: limits: cpus: "2" memory: 512M reservations: cpus: "1" memory: 256M
В Kubernetes задавайте resources.requests и resources.limits.
8.7. Для Gunicorn используйте memory-backed worker temp directory
Gunicorn heartbeat использует временные файлы. Если они лежат на дисковой файловой системе, возможны задержки и подвисания на операциях вроде os.fchmod.
Практика:
gunicorn --worker-tmp-dir /dev/shm config.wsgi -b 0.0.0.0:8000
Особенно полезно для Python web-приложений в контейнерах.
9. Цепочка поставки ПО, registry, подпись, сканирование, линтинг, тестирование и CI/CD
Даже идеально написанный Dockerfile не защищает полностью, если образы берутся из случайных источников, не подписываются, не сканируются, пушатся только как latest, а CI имеет лишние привилегии. Поэтому практики вокруг образа так же важны, как и сам Dockerfile.
9.1. Добавляйте metadata labels
Labels помогают понять, что это за образ, кто его поддерживает, где исходники, какая версия приложения, где документация и куда писать по вопросам безопасности.
Пример:
LABEL org.opencontainers.image.title="myapp" LABEL org.opencontainers.image.description="Example service" LABEL org.opencontainers.image.version="1.2.3" LABEL org.opencontainers.image.source="https://git.example.com/team/myapp" LABEL org.opencontainers.image.vendor="Example Team" LABEL org.opencontainers.image.licenses="MIT" LABEL securitytxt="https://example.com/.well-known/security.txt"
Для публичных образов полезен security.txt: он подсказывает исследователям, куда сообщать о найденных проблемах безопасности.
9.2. Храните образы в доверенном registry
Для прода лучше использовать приватный registry или доверенное enterprise-хранилище образов. Публичный Docker Hub удобен, но не все образы там поддерживаются, обновляются и сканируются.
Практики:
хранить внутренние образы в private registry;
ограничивать права на push/pull;
включать сканирование в registry;
не тянуть в прод случайные образы неизвестных авторов;
перед использованием стороннего образа проверять источник, Dockerfile, подпись, частоту обновлений и результаты сканирования.
9.3. Подписывайте и проверяйте образы
Тег образа может измениться. Registry может быть скомпрометирован. MITM-атаки и подмена образа — реальные риски для экосистемы.
Практика: подписывать собственные образы и проверять подпись перед использованием.
Раньше в этом контексте часто упоминали Docker Content Trust и Notary v1, но для новых production-процессов лучше смотреть в сторону более современных механизмов: Sigstore/Cosign, Notation/Notary Project, а также политик проверки подписи в CI/CD, registry или Kubernetes admission controller.
Пример с Cosign:
IMAGE="registry.example.com/myapp@sha256:<digest>" cosign sign "$IMAGE" cosign verify "$IMAGE" \ --certificate-identity="https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main" \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com"
Пример с Notation:
IMAGE="registry.example.com/myapp@sha256:<digest>" notation sign "$IMAGE" notation verify "$IMAGE"
Важно не просто подписать образ, а встроить проверку подписи в процесс запуска или развёртывания пайплайна. Иначе подпись будет существовать сама по себе, и не будет реально защищать прод от подмены образа.
9.4. Формируйте SBOM и provenance
Для контроля происхождения и состава образа важно понимать не только то, что образ подписан, но и что именно в него попало и как он был собран.
SBOM — это Software Bill of Materials, то есть список компонентов внутри образа: системные пакеты, runtime-зависимости, библиотеки приложения и их версии. Он помогает быстрее понять, затрагивает ли новая CVE конкретный образ, какие зависимости нужно обновить и откуда появился уязвимый компонент.
Provenance — это сведения о происхождении сборки: из какого репозитория, коммита, workflow, раннера, сборщика и с какими параметрами был собран образ. Это особенно полезно, когда нужно доказать, что продакшен образ действительно собран из нужного исходного кода, а не появился в registry вручную или из неизвестного пайплайна.
Пример с Docker Buildx:
docker buildx build \ --sbom=true \ --provenance=true \ -t registry.example.com/myapp:${GIT_SHA} \ --push \ .
Практики:
генерировать SBOM для продовых образов;
хранить SBOM и provenance рядом с образом или в системе артефактов;
использовать форматы SPDX или CycloneDX, если этого требуют процессы безопасности;
связывать SBOM/provenance с подписью образа;
проверять provenance в CI/CD или admission policy, если нужна строгая защита supply chain.
SBOM не заменяет сканирование, а provenance не заменяет подпись. Они дополняют друг друга: подпись отвечает на вопрос «можно ли доверять этому артефакту», SBOM — «что внутри», provenance — «как и откуда он был собран».
9.5. Сканируйте образы на уязвимости, секреты, malware и misconfiguration
Даже хороший Dockerfile может собрать образ с уязвимым базовым слоем или зависимостью. Поэтому сканирование должно быть частью CI/CD.
Что проверять:
CVE в системных пакетах;
CVE в зависимостях приложения;
секреты;
ошибки конфигурации;
запуск от root;
ADDбез checksum или там, где достаточноCOPY;лишние открытые порты;
подозрительные файлы;
вредоносные файлы, если есть такие требования.
Инструменты: Trivy, Snyk, Clair, Grype, Anchore, Dockle и аналоги.
Пример:
trivy image --scanners vuln,secret,misconfig myapp:1.2.3
Сканирование нужно делать:
локально до push;
в CI после сборки;
в registry после загрузки;
периодически для уже опубликованных образов, потому что новые CVE появляются позже.
9.6. Используйте Dockerfile linters и Docker Build Checks
Линтер ловит типовые ошибки раньше, чем они попадут в прод.
Самый известный инструмент — hadolint.
Пример:
hadolint Dockerfile
Он может подсветить:
отсутствие фиксированного тега;
shell-форму
CMD/ENTRYPOINT;лишние последовательные
RUN;отсутствие очистки package manager cache;
небезопасные паттерны установки пакетов.
Дополнительно используйте встроенные Docker Build Checks. Это проверки, которые запускаются на этапе сборки и помогают поймать ошибки Dockerfile ещё до публикации образа.
Пример:
docker buildx build --check .
Build Checks могут подсветить, например:
использование секретов в
ARGилиENV;попытку скопировать файл, который исключён через
.dockerignore;неопределённые
ARGвFROM;устаревший или неоднозначный формат инструкций;
shell-форму команд там, где лучше использовать JSON/exec-форму.
hadolint и Docker Build Checks не заменяют друг друга. Лучше использовать оба инструмента: hadolint как внешний линтер с большим набором правил, а Build Checks — как встроенную проверку Docker/BuildKit, которая хорошо понимает контекст самой сборки.
Линтинг Dockerfile должен быть обязательным шагом CI.
9.7. Тестируйте Dockerfile и итоговый образ
Dockerfile надо тестировать так же, как код.
Минимальный набор проверок:
docker build -t myapp:test . docker run --rm myapp:test --version docker run --rm myapp:test id docker run --rm -p 8080:8080 myapp:test
Что проверять:
контейнер стартует;
приложение отвечает на health endpoint;
процесс работает не от root;
нужные файлы есть;
лишних файлов и секретов нет;
открыты только нужные порты;
SIGTERM корректно завершает приложение;
образ проходит линтер и сканер.
Для формальных проверок можно использовать container structure tests или аналогичные инструменты.
9.8. Не пушьте один только latest в CI/CD
В CI часто делают так:
docker build -t myapp:latest . docker push myapp:latest
Это плохо: непонятно, какой коммит сейчас соответствует latest, и нельзя нормально откатиться.
Лучше тегировать образ несколькими осмысленными тегами:
docker build \ -t registry.example.com/myapp:1.2.3 \ -t registry.example.com/myapp:git-${GIT_SHA} \ .
Хороший боевой деплой должен ссылаться на immutable-тег или digest, а не на плавающий latest.
9.9. Защищайте Docker socket и Docker TCP API
Это уже не Dockerfile, но это важная практика вокруг контейнеров. /var/run/docker.sock даёт почти root-доступ к хосту. Если контейнеру смонтировать Docker socket, приложение внутри контейнера сможет управлять Docker на хосте.
Опасно:
volumes: - /var/run/docker.sock:/var/run/docker.sock
Практики:
не монтировать Docker socket без крайней необходимости;
если нужен доступ к Docker API, ограничивать его proxy/policy-механизмами;
не открывать Docker TCP API без TLS и аутентификации;
следить за правами на
/var/run/docker.sock;не запускать CI jobs с лишними привилегиями.
10. Финальные примеры Dockerfile
Ниже два примера, где несколько практик соединены в один рабочий Dockerfile: фиксированная версия базового образа, multi-stage, кеш BuildKit, non-root, healthcheck, exec-форма запуска, минимизация лишних файлов и защита кода от записи runtime-пользователем.
10.1. Пример Dockerfile для Python API
# syntax=docker/dockerfile:1.8 FROM python:3.12.13-slim-bookworm AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /build RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt ./ RUN --mount=type=cache,target=/root/.cache/pip \ pip wheel --wheel-dir /wheels -r requirements.txt FROM python:3.12.13-slim-bookworm AS runtime ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app RUN groupadd -r app && useradd -r -g app -d /nonexistent -s /usr/sbin/nologin app COPY --from=builder /wheels /wheels COPY requirements.txt ./ RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \ && rm -rf /wheels COPY . /app RUN chmod -R a-w /app USER app EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)" || exit 1 ENTRYPOINT ["gunicorn", "--worker-tmp-dir", "/dev/shm", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]
Что здесь есть:
фиксированная версия базового образа;
slim, а не фулл ОС;multi-stage;
build-зависимости не попадают в runtime;
pip cache не попадает в слой;
зависимости ставятся до копирования кода;
non-root пользователь;
exec-форма
ENTRYPOINT;healthcheck;
Gunicorn temp directory в
/dev/shm;код не доступен на запись runtime-пользователю.
10.2. Пример Dockerfile для Node.js
# syntax=docker/dockerfile:1.8 FROM node:24.16.0-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm \ npm ci --omit=dev FROM node:24.16.0-slim AS runtime WORKDIR /app ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules COPY package.json ./ COPY src/ ./src/ RUN chmod -R a-w /app USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" CMD ["node", "src/index.js"]
Что здесь есть:
фиксированная версия Node.js;
зависимости ставятся отдельно от исходников;
используется npm cache mount;
нет
latest;нет root;
нет shell-формы CMD;
healthcheck без установки curl;
копируются только нужные директории.
Заключение
Хороший Dockerfile — это не самый короткий Dockerfile. Хороший Dockerfile — это тот, который собирает маленький, понятный, проверяемый и воспроизводимый образ. В нём нет случайных пакетов, плавающих версий, root-процесса, секретов в слоях и магии вроде ADD по URL. Он быстро собирается, корректно завершается, проходит сканеры и одинаково ведёт себя в CI, staging и production.
Комментарии (6)

alexac
31.05.2026 20:49ADD можно оставить только для редкого случая, когда вам действительно нужна автоматическая распаковка локального tar-архива и вы контролируете этот архив.
Ну как так-то… ADD умеет делать это лучше, чем приведенный сниппет.
Сравниваем:
RUN set -eux; \ curl -fsSLo /tmp/tool.tar.gz "https://example.com/tool-${TOOL_VERSION}.tar.gz"; \ echo "${TOOL_SHA256} /tmp/tool.tar.gz" | sha256sum -c -; \ tar -xzf /tmp/tool.tar.gz -C /usr/local/bin; \ rm -f /tmp/tool.tar.gzПротив
ADD --unpack=true \ --checksum sha256:${TOOL_SHA256} \ https://example.com/tool-${TOOL_VERSION}.tar.gz /usr/local/binПри этом ADD лучше работает с кэшами, не требует наличия в образе curl, tar, shasum и даже шелла. И не запускает qemu для не-нативных архитектур.
Двльше, очень нужно рассказать всем и везде, что Dockerfile поддерживает heredoc. И это очень полезно в первую очередь для RUN. Сравниваем на том же самом сниппете:
RUN set -eux; \ curl -fsSLo /tmp/tool.tar.gz "https://example.com/tool-${TOOL_VERSION}.tar.gz"; \ echo "${TOOL_SHA256} /tmp/tool.tar.gz" | sha256sum -c -; \ tar -xzf /tmp/tool.tar.gz -C /usr/local/bin; \ rm -f /tmp/tool.tar.gzПротив
RUN << EOF set -eux curl -fsSLo /tmp/tool.tar.gz "https://example.com/tool-${TOOL_VERSION}.tar.gz" echo "${TOOL_SHA256} /tmp/tool.tar.gz" | sha256sum -c - tar -xzf /tmp/tool.tar.gz -C /usr/local/bin rm -f /tmp/tool.tar.gz EOFЧем сложнее операции по сборке, тем нужнее эта фича. А если уж там 5-10 опций
--mountдля всяких кэшей и прочего добавить, то вообще незаменимая штука.К слову об архитектурах.
Я бы еще добавил в секцию о multi-stage рекомендацию настраивать кросс-компиляцию и исполнять билд-стадию нативно, вот так:
ARG BUILDPLATFORM # Run build stage on host-native architecture FROM --platform=${BUILDPLATFORM} ${BUILD_BASE} AS build ARG TARGETARCH ARG BUILDARCH RUN --mount=... --mount=... --mount=... << EOF if [[ "${BUILDARCH}" != "${TARGETARCH}" ]; then # setup cross-compilation fi # compile make -j`nproc` all # install make -j`nproc` install DESTDIR=/dest EOF # For target architecture just copy files from build stage without executing anything. FROM ${BASE} COPY --from=build /dest /Если этого не делать, то при сборке мультплатформенных образов нативная для хоста архитектура будет собираться нормально, а вот все остальные будут собираться в qemu и это очень медленно.
Ну и на сладкое нужно упомянуть docker buildx bake, он пока довольно сырой, но уже позволяет гораздо более удобным способом управлять более сложными конфигурациями, когда требуется несколько вариантов образа и/или несколько вызовов докера для получения нужного результата.
Буквально пару недель назад переделывал контейнер одного сервиса. Раньше, там был alpine, gcc, и несмотря на настроенный multi-stage, нормально сборку никто не настраивал и не оптимизировал.
Я перевел с gcc на llvm/clang/lld, добавил варианты образов с сантиайзерами, внедрил кросс-компиляцию и 11 стадий сборки, за счет чего полностью избавился от сборки внутри qemu. Разобрался с кэшами и еще кучей всего. В результате, теперь сервис собирается за пять минут вместо полутора часов, при том, что сборка теперь делает в несколько раз больше полезных артефактов.

casssuzy Автор
31.05.2026 20:49После вашего комментария специально копнул глубже, перепроверил актуальные возможности Dockerfile/BuildKit и подкорректировал статью: переписал блок про ADD, добавил пояснение про heredoc и отдельно отметил multi-platform сборки с нативной build-стадией и кросс-компиляцией. Спасибо — это как раз тот случай, когда комментарий не просто уточняет деталь, а реально усиливает материал.
Heggi
Для сборки образа в CI/CD так же можно использовать buildkit rootless, если по каким-то причинам podman/buildah не подходит.
casssuzy Автор
Да, согласен. BuildKit без рут прав - хороший вариант для сборки образов в CI/CD, особенно если хочется уйти от привилегированного Docker-in-Docker, а podman/buildah по каким-то причинам не подходят.
Там есть конечно свои нюансы с раннером, кешем, правами, сторедж и сетью, но как отдельный рабочий вариант безопасной сборки образов без рут доступа, это вполне уместно.
Спасибо за комментарий, он в какой-то мере даже дополнил статью.