Docker в продакшене: 15 практик, к которым мы пришли после 3 лет эксплуатации

Предыстория

«КлаудСервис» разрабатывает платформу управления облачными ресурсами. На момент обращения к нам платформа состояла из 23 микросервисов, каждый из которых имел свой Dockerfile. Проблемы были типичные: раздутые образы, медленные билды, отсутствие сканирования уязвимостей и запуск всего от root. Мы провели аудит и за полгода привели инфраструктуру в порядок. Вот 15 практик, которые мы внедрили.

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 1

6. Оптимизация кеширования слоёв

Порядок инструкций в 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 сек
Критические CVE230
Контейнеры от root100%0%
Среднее время деплоя25 мин4 мин

Docker — мощный инструмент, но без правильных практик он становится источником боли. Начните с самого критичного: уберите root, добавьте health checks, настройте сканирование. Остальное можно внедрять итеративно. Если вам нужна помощь с контейнеризацией или миграцией на Kubernetes — напишите нам.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#buildkit#builds#checks#compose#devops#docker#docker swarm#dockerignore
Комментарии 0

Оставить комментарий

загрузка...