Сборка Docker-образов для прода: кейс автодилера на 12 сервисов

Клиент и задача

Компания «АвтоДилер» — федеральная сеть автосалонов с 28 точками продаж в 14 городах России. В начале 2026 года клиент обратился к нам с проблемой: их внутренняя IT-инфраструктура из 12 микросервисов (CRM, складской учёт, бухгалтерия, интеграция с ГИБДД, калькулятор кредитов и другие) работала на «сырых» Docker-образах, собранных разработчиками без единого стандарта.

Основные болевые точки:

  • Образы весили от 1.2 до 2.8 ГБ каждый — деплой на 28 площадок занимал до 40 минут
  • Отсутствовали healthcheck-и — Kubernetes не мог корректно перезапускать упавшие контейнеры
  • Контейнеры работали от root — критическая уязвимость безопасности
  • Не было .dockerignore — в образы попадали .env файлы с паролями от баз данных
  • Сборка одного образа занимала 12-15 минут из-за неправильного порядка слоёв

Наша команда из двух DevOps-инженеров взялась за стандартизацию всех 12 сервисов за 3 недели.

Аудит существующих Dockerfile

Мы провели полный аудит Dockerfile для каждого из 12 сервисов. Типичный Dockerfile выглядел так:

FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "main.py"]

Этот подход содержит 7 критических ошибок:

ПроблемаПоследствиеУровень риска
Полный базовый образ python:3.12Размер 1+ ГБ, лишние системные утилитыВысокий
Тег latest вместо конкретной версииНепредсказуемые сборки, разные результатыКритический
COPY . . перед pip installИнвалидация кеша при любом изменении кодаСредний
Нет .dockerignore.env, .git, __pycache__ попадают в образКритический
Нет USER — запуск от rootЭскалация привилегий при компрометацииКритический
Нет HEALTHCHECKKubernetes не может мониторить состояниеВысокий
Нет multi-stage buildBuild-зависимости остаются в финальном образеСредний

Из 12 сервисов: 8 написаны на Python (FastAPI/Django), 3 на Node.js и 1 на Go. Для каждого стека мы разработали эталонный шаблон.

Multi-stage сборка: убираем лишний вес

Первый и самый важный шаг — внедрение многоступенчатой сборки. Принцип прост: первый этап (builder) компилирует зависимости, второй этап (final) содержит только рантайм и скомпилированные артефакты.

Вот эталонный Dockerfile, который мы разработали для Python-сервисов «АвтоДилера»:

FROM python:3.12.1-slim AS builder
WORKDIR /install
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --upgrade pip && \
    pip wheel --no-deps --wheel-dir /wheels -r requirements.txt

FROM python:3.12.1-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1
WORKDIR /app

# Создаём непривилегированного пользователя
RUN useradd -m -r -s /bin/false appuser

# Устанавливаем зависимости из wheel-файлов
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install --no-deps --no-index --find-links=/wheels -r requirements.txt \
    && rm -rf /wheels

# Копируем код приложения
COPY --chown=appuser:appuser . .

# Переключаемся на непривилегированного пользователя
USER appuser

# Healthcheck для Kubernetes
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Для Node.js-сервисов мы использовали аналогичную схему с node:20-alpine в качестве builder и node:20-alpine для рантайма, копируя только node_modules и dist.

Результат по размерам образов

После внедрения multi-stage builds размеры образов сократились драматически:

СервисДо оптимизацииПосле оптимизацииСокращение
CRM (Python/FastAPI)1.8 ГБ195 МБ89%
Складской учёт (Python/Django)2.1 ГБ230 МБ89%
Калькулятор кредитов (Node.js)1.4 ГБ145 МБ90%
Интеграция ГИБДД (Go)1.2 ГБ18 МБ98%

Go-сервис показал самый впечатляющий результат — мы использовали FROM scratch в качестве финального этапа, копируя только статически скомпилированный бинарник.

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

Неправильный порядок инструкций в Dockerfile приводил к тому, что при каждом изменении кода Docker заново устанавливал все зависимости. У «АвтоДилера» это означало 8-12 минут на каждую сборку.

Ключевой принцип: инструкции, которые меняются реже, должны идти первыми. Зависимости (requirements.txt, package.json) меняются раз в неделю, код — десятки раз в день.

# НЕПРАВИЛЬНО — любое изменение кода инвалидирует кеш pip install
COPY . .
RUN pip install -r requirements.txt

# ПРАВИЛЬНО — pip install берётся из кеша, если requirements.txt не менялся
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

Мы также внедрили BuildKit кеширование для pip:

# syntax=docker/dockerfile:1
FROM python:3.12.1-slim AS builder
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

Это позволило переиспользовать скачанные пакеты между сборками разных веток. Среднее время сборки упало с 12 минут до 45 секунд при неизменных зависимостях.

Безопасность: непривилегированный пользователь и секреты

До нашего вмешательства все 12 контейнеров «АвтоДилера» работали от root. Это критическая уязвимость: если злоумышленник получает доступ к контейнеру, он получает root-права и может выбраться на хост через известные CVE.

Мы стандартизировали подход для всех сервисов:

# Создаём пользователя с минимальными правами
RUN groupadd -r appgroup && \
    useradd -r -g appgroup -d /app -s /sbin/nologin appuser

# Назначаем владельца файлов
COPY --chown=appuser:appgroup . .

# Переключаемся на пользователя
USER appuser

Для секретов мы запретили хранение .env файлов внутри образа. Вместо этого все конфиденциальные данные передаются через:

  • Kubernetes Secrets для паролей БД, API-ключей
  • ConfigMaps для неконфиденциальных параметров
  • Docker secrets (для docker-compose окружений на dev-стендах)

Обязательный .dockerignore, который мы добавили в каждый репозиторий:

.git
.gitignore
__pycache__/
*.pyc
*.pyo
.env
.env.*
.vscode/
.idea/
node_modules/
*.log
docker-compose*.yml
Dockerfile*
README.md
tests/
.coverage
htmlcov/

Выбор базового образа: slim vs alpine vs distroless

На этапе аудита разработчики спросили: «Почему не alpine? Он же легче!» Вопрос справедливый, но ответ не так очевиден.

Alpine Linux использует musl вместо glibc. Для Python это означает:

  • Пакеты с C-расширениями (numpy, pandas, psycopg2) компилируются из исходников, а не устанавливаются из wheel
  • Время сборки увеличивается в 3-5 раз
  • Потенциальные проблемы совместимости — musl обрабатывает DNS-резолвинг иначе, что приводило к таймаутам в production у некоторых клиентов

Наши рекомендации для стека «АвтоДилера»:

СтекРекомендованный образПричина
Pythonpython:3.12.1-slimglibc, быстрые wheel, стабильный DNS
Node.jsnode:20-alpineНет C-зависимостей, alpine безопасен
Goscratch или distrolessСтатический бинарник, минимальная поверхность атаки

Отдельно мы зафиксировали версии SHA256-дайджестами для production-образов:

FROM python@sha256:a1b2c3d4e5f6... AS builder

Это исключает ситуацию, когда мейнтейнер образа обновляет тег, ломая вашу сборку. Подробнее о выборе базовых образов — на itfresh.ru.

CI/CD пайплайн для сборки и деплоя

Мы построили единый CI/CD пайплайн на GitLab CI, который обслуживал все 12 сервисов. Каждый сервис получил свой .gitlab-ci.yml с общим шаблоном:

stages:
  - lint
  - build
  - scan
  - push
  - deploy

variables:
  IMAGE_TAG: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
  IMAGE_LATEST: ${CI_REGISTRY_IMAGE}:latest

lint-dockerfile:
  stage: lint
  image: hadolint/hadolint
  script:
    - hadolint Dockerfile

build:
  stage: build
  image: docker:24.0
  services:
    - docker:24.0-dind
  script:
    - docker build -t ${IMAGE_TAG} -t ${IMAGE_LATEST} .
    - docker save ${IMAGE_TAG} > image.tar
  artifacts:
    paths:
      - image.tar

scan:
  stage: scan
  image: aquasec/trivy
  script:
    - trivy image --input image.tar --severity HIGH,CRITICAL --exit-code 1

push:
  stage: push
  script:
    - docker load < image.tar
    - docker push ${IMAGE_TAG}
    - docker push ${IMAGE_LATEST}

deploy-staging:
  stage: deploy
  script:
    - kubectl set image deployment/${SERVICE_NAME} \
        ${SERVICE_NAME}=${IMAGE_TAG} -n staging
    - kubectl rollout status deployment/${SERVICE_NAME} -n staging --timeout=300s

Ключевые элементы пайплайна:

  • Hadolint — линтер Dockerfile, который отлавливает ошибки до сборки
  • Trivy — сканер уязвимостей, блокирующий деплой при обнаружении CVE с уровнем HIGH и выше
  • Семантическое тегирование — каждый образ получает тег с коммит-хешем, а не only latest
  • Rollout status — CI ждёт успешного раскатывания в Kubernetes перед завершением

Результаты и рекомендации

За 3 недели работы наша команда стандартизировала все 12 сервисов «АвтоДилера». Итоговые метрики:

МетрикаДоПосле
Средний размер образа1.7 ГБ180 МБ
Время сборки (cold)12-15 мин2-3 мин
Время сборки (cached)8-10 мин35-50 сек
Время деплоя на 28 площадок40 мин6 мин
CVE в образах (HIGH+)470
Контейнеры от root12 из 120 из 12

Ключевые рекомендации, которые мы оформили как внутренний стандарт для клиента:

  • Всегда фиксируйте версию базового образа — никогда не используйте тег latest
  • Multi-stage build — обязателен для Python и Node.js
  • Один контейнер — один процесс
  • HEALTHCHECK в каждом Dockerfile
  • Непривилегированный пользователь — стандарт, не исключение
  • .dockerignore — первый файл, который создаётся в проекте
  • CMD vs ENTRYPOINT: используйте ENTRYPOINT для фиксированного поведения, CMD — для параметров по умолчанию

После внедрения стандартов команда «АвтоДилера» самостоятельно контейнеризировала два новых сервиса, следуя нашим шаблонам, без единого замечания на код-ревью.

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

Alpine использует musl вместо glibc, что приводит к проблемам с компиляцией C-расширений Python (numpy, pandas, psycopg2). Пакеты собираются из исходников вместо установки готовых wheel-файлов, увеличивая время сборки в 3-5 раз. Также musl иначе обрабатывает DNS, что может вызывать таймауты в production.
Инструкции, которые меняются реже, должны идти первыми. Сначала копируйте файл зависимостей (requirements.txt, package.json), затем устанавливайте зависимости, и только после этого копируйте исходный код. Так при изменении кода Docker переиспользует кеш установки зависимостей.
Multi-stage build убирает из финального образа всё, что нужно только для сборки: компиляторы, заголовочные файлы, build-essential. Это уменьшает размер образа в 5-10 раз, сокращает поверхность атаки и ускоряет деплой. В нашем проекте средний размер образа упал с 1.7 ГБ до 180 МБ.
Минимум четыре меры: запуск от непривилегированного пользователя (USER в Dockerfile), сканирование образов на CVE (Trivy, Snyk), использование .dockerignore для исключения секретов, фиксация версий базовых образов через SHA256-дайджест. Дополнительно — network policies в Kubernetes и read-only root filesystem.
HEALTHCHECK в Dockerfile полезен для локальной разработки и docker-compose окружений, где Kubernetes probes недоступны. В production Kubernetes liveness и readiness probes имеют приоритет, но HEALTHCHECK служит документацией правильного эндпоинта проверки здоровья и работает при запуске через обычный Docker.

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

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

📞 Связаться с нами
#docker production#multi-stage build#оптимизация docker образов#dockerfile best practices#docker безопасность#docker CI/CD#контейнеризация#docker slim alpine
Комментарии 0

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

загрузка...