Предисловие
Статья получилась большой: практик много, и каждая из них важна по-своему. Я собрал её как набор 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.04RUN 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 builderWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 GOOS=linux go build -o /app ./cmd/appFROM gcr.io/distroless/static-debian12COPY --from=builder /app /appUSER nonroot:nonrootENTRYPOINT ["/app"]
scratch
scratch — полностью пустой образ. Он подходит только для статически собранных бинарников, которым не нужны shell, libc, сертификаты, timezone data и прочие системные файлы.
FROM scratchCOPY --from=builder /app /appENTRYPOINT ["/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__/*.pycnode_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 ciCOPY src/ ./src/
Или для Java:
COPY target/app.jar /app/app.jar
Смысл: Dockerfile должен копировать ровно то, что нужно для сборки или запуска.
2.3. Используйте COPY, а не ADD
COPY делает простую и понятную вещь: копирует локальные файлы из build context в образ.
ADD умеет больше:
-
копировать локальные файлы;
-
распаковывать локальные архивы;
-
скачивать файлы по URL.
Именно из-за этой многофункциональности ADD чаще опаснее и менее предсказуем.
Плохо:
ADD https://example.com/app.tar.gz /app
Лучше:
COPY app/ /app/
Если нужно скачать архив, делайте это явно через RUN, с проверкой checksum и удалением временных файлов:
RUN set -eux; \ curl -fsSLo /tmp/app.tar.gz https://example.com/app.tar.gz; \ echo "<sha256> /tmp/app.tar.gz" | sha256sum -c -; \ tar -xzf /tmp/app.tar.gz -C /app; \ rm -f /tmp/app.tar.gz
ADD можно оставить только для редкого случая, когда вам действительно нужна автоматическая распаковка локального tar-архива и вы контролируете этот архив.
2.4. Скачивайте внешние файлы безопасно
Опасный антипаттерн:
RUN curl -fsSL http://example.com/install.sh | sh
Проблемы здесь сразу три:
-
используется небезопасный канал или непроверенный источник;
-
скачанный скрипт сразу выполняется;
-
не проверяется подпись или checksum.
Правильнее:
-
скачивать по HTTPS;
-
проверять SHA256/SHA512;
-
проверять GPG-подпись, если вендор её предоставляет;
-
останавливать сборку при несовпадении checksum;
-
скачивать, распаковывать и удалять архив в одном
RUN.
Пример:
ARG TOOL_VERSION=1.2.3ARG TOOL_SHA256=<expected_sha256>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
Если добавляете сторонний 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 ciCOPY . .
Общий принцип:
-
Сначала базовый образ.
-
Потом системные зависимости.
-
Потом lock-файлы и манифесты зависимостей.
-
Потом установка зависимостей.
-
Потом исходный код.
-
В самом конце — то, что меняется чаще всего.
Для Python:
COPY requirements.txt ./RUN pip install --no-cache-dir -r requirements.txtCOPY . .
Для Go:
COPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN go build -o /app ./cmd/app
Для Node.js:
COPY package*.json ./RUN npm ciCOPY . .
3.2. Объединяйте связанные команды в один RUN
Если вы создадите лишний мусор в одном слое, а удалите его в другом, размер образа всё равно может остаться большим: данные уже попали в нижний слой.
Плохо:
RUN apt-get updateRUN apt-get install -y curlRUN 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/UbuntuRUN apt-get update && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*# AlpineRUN apk add --no-cache curl# Yum/DNFRUN 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:
RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txt
Node.js:
RUN --mount=type=cache,target=/root/.npm \ npm ci
Apt:
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:
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
Это помогает уменьшить количество слоёв и не оставлять лишние файлы в образе.
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 и переиспользовать между одноразовыми раннерами.
4. Multi-stage build и языковые оптимизации
Multi-stage build отделяет среду сборки от среды запуска. В builder-стадии могут быть компиляторы, SDK, dev-зависимости, кеши и исходники. В финальном образе должны остаться только runtime и нужные артефакты.
Эта практика одновременно уменьшает размер образа, снижает поверхность атаки и делает runtime-образ чище.
4.1. Используйте multi-stage build
Плохо:
FROM golang:1.22WORKDIR /appCOPY . .RUN go build -o app ./cmd/appCMD ["./app"]
Так в образе останутся Go toolchain, исходники и лишние файлы.
Лучше:
FROM golang:1.22 AS builderWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 GOOS=linux go build -o /app ./cmd/appFROM gcr.io/distroless/static-debian12COPY --from=builder /app /appUSER nonroot:nonrootENTRYPOINT ["/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 builderWORKDIR /buildRUN 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.txtFROM python:3.12-slimWORKDIR /appCOPY --from=builder /wheels /wheelsCOPY requirements.txt .RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \ && rm -rf /wheelsCOPY . .CMD ["python", "main.py"]
Вариант с virtualenv:
FROM python:3.12-slim AS builderRUN python -m venv /opt/venvENV PATH="/opt/venv/bin:$PATH"COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtFROM python:3.12-slimENV PATH="/opt/venv/bin:$PATH"COPY --from=builder /opt/venv /opt/venvWORKDIR /appCOPY . .CMD ["python", "main.py"]
Virtualenv внутри контейнера обычно не нужен, потому что контейнер уже изолирует окружение. Но в multi-stage он бывает удобен: можно собрать окружение в builder-стадии и перенести его целиком в runtime.
4.3. Ускоряйте Python-сборки современными инструментами, но не ломайте предсказуемость
Для Python можно использовать uv и другие быстрые установщики зависимостей. Это ускоряет сборки, особенно в CI. Но ускорение не должно отменять базовые правила:
-
фиксируйте зависимости lock-файлом;
-
используйте кеш BuildKit;
-
не кладите кеш в финальный образ;
-
отделяйте build-зависимости от runtime-зависимостей;
-
не копируйте секреты и лишние файлы.
Пример идеи:
RUN --mount=type=cache,target=/root/.cache/uv \ uv pip install --system -r requirements.txt
5. Секреты: build-time, runtime и защита от утечек в слоях
Секреты нельзя передавать через ENV, ARG, COPY, RUN echo ... и нельзя оставлять в файлах, которые попадают в контекст сборки.
Плохо:
ARG SSH_PRIVATE_KEYENV API_TOKEN=super-secretCOPY . .
Почему плохо:
-
секрет может попасть в слой;
-
секрет может быть виден через
docker history; -
переменные окружения можно увидеть через inspect/runtime;
-
удаление файла в следующем слое не удаляет его из предыдущего слоя.
5.1. BuildKit secrets для этапа сборки
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 appUSER app
Для Debian/Ubuntu:
RUN groupadd -r app && useradd -r -g app -d /nonexistent -s /usr/sbin/nologin appUSER app
Для Alpine:
RUN addgroup -S app && adduser -S app -G appUSER app
Если базовый образ уже содержит non-root пользователя, используйте его. Например, в Node.js-образах часто есть пользователь node.
6.2. Не привязывайтесь жёстко к одному UID
В некоторых окружениях, например OpenShift, контейнер может запускаться с произвольным UID. Если Dockerfile рассчитан только на конкретного пользователя и конкретный UID, приложение может не иметь доступа к нужным директориям.
Плохо:
RUN mkdir /app-tmp && chown -R app:app /app-tmpUSER appENV TMP_DIR=/app-tmp
Если runtime запустит контейнер другим UID, запись в /app-tmp может сломаться.
Лучше:
ENV TMP_DIR=/tmp
Практики:
-
пишите временные файлы в
/tmp, где это уместно; -
делайте файлы приложения доступными на чтение, если это безопасно;
-
не требуйте владения файлами для выполнения;
-
проверяйте запуск с произвольным UID;
-
не решайте проблемы прав запуском от root.
6.3. Делайте исполняемые файлы неизменяемыми для runtime-пользователя
Не всегда нужно делать пользователя приложения владельцем кода. Часто приложению достаточно прав на чтение и выполнение.
Плохой паттерн:
COPY --chown=app:app . /appUSER app
Если приложение или злоумышленник получит возможность писать в /app, он сможет изменить исполняемые файлы или entrypoint-скрипты.
Лучше:
COPY . /appRUN chmod -R a-w /app && chmod +x /app/entrypoint.shUSER app
Идея: код и исполняемые файлы принадлежат root, runtime-пользователь может их читать/исполнять, но не изменять. Отдельные директории для записи создаются явно: /tmp, /var/cache/myapp, /data и т.д.
6.4. Если на старте нужны root-действия, используйте gosu/su-exec, а не sudo
Иногда entrypoint должен сначала сделать root-действие: например, поправить ownership volume, а потом запустить приложение от обычного пользователя.
В таком случае не стоит запускать само приложение через sudo. Лучше использовать gosu или su-exec, потому что они не создают лишнюю цепочку процессов и помогают сохранить модель «один основной процесс».
Пример:
#!/bin/shset -eif [ "$1" = "myapp" ]; then chown -R app:app /data exec gosu app "$@"fiexec "$@"
Важно: 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/shset -eexec 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 в language dependencies;
-
секреты;
-
неправильные конфигурации;
-
запуск от root;
-
использование
ADD; -
открытые порты;
-
подозрительные файлы;
-
malware, если есть такие требования.
Инструменты: 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 --versiondocker run --rm myapp:test iddocker 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
FROM python:3.12.13-slim-bookworm AS builderENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1WORKDIR /buildRUN 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.txtFROM python:3.12.13-slim-bookworm AS runtimeENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1WORKDIR /appRUN groupadd -r app && useradd -r -g app -d /nonexistent -s /usr/sbin/nologin appCOPY --from=builder /wheels /wheelsCOPY requirements.txt ./RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \ && rm -rf /wheelsCOPY . /appRUN chmod -R a-w /appUSER appEXPOSE 8000HEALTHCHECK --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 1ENTRYPOINT ["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
FROM node:24.16.0-slim AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm \ npm ci --omit=devFROM node:24.16.0-slim AS runtimeWORKDIR /appENV NODE_ENV=productionCOPY --from=deps /app/node_modules ./node_modulesCOPY package.json ./COPY src/ ./src/RUN chmod -R a-w /appUSER nodeEXPOSE 3000HEALTHCHECK --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.
ссылка на оригинал статьи https://habr.com/ru/articles/1041784/