До этого урока мы разбирали Docker по кускам - слои, кэш, сети, тома. Теперь собираем все вместе на реальной задаче: упаковать в контейнеры живое приложение. Не abstract hello-world, а то, что встречается в каждом втором проекте - бэкенд на Python, Go, Node или Java, SPA-фронтенд и база данных. У каждого из трех своя физика. Бэкенд должен правильно ловить сигналы и не запускаться из-под root. Фронт это статика, которую глупо отдавать тяжелым node-образом. БД хранит состояние, и тут одна ошибка с томом стоит тебе всех данных.
Боль, которую решаем, простая. Новички пихают в один образ исходники, компилятор, тесты, dev-зависимости и запускают процесс от рута - получается жирный дырявый образ на гигабайт, который не умеет грейсфул-шатдаун и теряет данные при пересоздании. Контейнеризация приложения - это не "обернул в Dockerfile и забыл", а набор инженерных решений под каждый тип нагрузки. Разберем их по слоям, с механикой под капотом и граблями из боевой эксплуатации.

Бэкенд: multi-stage, непривилегированный, сигналы
Начнем с типового бэкенда. Главная идея для компилируемых и сборочных языков - multi-stage build. Ты делишь Dockerfile на стадии: в первой собираешь артефакт со всем тяжелым инструментарием, во второй копируешь только готовый бинарь или зависимости в чистый минимальный образ. В финальный образ не попадает ни компилятор, ни кэш пакетов, ни исходники.
Покажу на Go - язык дает статический бинарь, поэтому финальная стадия может быть вообще пустой.
Код: Выделить всё
# build stage
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
# runtime stage
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app/server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Для docker python и docker node multi-stage тоже работает, но смысл смещается. Интерпретируемым языкам компилятор не нужен в рантайме, зато нужны зависимости. В питоне трюк - ставить пакеты в отдельный virtualenv на build-стадии и копировать его готовым.
Код: Выделить всё
FROM python:3.13-slim AS build
WORKDIR /app
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.13-slim
RUN useradd -r -u 10001 appuser
COPY --from=build /opt/venv /opt/venv
COPY --chown=appuser . /app
WORKDIR /app
ENV PATH="/opt/venv/bin:$PATH"
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health').status==200 else 1)"
ENTRYPOINT ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]
Сигналы. Когда оркестратор останавливает контейнер, он шлет PID 1 сигнал SIGTERM, ждет grace period (по умолчанию 10 секунд) и добивает SIGKILL. Если твой процесс не PID 1 или не умеет ловить SIGTERM, ты получаешь жесткое убийство: оборванные запросы, незакрытые транзакции, поврежденные файлы. Есть две ловушки. Первая - shell-форма ENTRYPOINT. Если написать ENTRYPOINT node server.js (без скобок), Docker запустит это как /bin/sh -c "node server.js", и PID 1 станет shell, который сигналы наследникам не пробрасывает. Всегда используй exec-форму с массивом: ENTRYPOINT ["node", "server.js"]. Вторая ловушка - зомби-процессы: если приложение само порождает дочерние процессы, ему нужен init, который их пожинает. Для этого есть встроенный флаг docker run --init (он подкладывает tini в PID 1) или ENTRYPOINT через tini вручную. В коде же надо явно повесить хендлер на SIGTERM и сделать graceful shutdown - дать серверу дослать ответы и закрыть пул соединений.
Healthcheck. Инструкция HEALTHCHECK периодически дергает команду внутри контейнера. Поля разбираются так: interval - как часто проверять, timeout - сколько ждать ответа, start-period - окно прогрева, в течение которого падения не считаются (важно для Java с долгим стартом JVM), retries - сколько подряд провалов до статуса unhealthy. Посмотреть статус:
Код: Выделить всё
$ docker ps
CONTAINER ID IMAGE STATUS PORTS
a1b2c3d4e5f6 api:1.0 Up 2 minutes (healthy) 0.0.0.0:8000->8000/tcp
Про docker java отдельная заметка: тащи в рантайм JRE, а не полный JDK, бери eclipse-temurin:21-jre вместо jdk, и для свежих JVM ставь флаги памяти осознанно - современная JVM уважает cgroup-лимиты контейнера, но проверь это под своей версией, чтобы heap не вылез за память пода и не словить OOMKill.
Фронтенд SPA: собрать в node, отдать через docker nginx
Со SPA (React, Vue, Angular) частая ошибка - оставить в продакшене dev-сервер вроде vite или webpack-dev-server. Это медленно, небезопасно и жрет память. На самом деле собранный фронт - это просто папка статики: html, js, css. Для статики не нужен Node вообще, ее идеально отдает легкий веб-сервер. Поэтому каноничный паттерн - multi-stage, где docker node собирает бандл, а docker nginx его раздает.
Код: Выделить всё
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
Конфиг nginx для SPA обязан уметь одну вещь - отдавать index.html на любой неизвестный путь, иначе клиентский роутинг сломается при перезагрузке страницы на /profile или /orders/42.
Код: Выделить всё
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Docker база данных: можно в dev, осторожно в проде
Самая нагруженная тема. БД в контейнере - это абсолютно нормально для разработки и staging: одна команда поднимает чистый postgres нужной версии, и у всей команды одинаковое окружение. А вот в продакшене docker база данных требует трезвой головы.
Ключевая разница между БД и stateless-бэкендом - состояние. Контейнер по природе эфемерен: пересоздал - все, что было внутри, исчезло. Для базы это смертельно. Поэтому железное правило: данные БД живут в томе, не в слое образа. Том - это директория на хосте (или managed volume), которую Docker монтирует внутрь контейнера; она переживает пересоздание и обновление контейнера.
Код: Выделить всё
$ docker volume create pgdata
$ docker run -d --name pg \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:17
Код: Выделить всё
$ docker volume inspect pgdata
[
{
"Mountpoint": "/var/lib/docker/volumes/pgdata/_data",
"Name": "pgdata",
"Driver": "local"
}
]
Почему в проде осторожно. Во-первых, IO и производительность: слой overlay2 и неправильно выбранный драйвер тома добавляют накладные расходы, а БД чувствительна к латентности диска - под серьезной нагрузкой нужен том на быстром локальном NVMe, а не на сетевом хранилище с непредсказуемой задержкой. Во-вторых, бэкапы и восстановление: контейнер не отменяет необходимость pg_dump/pg_basebackup, репликации и проверенного плана восстановления - оркестратор может пересоздать под на другом узле, и если том не следует за ним, ты потерял базу. В-третьих, апгрейды мажорных версий postgres требуют pg_upgrade, а не просто смены тега образа - новый бинарь не прочитает старый формат каталога. Поэтому многие команды осознанно держат прод-БД как managed-сервис (RDS, Yandex Managed PostgreSQL, облако VK) или на bare-metal, а в контейнерах гоняют только dev и staging. Это не догма, БД в Kubernetes через зрелые операторы (CloudNativePG, Zalando) работает в проде - но это отдельная инженерная дисциплина с резервированием и failover, а не "просто docker run postgres".
Конфиг через env и сборка стека в Compose
Контейнер должен быть неизменяемым артефактом, а отличия между dev, staging и prod - снаружи, через переменные окружения. Это принцип config из методологии twelve-factor. Один и тот же образ бэкенда читает DATABASE_URL и LOG_LEVEL из env и ведет себя по-разному в разных средах, не пересобираясь.
Собираем все три компонента в стек. Compose v2 (команда docker compose, файл compose.yaml без устаревшего ключа version) описывает сервисы, сети и тома декларативно.
Код: Выделить всё
services:
db:
image: postgres:17
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes:
- pgdata:/var/lib/postgresql/data
secrets:
- db_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
api:
build: ./backend
environment:
DATABASE_URL: postgres://app@db:5432/app
LOG_LEVEL: info
depends_on:
db:
condition: service_healthy
ports:
- "8000:8000"
web:
build: ./frontend
depends_on:
- api
ports:
- "8080:80"
volumes:
pgdata:
secrets:
db_password:
file: ./secrets/db_password.txt
Теперь самый частый антипаттерн - секреты. В примере пароль идет не как POSTGRES_PASSWORD в открытом env, а через POSTGRES_PASSWORD_FILE и механизм secrets, который монтирует файл в /run/secrets/db_password (это tmpfs в памяти, не на диске и не в слое образа). Почему это важно: переменные окружения видны всем процессам контейнера, утекают в docker inspect, в логи, в дочерние процессы и в crash-репорты. Пароль в env - это пароль на виду. Файловые секреты живут в памяти и не светятся в инспекте.
Антипаттерны и грабли из практики
- Данные БД внутри образа. Никогда не клади дамп или каталог данных в слой образа через COPY. Образ неизменяем и пересобирается, том - нет. Данные в образе = данные, которые ты гарантированно потеряешь и которые раздуют образ до неприличия.
- Секреты в ENV и в Dockerfile. ENV DB_PASSWORD=... в Dockerfile вмораживает секрет в слой навсегда - его видно в docker history любому, у кого есть образ. Для build-time секретов (токен приватного репозитория) используй RUN --mount=type=secret BuildKit, для рантайма - docker secrets или внешний vault.
- root по умолчанию. Без инструкции USER процесс бежит от рута. Контейнерный root - это не полная изоляция от хостового, и при цепочке уязвимостей это лишний шаг к побегу. Всегда явный непривилегированный USER.
- latest как тег. FROM postgres:latest делает сборку невоспроизводимой - сегодня и завтра это разные образы. Пинуй мажорную версию, а в идеале digest по sha256.
- Тяжелый dev-образ в проде. node-dev-сервер для SPA, JDK вместо JRE, отсутствие multi-stage - все это лишние сотни мегабайт и десятки CVE в финальном образе.
- Игнор .dockerignore. Без него в контекст сборки и в COPY . попадают .git, node_modules, локальные .env с секретами. Это и медленно, и опасно.
Мини-лаба: подними стек руками
- Создай папки backend и frontend. В backend положи простейший HTTP-сервер на питоне с эндпоинтами / и /health, в frontend - любой собранный SPA (хоть статичный index.html в dist).
- Напиши для бэкенда multi-stage Dockerfile с непривилегированным USER, exec-формой ENTRYPOINT и HEALTHCHECK на /health.
- Для фронта сделай Dockerfile node-build -> nginx с try_files на index.html.
- Создай файл secrets/db_password.txt и собери compose.yaml из трех сервисов с томом pgdata и depends_on по service_healthy.
- Запусти docker compose up --build, дождись (healthy) у db в docker compose ps, открой фронт на 8080 и проверь, что api ходит в базу.
- Сделай docker compose down (без -v!), снова up - убедись, что данные в pgdata выжили. Потом docker compose down -v и увидь, что том и данные исчезли.
- Почему shell-форма ENTRYPOINT ломает грейсфул-шатдаун и как это связано с PID 1 и SIGTERM?
- Зачем для SPA нужен docker nginx с try_files, и что произойдет без этой директивы при перезагрузке страницы на вложенном маршруте?
- Чем именованный том отличается от слоя образа для docker база данных, и почему init-скрипты иногда молча не выполняются?
- Почему секрет в переменной окружения - плохо, и какие два механизма (build-time и runtime) дают безопасную альтернативу?
Контейнеризация приложения - это три разных инженерных задачи под одной крышей. Бэкенд: multi-stage, непривилегированный юзер, exec-ENTRYPOINT с обработкой SIGTERM и осмысленный healthcheck. Фронтенд: собрать в docker node, отдать легким docker nginx с try_files. БД: данные строго в томе, init только на пустом каталоге, в проде - трезвая оценка IO, бэкапов и апгрейдов. Конфиг наружу через env, секреты - через файлы и docker secrets, а не открытым текстом. Связываем все в Compose v2 с depends_on по healthcheck. Сделаешь так - получишь стек, который не разваливается при первом же рестарте и не отдает пароль каждому, кто запустил docker inspect.