«КлаудСервис» разрабатывает платформу управления облачными ресурсами. На момент обращения к нам платформа состояла из 23 микросервисов, каждый из которых имел свой Dockerfile. Проблемы были типичные: раздутые образы, медленные билды, отсутствие сканирования уязвимостей и запуск всего от root. Мы провели аудит и за полгода привели инфраструктуру в порядок. Вот 15 практик, которые мы внедрили.
Docker в продакшене: 15 практик, к которым мы пришли после 3 лет эксплуатации
Предыстория
1. Multi-stage builds для минимальных образов
Первое, за что мы взялись, — размер образов. Типичный Dockerfile выглядел так: один жирный образ с компилятором, dev-зависимостями и самим приложением. После перехода на multi-stage builds средний размер образа упал с 1.8 ГБ до 120 МБ.
# Этап сборки
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
# Финальный образ
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /app/server /usr/local/bin/server
EXPOSE 8080
ENTRYPOINT ["server"]
Для Node.js-сервисов мы использовали аналогичный подход: сборка в полном node-образе, а в финальный копируем только node_modules (production) и собранный код.
2. .dockerignore — не складывайте мусор в контекст
Без .dockerignore Docker-контекст включал node_modules, .git, логи и тестовые данные. Контекст одного сервиса весил 800 МБ. После настройки — 12 МБ.
# .dockerignore
.git
.gitignore
node_modules
npm-debug.log
Dockerfile*
docker-compose*
.env*
*.md
tests/
coverage/
.vscode/
.idea/
Правило простое: если файл не нужен внутри контейнера, он должен быть в .dockerignore.
3. Запуск от непривилегированного пользователя
Все 23 сервиса работали от root. Это значит, что при эксплуатации уязвимости в приложении атакующий получает root внутри контейнера, что при некоторых конфигурациях (привилегированный режим, пробросы) может привести к побегу на хост.
FROM alpine:3.19
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder --chown=appuser:appgroup /app/server /usr/local/bin/server
USER appuser
EXPOSE 8080
ENTRYPOINT ["server"]
Важно: убедитесь, что приложение может работать без root. Порты ниже 1024 требуют привилегий — используйте порты выше 1024 и пробрасывайте через -p 80:8080.
4. Health checks — обязательно
Docker без HEALTHCHECK не знает, живо ли ваше приложение. Контейнер может висеть с OOM или deadlock, а Docker будет показывать Up 3 days.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
Мы стандартизировали эндпоинт /health для всех сервисов. Он проверяет подключение к БД, Redis и другим критичным зависимостям. Для оркестратора (Kubernetes или Docker Swarm) это основа для автоматического перезапуска.
5. Проблема PID 1 и tini
Это одна из самых коварных проблем. Когда ваш процесс запускается как PID 1 в контейнере, он не получает стандартное поведение обработки сигналов. SIGTERM от docker stop может быть проигнорирован, и Docker убьёт процесс через SIGKILL после таймаута.
FROM alpine:3.19
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["server"]
Альтернатива — использовать форму exec в shell-скриптах, если у вас entrypoint-скрипт:
#!/bin/sh
# entrypoint.sh
echo "Running migrations..."
/app/migrate up
echo "Starting server..."
exec /app/server "$@" # exec заменяет shell-процесс, server становится PID 16. Оптимизация кеширования слоёв
Порядок инструкций в Dockerfile критичен. Каждая инструкция — слой, и при изменении одного слоя все последующие пересобираются. Зависимости меняются реже, чем код — копируйте их первыми.
# Правильно: зависимости копируются отдельно
COPY go.mod go.sum ./
RUN go mod download # Кешируется, пока go.mod не изменился
COPY . . # Код меняется часто, но слой выше остаётся в кеше
RUN go build -o /app/server
Для Node.js — аналогично: сначала package.json и package-lock.json, потом npm ci, потом остальной код. Время билда упало с 12 минут до 45 секунд при изменении только кода.
7. Секреты в билдах через BuildKit
Мы нашли приватные SSH-ключи и NPM-токены, захардкоженные через COPY и ARG. Они остаются в слоях образа и доступны через docker history.
# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm ci --registry=https://npm.cloudservice.internal
COPY . .
RUN npm run build
Сборка запускается так:
DOCKER_BUILDKIT=1 docker build \
--secret id=npm_token,src=$HOME/.npm_token \
-t myapp:latest .
Секрет монтируется только на время выполнения RUN и не сохраняется в слое.
8. Логирование в stdout/stderr
Несколько сервисов писали логи в файлы внутри контейнера. Это означает, что docker logs пустой, а для просмотра логов нужно заходить в контейнер. Мы перевели все сервисы на запись в stdout/stderr.
# Go: логирование через zerolog в stdout
logger := zerolog.New(os.Stdout).With().
Timestamp().
Str("service", "auth-api").
Logger()
# Python: настройка в Django
LOGGING = {
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'stream': 'ext://sys.stdout',
'formatter': 'json',
},
},
'root': {
'handlers': ['console'],
'level': 'INFO',
},
}
Docker-драйвер логирования (json-file, fluentd, gelf) подхватывает stdout/stderr автоматически. Centralized logging через Fluentd или Vector настраивается один раз на уровне демона.
9. Ограничение ресурсов
Без лимитов один утёкший сервис может съесть всю память хоста и положить остальные контейнеры. Мы установили лимиты для каждого сервиса:
# docker-compose.yml
services:
auth-api:
image: cloudservice/auth-api:1.5.2
deploy:
resources:
limits:
cpus: '2.0'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
Для определения правильных лимитов мы неделю собирали метрики через cAdvisor + Prometheus. Лимит памяти ставится с запасом 20-30% от пикового потребления.
10. Сканирование образов с Trivy
При первом сканировании Trivy нашёл 847 уязвимостей в образах, из них 23 критические. Мы интегрировали сканирование в CI:
# .gitlab-ci.yml
scan:
stage: security
image: aquasec/trivy:latest
script:
- trivy image --severity HIGH,CRITICAL --exit-code 1
--ignore-unfixed ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}
allow_failure: false
Политика: билд падает при наличии HIGH/CRITICAL уязвимостей. Базовые образы обновляются еженедельно автоматическим пайплайном.
11. Docker Compose для локальной разработки
Раньше у разработчиков были инструкции на 3 страницы по настройке локального окружения. Мы создали docker-compose.dev.yml, который поднимает всю инфраструктуру одной командой:
# docker-compose.dev.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: cloudservice
POSTGRES_PASSWORD: devpass
ports: ["5432:5432"]
volumes:
- pgdata:/var/lib/postgresql/data
- ./migrations/init.sql:/docker-entrypoint-initdb.d/init.sql
redis:
image: redis:7-alpine
ports: ["6379:6379"]
app:
build:
context: .
target: development # multi-stage: отдельный этап для dev с hot-reload
volumes:
- .:/app # live-reload кода
- /app/node_modules # node_modules из контейнера, не с хоста
ports: ["3000:3000"]
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
volumes:
pgdata:12. Стратегия тегирования: забудьте про :latest
Тег :latest — это не версия, это указатель, который может указывать на что угодно. Мы внедрили строгую стратегию:
# CI/CD pipeline
IMAGE_TAG="${CI_COMMIT_SHA:0:8}"
SEMVER_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
docker build \
-t ${REGISTRY}/auth-api:${IMAGE_TAG} \
-t ${REGISTRY}/auth-api:${SEMVER_TAG} \
-t ${REGISTRY}/auth-api:latest \
.
# В продакшен деплоится конкретный SHA-тег
# docker-compose.prod.yml ссылается на конкретный тег:
# image: registry.cloudservice.internal/auth-api:a1b2c3d4
Правила: в production используется только SHA или semver-тег. :latest собирается для удобства, но никогда не деплоится. Каждый деплой ссылается на неизменяемый образ.
13. Выбор оркестратора для продакшена
На начальном этапе «КлаудСервис» использовал чистый Docker Compose в продакшене. Для 23 сервисов этого было недостаточно: нет автоматического перезапуска при падении ноды, нет rolling updates, нет service discovery.
Мы оценили три варианта:
- Docker Swarm — прост в настройке, но ограниченная экосистема и замедленное развитие.
- Kubernetes — стандарт индустрии, но сложность управления кластером высока.
- Managed Kubernetes (Yandex Cloud) — кластер управляется провайдером, команда фокусируется на приложениях.
Выбрали managed Kubernetes. Миграция заняла 2 месяца, но дала автоскейлинг, rolling updates и нормальный service discovery из коробки.
14. Отладка запущенных контейнеров
Минимальные образы — это хорошо, но при дебаге внутри контейнера нет ни curl, ни strace, ни netstat. Решение — debug-контейнер:
# Подключение debug-контейнера к сети и PID-пространству целевого
docker run -it --rm \
--network container:auth-api-prod \
--pid container:auth-api-prod \
nicolaka/netshoot \
bash
# Внутри debug-контейнера:
curl localhost:8080/health
ss -tlnp
tcpdump -i eth0 port 5432 -w /tmp/pg.pcap
strace -p 1 -f -e trace=network
В Kubernetes аналог — kubectl debug с ephemeral-контейнером. Главное — никогда не ставить debug-инструменты в продакшен-образ.
15. Автоматическая очистка
За полгода билд-сервер накопил 180 ГБ неиспользуемых образов и слоёв. Мы настроили автоматическую очистку:
# Cron-задача на билд-серверах (каждую ночь)
0 3 * * * docker system prune -af --filter "until=168h" >> /var/log/docker-cleanup.log 2>&1
# Для registry — garbage collection
docker exec registry bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged
Для Container Registry в облаке настроили lifecycle policies: образы старше 30 дней без тегов удаляются автоматически. Исключение — образы с semver-тегами, они хранятся 90 дней.
Итоги
После внедрения всех 15 практик мы получили следующие результаты:
| Метрика | До | После |
|---|---|---|
| Средний размер образа | 1.8 ГБ | 120 МБ |
| Время сборки (без кеша) | 18 мин | 3 мин |
| Время сборки (с кешем) | 12 мин | 45 сек |
| Критические CVE | 23 | 0 |
| Контейнеры от root | 100% | 0% |
| Среднее время деплоя | 25 мин | 4 мин |
Docker — мощный инструмент, но без правильных практик он становится источником боли. Начните с самого критичного: уберите root, добавьте health checks, настройте сканирование. Остальное можно внедрять итеративно. Если вам нужна помощь с контейнеризацией или миграцией на Kubernetes — напишите нам.
Оставить комментарий