Один процесс на контейнер: почему не VM
Главное недопонимание новичка - воспринимать контейнер как лёгкую виртуалку, куда надо засунуть nginx, php-fpm, cron и заодно базу. Это первый и самый дорогой антипаттерн. Контейнер - это не машина, это обёртка вокруг ОДНОГО процесса. У него один PID 1, и весь жизненный цикл контейнера завязан на этот процесс: умер PID 1 - умер контейнер.
Почему один процесс? Потому что на этом держится вся модель. Оркестратор (Compose, k8s, Swarm) масштабирует, рестартует и проверяет здоровье на уровне контейнера. Если внутри пять процессов, а упал один из них (не PID 1), Docker этого не увидит - контейнер живой, сервис мёртвый. Логи смешиваются в кашу. Обновить один компонент нельзя, не пересобрав всё. Масштабировать nginx отдельно от php нельзя - они склеены.
Правильно - разнести по контейнерам и связать сетью: nginx, php-fpm, postgres, redis - каждый своим. Это и есть docker правила в чистом виде. Если внутри одного контейнера действительно нужно несколько процессов (редкий случай - например, приложение плюс sidecar-агент в одном образе), бери легковесный init вроде tini или dumb-init, а не полноценный systemd. Кстати, поэтому в exec-форме ENTRYPOINT так важен правильный PID 1: без init зомби-процессы не реапятся, а сигналы (SIGTERM при docker stop) не доходят до приложения, и контейнер умирает по таймауту через SIGKILL.

Иммутабельность и теги: не latest в проде
Контейнер - вещь одноразовая. Правило: не правь работающий контейнер, пересобирай образ. Зашёл через docker exec, поставил пакет, поправил конфиг - поздравляю, ты создал снежинку, которую невозможно воспроизвести. Перезапустишь контейнер - изменения исчезнут (writable-слой стирается). Раскатишь на второй ноде - там этих правок нет. Иммутабельность означает: образ - это артефакт, его собирают раз и катают везде одинаковым. Все изменения - только через Dockerfile и пересборку.
Отсюда вырастает тема тегов. latest в проде - антипаттерн номер один по последствиям. latest - это не "последняя стабильная версия", это просто тег по умолчанию, обычная мутабельная метка. Сегодня nginx:latest указывает на один билд, завтра мейнтейнер перетегировал - и тот же docker pull притащит другой образ. Воспроизводимость сборки умерла, а ты узнаешь об этом в три ночи.
Уровни надёжности по возрастанию:
- latest - мутабельный, для прода запрещён;
- семантический тег вроде 1.27 или 1.27.4 - лучше, но мейнтейнер может перезалить и его;
- pin по digest - единственная по-настоящему иммутабельная ссылка.
Код: Выделить всё
# Мутабельно - так в проде нельзя
FROM node:22-alpine
# Иммутабельно - тег для читаемости плюс digest для гарантии
FROM node:22-alpine@sha256:7d7f3...e1b9
Код: Выделить всё
docker buildx imagetools inspect node:22-alpine
# или у уже подтянутого образа:
docker inspect --format='{{index .RepoDigests 0}}' node:22-alpine
# node@sha256:7d7f3a...e1b9
Образ: минимальный, кэш-дружелюбный, без лишнего
Fat-образ (жирный) - классический антипаттерн. Тащить в прод полный ubuntu с компилятором, заголовками и dev-зависимостями - это медленный pull, лишние гигабайты в реестре и огромная поверхность атаки: каждый пакет - потенциальная CVE. Лекарство - минимальная база и multi-stage build. В стадии сборки у тебя есть всё (компилятор, dev-deps), а в финальный образ копируется только артефакт.
Код: Выделить всё
# Стадия сборки - тут можно всё
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
# Финал - только бинарь, ничего лишнего
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot
ENTRYPOINT ["/server"]
Теперь dockerfile best practices про КЭШ. BuildKit (по умолчанию в современном Docker) кэширует слои, и порядок инструкций определяет, сколько кэша переживёт изменение кода. Правило: от редко меняющегося к часто меняющемуся. Сначала ставим зависимости (меняются редко), потом копируем исходники (меняются каждый коммит).
Код: Выделить всё
# ПЛОХО - любое изменение кода рушит кэш установки зависимостей
COPY . .
RUN npm ci
# ХОРОШО - манифесты отдельно, кэш npm ci переживает правки кода
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Код: Выделить всё
RUN --mount=type=cache,target=/root/.npm npm ci
Код: Выделить всё
.git
node_modules
*.log
.env
.env.*
Dockerfile
Контейнер от root - антипаттерн с прямым риском. По умолчанию процесс в контейнере - root, и хоть namespaces его изолируют, при любой уязвимости рантайма или мисконфиге (а особенно при примонтированном docker.sock или --privileged) это становится root на хосте. Правило: добавь непривилегированного пользователя и переключись на него. Бонус - ты сразу ловишь приложения, которые зачем-то лезут писать в системные каталоги.
Код: Выделить всё
RUN addgroup -S app && adduser -S app -G app
USER app
Секреты в образе или в ENV - грубейшая ошибка. Два частых способа налажать:
- ARG/ENV с паролем - значения ENV видны любому через docker inspect и попадают в дочерние процессы;
- COPY ключа с последующим RM в другом слое - файл остаётся в нижнем слое истории, его достаёт docker history и распаковка образа.
Код: Выделить всё
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.txt .
Healthcheck, сигналы и грабли рантайма
HEALTHCHECK даёт Docker и оркестратору правду о состоянии приложения. Без него "контейнер запущен" означает лишь "PID 1 жив", а не "сервис отвечает". Healthcheck бьёт по реальному эндпоинту.
Код: Выделить всё
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/health || exit 1
Код: Выделить всё
docker ps
# CONTAINER ID IMAGE STATUS PORTS
# a1b2c3d4e5f6 api:1.4 Up 2 minutes (healthy) 0.0.0.0:8080->8080/tcp
Сигналы - частая боль. docker stop шлёт PID 1 SIGTERM и ждёт grace-period (по умолчанию 10 с), потом добивает SIGKILL. Если приложение не PID 1 (запущено через shell-форму CMD, обёрнуто в sh -c), SIGTERM уходит шеллу, а не приложению - graceful shutdown не срабатывает, соединения рвутся, контейнер тупит 10 секунд и умирает жёстко. Поэтому exec-форма обязательна.
Код: Выделить всё
# shell-форма - PID 1 это /bin/sh, сигналы не доходят
CMD npm start
# exec-форма - приложение само PID 1, ловит SIGTERM
CMD ["node", "server.js"]
Три способа выстрелить себе в ногу на запуске.
Монтирование docker.sock в контейнер. /var/run/docker.sock - это фактически root на хосте: контейнер с ним может запускать привилегированные контейнеры, лезть в другие и сбежать на хост. Часто так делают ради "контейнера, который управляет контейнерами" (CI-агенты, watchtower). Если без этого никак - проксируй сокет через ограничивающий прокси, давай read-only и минимум прав, а лучше используй rootless или сокет-активацию.
--privileged без нужды. Флаг выдаёт контейнеру ВСЕ capabilities ядра, доступ к устройствам и снимает защиту. Нужен он реально редко. Если требуется одна-две привилегии - выдавай точечно через --cap-add (например, NET_ADMIN), а остальное лучше сразу срезать.
Код: Выделить всё
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--read-only --security-opt=no-new-privileges myapp:1.4
И верхний слой supply chain: собранный образ надо сканировать и подписывать. Trivy или Docker Scout гоняют по CVE и генерят SBOM (опись содержимого), cosign (Sigstore, keyless через Fulcio/Rekor) подписывает, а политика в кластере не пускает неподписанное или с критическими дырами. SBOM при этом хранят рядом с образом, а не выбрасывают.
Код: Выделить всё
trivy image --severity HIGH,CRITICAL myapp:1.4
cosign sign myregistry.ru/myapp@sha256:7d7f3...e1b9
Мини-лаба: переписать плохой Dockerfile
Возьми заведомо плохой Dockerfile и почини его по чеклисту.
Код: Выделить всё
# BAD
FROM node:latest
COPY . .
RUN npm install
ENV DB_PASSWORD=secret123
CMD npm start
- Замени node:latest на конкретный тег плюс digest (docker buildx imagetools inspect node:22-alpine, подставь хеш).
- Сделай multi-stage: deps-стадия с npm ci, финал на slim/alpine, скопируй только нужное.
- Поправь порядок слоёв: сначала COPY package*.json и npm ci, потом COPY . .
- Добавь .dockerignore с node_modules, .git, .env.
- Убери ENV DB_PASSWORD - пароль пробрось в рантайме через docker run --env-file или секрет.
- Добавь непривилегированного USER, exec-форму CMD ["node","server.js"] и HEALTHCHECK.
- Собери, потом docker history myapp - убедись, что секрета в слоях нет, а docker ps показывает (healthy).
Этот список - то, что должно гореть красным на ревью:
- База пинится по digest, не latest;
- multi-stage, финальный образ минимальный (distroless/alpine/slim/scratch);
- слои упорядочены кэш-дружелюбно (зависимости до кода);
- есть .dockerignore, в контекст не утекает лишнее;
- нет секретов в ARG/ENV/слоях; сборочные секреты через --mount=type=secret;
- процесс не от root, есть USER, по возможности read-only и no-new-privileges;
- CMD/ENTRYPOINT в exec-форме, корректный PID 1 и обработка SIGTERM;
- есть HEALTHCHECK по реальному эндпоинту;
- логи в stdout/stderr, состояние в томах, не в образе;
- образ сканируется (Trivy/Scout), есть SBOM, релизный образ подписан.
Docker лучшие практики - это не про красоту, а про воспроизводимость и безопасность под нагрузкой. Один процесс на контейнер, иммутабельность с пином по digest вместо latest, минимальный multi-stage образ с кэш-дружелюбным порядком слоёв, не от root, секреты снаружи, логи в stdout, healthcheck с корректными сигналами. А антипаттерны - fat-образы, секреты в слоях, данные в образе, docker.sock и --privileged без нужды - убирай на ревью по чеклисту, пока они не убрали тебя в проде.