6 продвинутых возможностей Docker для продакшена: multi-stage, secrets, healthcheck

Исходная ситуация: 12 сервисов, 15 GB образов

В марте 2026 года к нам обратилась команда SaaS-платформы ТаскТрекер — менеджер задач для распределённых команд. Архитектура: 12 микросервисов (Go, Python, Node.js), все контейнеризованы в Docker, деплой через Docker Compose на 3 сервера.

Проблемы, с которыми пришли:

  • Огромные образы: средний размер — 1.2 GB, суммарно 15 GB. Деплой занимал 20 минут из-за скачивания
  • Утечки секретов: API-ключи передавались через ENV и светились в docker inspect
  • Нет healthcheck: при падении сервиса Docker не перезапускал контейнер
  • OOM kills: Python-сервис аналитики периодически съедал всю RAM и убивал соседние контейнеры
  • Сборка на CI: каждый пуш пересобирал всё с нуля — 12 минут на билд

Мы внедрили 6 продвинутых Docker-фич, которые решили все эти проблемы за два дня работы.

Фича 1: Multi-stage builds — образ в 10 раз меньше

Главная причина гигантских образов — в финальный image попадали компиляторы, исходники, dev-зависимости. Multi-stage build разделяет сборку и запуск:

# До: однофазный Dockerfile для Go-сервиса
# Размер: 1.3 GB
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server ./cmd/server
CMD ["./server"]
# После: multi-stage build
# Размер: 12 MB (в 108 раз меньше!)

# Стадия 1: сборка
FROM golang:1.22-alpine AS builder
WORKDIR /app

# Сначала копируем только go.mod/go.sum — кеширование зависимостей
COPY go.mod go.sum ./
RUN go mod download

# Затем копируем исходники и собираем
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags='-w -s -extldflags "-static"' \
    -o /server ./cmd/server

# Стадия 2: минимальный runtime
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata && \
    adduser -D -u 1000 appuser

COPY --from=builder /server /usr/local/bin/server
USER appuser
EXPOSE 8080
CMD ["/usr/local/bin/server"]
# Для Python-сервиса — аналогичный подход
# До: 1.1 GB, После: 145 MB

# Стадия 1: сборка зависимостей
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Стадия 2: runtime
FROM python:3.12-slim
RUN useradd -m -u 1000 appuser
COPY --from=builder /install /usr/local
COPY --chown=appuser:appuser . /app
WORKDIR /app
USER appuser
CMD ["python", "main.py"]

Ключевой приём: COPY go.mod go.sum перед COPY . .. Если зависимости не изменились, Docker использует кеш для go mod download — сборка занимает секунды вместо минут. Флаги -ldflags='-w -s' убирают отладочную информацию и уменьшают бинарник на 30%.

Фича 2: BuildKit secrets — безопасная передача ключей

Команда ТаскТрекер передавала API-ключи для приватных npm-пакетов и Git-репозиториев через ARG. Это опасно: аргументы сборки сохраняются в истории образа и видны через docker history.

# ПЛОХО — секрет остаётся в слое образа
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && \
    npm install && \
    rm .npmrc
# Даже после rm — секрет есть в предыдущем слое!
# ХОРОШО — BuildKit secret mount
# Секрет доступен только во время выполнения RUN, не сохраняется в образе

# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./

# Секрет монтируется как файл, доступный только в этом RUN
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --production

COPY . .
RUN npm run build

# Runtime
FROM node:20-alpine
RUN adduser -D appuser
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/node_modules /app/node_modules
USER appuser
CMD ["node", "/app/dist/server.js"]
# Команда сборки с секретом
DOCKER_BUILDKIT=1 docker build \
    --secret id=npmrc,src=$HOME/.npmrc \
    -t tasktracker-web:latest .

# Для SSH-ключей (клонирование приватных репозиториев)
DOCKER_BUILDKIT=1 docker build \
    --ssh default=$SSH_AUTH_SOCK \
    -t tasktracker-api:latest .

# В Dockerfile:
RUN --mount=type=ssh git clone git@github.com:tasktracker/private-lib.git

После сборки проверяем, что секрет не попал в образ:

# Проверка: секрет не должен быть виден
docker history tasktracker-web:latest
# Строка с --mount=type=secret показывает <missing> — секрет не сохранён

docker inspect tasktracker-web:latest | grep -i npm_token
# Пусто — чисто

Фича 3: Docker init (tini) и правильный PID 1

Процесс с PID 1 в контейнере имеет особую ответственность: он должен обрабатывать сигналы (SIGTERM, SIGCHLD) и подчищать зомби-процессы. Обычное приложение этого не делает, что приводит к проблемам при docker stop — контейнер ждёт 10 секунд и убивается SIGKILL.

# Проблема: Node.js не обрабатывает SIGTERM как PID 1
# docker stop ждёт 10 секунд, затем SIGKILL — потеря данных

# Решение 1: tini как init-процесс
FROM node:20-alpine
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

# Tini становится PID 1, перенаправляет сигналы дочернему процессу
# и собирает зомби-процессы
# Решение 2: встроенный --init флаг Docker (использует tini автоматически)
docker run --init tasktracker-api

# Или в docker-compose.yml:
services:
  api:
    image: tasktracker-api
    init: true
# Решение 3: обработка сигналов в приложении (Go-пример)
func main() {
    srv := &http.Server{Addr: ":8080"}
    
    // Канал для сигналов ОС
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
    
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()
    
    // Ждём сигнал завершения
    <-quit
    log.Println("Shutting down gracefully...")
    
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatal("Forced shutdown:", err)
    }
    log.Println("Server stopped cleanly")
}

Для Go-сервисов мы реализовали graceful shutdown в коде (Решение 3). Для Node.js и Python использовали tini. Результат: docker stop завершается за 1-2 секунды вместо 10, все текущие запросы корректно дообрабатываются.

Фича 4: HEALTHCHECK — автоматический перезапуск упавших сервисов

Без healthcheck Docker считает контейнер «живым», если PID 1 работает. Но сервис может повиснуть: deadlock, утечка памяти, потеря соединения с БД — процесс жив, но не обслуживает запросы.

# Dockerfile с HEALTHCHECK
FROM golang:1.22-alpine AS builder
# ... build stages ...

FROM alpine:3.19
RUN apk --no-cache add curl ca-certificates
COPY --from=builder /server /usr/local/bin/server

# Health check: каждые 30 секунд, таймаут 5 секунд,
# 3 неудачи подряд = unhealthy
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
    CMD curl -f http://localhost:8080/healthz || exit 1

CMD ["/usr/local/bin/server"]
# Эндпоинт /healthz в Go-сервисе — проверяет реальную работоспособность
func healthzHandler(w http.ResponseWriter, r *http.Request) {
    // Проверяем подключение к БД
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
    defer cancel()
    
    if err := db.PingContext(ctx); err != nil {
        w.WriteHeader(http.StatusServiceUnavailable)
        json.NewEncoder(w).Encode(map[string]string{
            "status": "unhealthy",
            "error":  "database unreachable",
        })
        return
    }
    
    // Проверяем подключение к Redis
    if err := rdb.Ping(ctx).Err(); err != nil {
        w.WriteHeader(http.StatusServiceUnavailable)
        json.NewEncoder(w).Encode(map[string]string{
            "status": "unhealthy",
            "error":  "redis unreachable",
        })
        return
    }
    
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
}
# docker-compose.yml с автоперезапуском
services:
  api:
    image: tasktracker-api
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s
    deploy:
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 5

# Мониторинг состояния
# docker ps показывает статус: (healthy), (unhealthy), (starting)
# docker inspect --format='{{json .State.Health}}' tasktracker-api

start_period — время на запуск приложения, в течение которого неудачные проверки не считаются. Без него сервис, которому нужно 10 секунд на инициализацию, будет перезапущен раньше, чем успеет стартовать.

Фича 5: Resource limits и user namespaces

Python-сервис аналитики ТаскТрекера при обработке больших отчётов съедал всю доступную RAM (32 GB), после чего OOM killer убивал случайные контейнеры на том же хосте. Решение — жёсткие лимиты:

# docker-compose.yml — лимиты ресурсов для каждого сервиса
services:
  analytics:
    image: tasktracker-analytics
    deploy:
      resources:
        limits:
          cpus: '2.0'       # максимум 2 ядра
          memory: 4G        # максимум 4 GB RAM
        reservations:
          cpus: '0.5'       # гарантированные 0.5 ядра
          memory: 1G        # гарантированные 1 GB RAM
    
    # Или через command-line:
    # docker run --memory=4g --memory-swap=4g --cpus=2.0 analytics

  api:
    image: tasktracker-api
    deploy:
      resources:
        limits:
          cpus: '4.0'
          memory: 2G
        reservations:
          cpus: '1.0'
          memory: 512M

  worker:
    image: tasktracker-worker
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 1G
    # pids_limit — защита от fork bomb
    pids_limit: 100
# User namespaces — контейнер работает от root внутри,
# но mapped на непривилегированного пользователя снаружи

# /etc/docker/daemon.json
{
    "userns-remap": "default",
    "log-driver": "json-file",
    "log-opts": {
        "max-size": "10m",
        "max-file": "3"
    },
    "default-ulimits": {
        "nofile": {
            "Name": "nofile",
            "Hard": 65535,
            "Soft": 65535
        }
    }
}

# Проверяем, что userns-remap работает
cat /etc/subuid
# dockremap:100000:65536

docker run --rm alpine id
# uid=0(root) gid=0(root)  ← внутри контейнера root

# На хосте процесс работает от UID 100000
ps aux | grep alpine
# 100000  ... /bin/sh  ← на хосте непривилегированный

После установки лимитов OOM kills прекратились. Сервис аналитики при превышении 4 GB получает свой OOM kill, не затрагивая остальные контейнеры. Флаг --memory-swap=4g (равен --memory) означает запрет swap — предотвращает деградацию производительности.

Фича 6: Compose profiles и оптимизация build cache

12 сервисов в одном docker-compose.yml — неудобно. Разработчик, работающий над API, не хочет запускать аналитику и воркеры. Docker Compose profiles решают эту проблему:

# docker-compose.yml с profiles
services:
  # Core — всегда запускается
  api:
    image: tasktracker-api
    profiles: ["core", "full"]
    # ...
  
  postgres:
    image: postgres:16
    profiles: ["core", "full"]
    # ...
  
  redis:
    image: redis:7-alpine
    profiles: ["core", "full"]
    # ...

  # Workers — только при необходимости
  worker-email:
    image: tasktracker-worker
    profiles: ["workers", "full"]
    # ...
  
  worker-export:
    image: tasktracker-worker
    profiles: ["workers", "full"]
    # ...

  # Analytics — только для data team
  analytics:
    image: tasktracker-analytics
    profiles: ["analytics", "full"]
    # ...

  # Monitoring — только на production
  prometheus:
    image: prom/prometheus
    profiles: ["monitoring", "full"]
    # ...

  grafana:
    image: grafana/grafana
    profiles: ["monitoring", "full"]
    # ...
# Запуск только core-сервисов (для разработчика)
DOCKER_COMPOSE_PROFILES=core docker compose up -d

# Запуск core + workers
DOCKER_COMPOSE_PROFILES=core,workers docker compose up -d

# Полный стек на production
DOCKER_COMPOSE_PROFILES=full docker compose up -d
# Оптимизация build cache — .dockerignore
# Каждый сервис имеет свой .dockerignore

# services/api/Dockerfile.dockerignore
**/.git
**/.env
**/node_modules
**/__pycache__
**/dist
**/coverage
**/*.test.go
**/*.spec.ts
**/docker-compose*.yml
**/README.md
**/docs
# Кеширование зависимостей через BuildKit cache mount
# Для Go:
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /server ./cmd/server

# Для Python:
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

# Для Node.js:
RUN --mount=type=cache,target=/root/.npm \
    npm ci --production

BuildKit cache mount сохраняет скачанные зависимости между сборками. Если go.mod не изменился, go build использует кешированные модули. Время сборки всех 12 сервисов на CI: с 12 минут до 2 минут (при отсутствии изменений в зависимостях — 40 секунд).

Итоги: что изменилось для ТаскТрекер

За два дня работы мы трансформировали Docker-инфраструктуру ТаскТрекера:

ПараметрДоПосле
Средний размер образа1.2 GB50 MB (Go) / 145 MB (Python)
Суммарно все образы15 GB1.1 GB
Время деплоя20 минут3 минуты
Время сборки CI12 минут2 минуты
OOM kills за неделю3-50
Секреты в docker inspectВидныОтсутствуют
docker stop время10 секунд (SIGKILL)1-2 секунды (graceful)

Каждая из этих фич применима к любому Docker-проекту. Специалисты itfresh.ru рекомендуют внедрять их последовательно: начните с multi-stage builds (максимальный визуальный эффект), затем healthcheck (максимальный эффект на стабильность), затем остальное.

Часто задаваемые вопросы

Нет, если правильно настроить порядок слоёв. Копируйте файлы зависимостей (go.mod, package.json, requirements.txt) отдельным слоем перед исходниками. Docker кеширует скачанные зависимости и пересобирает только код. С BuildKit cache mount время может даже уменьшиться.
Да, если ваш Go-бинарник полностью статический (CGO_ENABLED=0). Образ на scratch весит ровно столько, сколько бинарник — обычно 5-15 MB. Но в scratch нет shell, curl, ca-certificates — HEALTHCHECK через curl не сработает. Используйте HTTP-проверку из кода или копируйте ca-certificates из builder-стадии.
Установите --memory и --memory-swap на одинаковое значение: docker run --memory=4g --memory-swap=4g. Если --memory-swap больше --memory, разница используется как swap. Если --memory-swap равен --memory, swap запрещён. Для docker-compose используйте deploy.resources.limits.memory.
Tini решает две проблемы: пересылку сигналов дочернему процессу и подчистку зомби-процессов. Если ваше приложение запускает дочерние процессы (shell-скрипты, workers), без tini зомби будут накапливаться. Для Go/Java, которые не порождают детей, достаточно обработки сигналов в коде.
Сервисы с profiles НЕ запускаются по умолчанию при docker compose up. Нужно явно указать профиль: DOCKER_COMPOSE_PROFILES=core docker compose up или docker compose --profile core up. Сервисы без profiles запускаются всегда — используйте это для базовых зависимостей вроде БД.

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

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

📞 Связаться с нами
#docker multi-stage build#buildkit secrets#docker healthcheck#docker resource limits#docker user namespaces#docker compose profiles#dockerfile оптимизация#docker production
Комментарии 0

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

загрузка...