Ты прошел весь курс: образы, слои, сети, тома, BuildKit, безопасность, supply chain. По отдельности все понятно. Но в реальности боль не в одной команде, а в том, что все это надо собрать вместе и чтобы оно не падало в три часа ночи. Поэтому финальный docker проект - не очередной "hello world", а полноценный docker стек уровня прода: API, база, кэш, reverse-proxy и фоновый воркер. Мы соберем его в одном compose.yaml и пройдемся по тем местам, где обычно ломается реальный docker production: тощие multi-stage образы, запуск без root, healthcheck с правильными зависимостями, лимиты ресурсов, секреты снаружи репозитория, ротация логов и базовый мониторинг через cAdvisor. Дальше - сборка в CI со сканом и подписью, пуш в registry, полный чеклист прод-готовности и куда расти после Docker.
Главная мысль урока: production - это не "запустилось на ноуте", а "переживает падение зависимости, рестарт хоста, утечку памяти в одном сервисе и при этом не утаскивает за собой остальные". Compose с правильными настройками держит 99%+ аптайма для одного хоста - этого хватает огромному числу проектов, а Kubernetes нужен далеко не всем и не сразу.

Архитектура стека: пять сервисов и почему именно так
Наш docker compose проект состоит из пяти контейнеров. proxy (nginx или Caddy) - единственная точка входа, терминирует TLS и роутит трафик. api - наше приложение, stateless, его можно масштабировать репликами. worker - тот же образ, что и api, но запускает фоновую обработку очереди, а не HTTP-сервер. db (PostgreSQL) - stateful, данные живут в именованном томе. cache (Redis) - кэш и брокер задач для воркера.
Ключевой принцип: разделяй по ответственности и по жизненному циклу. API и worker - одинаковый код, но разные команды запуска и разные лимиты, потому что у них разный профиль нагрузки. База и кэш - снизу, как фундамент, и стартовать они должны раньше тех, кто к ним лезет. Это и есть граф зависимостей, который мы дальше зашьем в depends_on.
Образ: multi-stage и непривилегированный запуск
Образ для api и worker собираем в две стадии. Первая стадия тянет компилятор/зависимости и собирает артефакт, вторая копирует только готовое в тонкий runtime. Так в финальный образ не попадают ни компилятор, ни кэш пакетного менеджера, ни заголовочные файлы. Размер падает в разы, поверхность атаки - тоже.
Код: Выделить всё
# syntax=docker/dockerfile:1
FROM golang:1.24 AS build
WORKDIR /src
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 \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
Проверь, что внутри реально не root:
Код: Выделить всё
$ docker run --rm myapp:latest id
uid=65532(nonroot) gid=65532(nonroot) groups=65532(nonroot)
Compose-файл: healthcheck, зависимости и лимиты
Теперь собираем docker стек. Compose v2, файл называется compose.yaml, поле version: давно не нужно и игнорируется. Покажу опорные куски, а не весь файл целиком - чтобы видна была механика.
Код: Выделить всё
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
deploy:
resources:
limits: { cpus: "1.5", memory: 512M }
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
api:
image: registry.example.ru/app:${TAG:-latest}
depends_on:
db: { condition: service_healthy }
cache: { condition: service_healthy }
healthcheck:
test: ["CMD", "/app", "healthcheck"]
interval: 10s
timeout: 3s
retries: 3
start_period: 15s
deploy:
resources:
limits: { cpus: "1.0", memory: 256M }
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
Разбор полей healthcheck. interval - как часто пинговать (10s - разумный дефолт). timeout - сколько ждать ответа, после чего попытка считается провальной. retries - сколько провалов подряд переводят контейнер в unhealthy. start_period - окно прогрева: провалы в это время не считаются, чтобы медленный старт базы не уронил весь стек. Считай так: реальное время до пометки unhealthy это примерно start_period плюс interval умножить на retries.
Про лимиты. Без limits один сервис с утечкой памяти сожрет всю RAM хоста и OOM-killer прибьет случайные контейнеры, возможно базу. С memory: 256M ядро прибьет именно зарвавшийся контейнер (код выхода 137), restart: unless-stopped его поднимет, остальные живы. Важная тонкость: deploy.resources.limits в standalone Compose v2 работает и без Swarm - это давняя путаница из доков по Swarm. cpus: "1.0" это не одно ядро жестко, а квота в одно ядро-эквивалент через cgroups (cpu.max в cgroup v2 под /sys/fs/cgroup).
Про ротацию логов. Драйвер json-file по умолчанию пишет в /var/lib/docker/containers/.../*-json.log без ограничения. Болтливый сервис за неделю забивает диск под ноль, и хост встает. max-size и max-file включают ротацию: три файла по 10 МБ, итого максимум 30 МБ на контейнер. Это можно задать и глобально в /etc/docker/daemon.json, чтобы не дублировать в каждом сервисе.
Секреты снаружи: ничего чувствительного в образе и в git
Пароль базы в нашем примере приходит не из environment, а из секрета. Compose монтирует файл в /run/secrets/db_password (это tmpfs, в памяти, не на диске), а Postgres читает его через POSTGRES_PASSWORD_FILE. Почему не просто environment? Переменные окружения видны в docker inspect, в /proc/PID/environ, текут в логи и в дочерние процессы. Файл-секрет этих утечек лишен.
Код: Выделить всё
secrets:
db_password:
file: ./secrets/db_password.txt # для одного хоста
# либо external: true и подача через свой секрет-провайдер
Мониторинг: cAdvisor и почему он нужен
Лимиты ты поставил, но как понять, упирается ли сервис в потолок? Добавляем в стек cAdvisor от Google - он читает cgroups и отдает по-контейнерные метрики CPU, памяти, сети, диска.
Код: Выделить всё
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
ports: ["8080:8080"]
restart: unless-stopped
Сборка в CI: скан, подпись, пуш в registry
Локально собрать образ умеет каждый. Production-конвейер другой: сборка воспроизводимая, образ просканирован, подписан и снабжен SBOM. Современный пайплайн идет по стадиям Build -> Scan -> Sign -> Attest -> Push.
Сборка кросс-платформенного образа через buildx с генерацией provenance и SBOM прямо в один проход:
Код: Выделить всё
$ docker buildx build \
--platform linux/amd64,linux/arm64 \
--provenance=true --sbom=true \
-t registry.example.ru/app:$TAG \
--push .
Код: Выделить всё
$ trivy image --exit-code 1 --severity HIGH,CRITICAL registry.example.ru/app:$TAG
$ cosign sign --yes registry.example.ru/app:$TAG
RU-реалии: Docker Hub из России отдает rate limit и иногда недоступен без зеркала, поэтому базовые образы тяни через зеркало или зеркаль в свой Yandex Container Registry / VK Cloud Container Registry, а свои образы пушь туда же. В CI это просто другой адрес в -t и docker login на нужный реестр.
Чеклист прод-готовности целиком
- Образ: multi-stage, тонкая база (distroless/slim/alpine), .dockerignore настроен, тег зафиксирован по дайджесту, не root внутри (проверено через id).
- Безопасность: USER не root, read_only: true где можно, cap_drop: [ALL] и точечный cap_add, security_opt: no-new-privileges, секреты только файлами/из менеджера.
- Надежность: healthcheck у каждого долгоживущего сервиса, depends_on с condition: service_healthy, restart: unless-stopped, корректная обработка SIGTERM (graceful shutdown).
- Ресурсы: limits по cpus и memory у всех, ротация логов (max-size/max-file), тома для stateful-данных, бэкап тома базы.
- Поставка: сборка в CI, скан Trivy/Scout с порогом по severity, подпись cosign, SBOM и provenance, пуш в приватный/зеркальный registry.
- Наблюдаемость: cAdvisor (или экспортер) + Prometheus + Grafana, централизованные логи, алерты на unhealthy и на упор в лимит памяти.
Типичные грабли и антипаттерны
- depends_on без healthcheck у зависимости - condition: service_healthy молча деградирует, гонка возвращается.
- latest в проде - неповторяемый деплой, "у меня вчера работало". Фиксируй тег или дайджест sha256.
- Секрет в ARG/ENV или в COPY - он навсегда в слое истории, docker history его покажет даже после удаления.
- Нет лимитов - один утекший сервис кладет всю машину через OOM-killer.
- Логи без ротации - диск под ноль за неделю, классика падения хоста.
- Healthcheck, который всегда healthy (curl на /, который отвечает даже у мертвого приложения) - оркестратор думает, что все живо, а оно нет. Проверяй реальную готовность: коннект к базе, а не просто живой порт.
Один хост и Compose тебя проведут далеко, но рано или поздно упрешься в потолок. Kubernetes - когда нужны несколько узлов, автоскейл, self-healing на уровне кластера и декларативные раскатки. Кстати, k8s давно выпилил dockershim и работает с containerd напрямую через CRI - Docker как демон ему не нужен, но образы остались OCI-совместимыми, так что твои сборки едут в k8s без изменений. Podman + Quadlet - демонless и rootless по дизайну, контейнеры описываются как systemd-юниты, отличный путь для одиночного хоста под управлением systemd. eBPF-наблюдаемость (Cilium/Hubble, Falco на eBPF, Pixie) - видеть и фильтровать сетевой трафик и системные вызовы контейнеров на уровне ядра без сайдкаров. Платформенная инженерия - когда сервисов и команд много, поверх всего этого строят internal developer platform, чтобы разработчик катил сервис по golden path, не зная деталей.
Мини-лаба: собери стек руками
- Напиши multi-stage Dockerfile для своего api, добавь USER non-root, проверь docker run --rm img id - убедись, что не uid 0.
- Собери compose.yaml из db (postgres), cache (redis), api и worker. Базе и кэшу пропиши healthcheck, у api поставь depends_on с condition: service_healthy.
- Останови специально db (docker compose stop db) и подними стек заново - убедись, что api не стартует, пока база unhealthy.
- Добавь deploy.resources.limits и logging с max-size/max-file всем сервисам. Урежь memory api до 32M, запусти нагрузку, поймай рестарт с кодом 137.
- Подними cAdvisor, открой :8080 и найди по-контейнерное потребление памяти.
- Вынеси пароль базы в secrets через файл, убери его из environment, проверь docker compose config - пароля не должно быть в выводе.
- Чем depends_on с condition: service_healthy отличается от обычного depends_on и почему без healthcheck он бесполезен?
- Почему секрет, переданный через ENV или ARG, опаснее секрета из /run/secrets, и где он может утечь?
- Что произойдет с контейнером без memory limit при утечке памяти и кто и почему пострадает на хосте?
- Какие шаги проходит образ в production-пайплайне от сборки до пуша и за что отвечают Trivy и cosign?
Ты собрал не демку, а docker production стек: тонкие multi-stage образы без root, healthcheck с настоящими зависимостями, лимиты против OOM, секреты снаружи, ротацию логов, мониторинг через cAdvisor и поставку через CI со сканом и подписью. Это и есть production-ready - система, которая переживает падение зависимости и рестарт без твоего участия. Контейнеры - не магия, а аккуратная сумма cgroups, namespaces, слоев и OCI-стандарта; ты теперь видишь, что под капотом, и где обычно ломается. Дальше дорога одна - выбирай по масштабу: остаешься на Compose, уходишь в Kubernetes или Podman, добавляешь eBPF-наблюдаемость и строишь платформу. Базу ты заложил крепкую. Удачи в проде.