Контейнеризация 20 сервисов в Docker для завода ПромТехСервис

С чем пришёл клиент

В феврале 2026 года к нам в itfresh.ru обратился технический директор производственной компании «ПромТехСервис» — предприятия с 450 сотрудниками и собственным отделом разработки из 8 человек. У компании работало 20 внутренних сервисов: ERP-модуль учёта комплектующих, система контроля качества, складской терминал, веб-портал для дилеров, три интеграции с 1С, внутренний мессенджер и ещё десяток вспомогательных утилит.

Проблемы были типичными для компании, выросшей органически:

  • Разворачивание нового сервера — от 2 до 5 дней ручной настройки. Инженер устанавливал Python 3.8 и 3.11, Node.js 16 и 18, PostgreSQL, Redis, RabbitMQ, настраивал systemd-юниты — и каждый раз что-то забывал.
  • Конфликты зависимостей — один сервис требовал libssl 1.1, другой — 3.0. На одном сервере запускались приложения на разных версиях Python, и обновление системного пакета ломало половину стека.
  • Отсутствие воспроизводимости — фраза «у меня на машине работает» звучала минимум три раза в неделю. Тестовая среда не соответствовала продакшену, потому что настраивалась вручную полгода назад.
  • Откат при сбое — занимал от 2 до 6 часов. Никакого версионирования окружения не было.

Задача: полностью контейнеризировать все 20 сервисов, выстроить процесс сборки и деплоя, обеспечить изоляцию и воспроизводимость.

Команда проекта и план работ

Наша команда состояла из трёх инженеров itfresh.ru и двух разработчиков клиента, которых мы обучали в процессе. Проект разбили на 6 недель:

  • Неделя 1-2: Аудит всех 20 сервисов, составление матрицы зависимостей, написание Dockerfile для каждого приложения.
  • Неделя 3: Организация docker-compose стеков, настройка сетей и томов, развёртывание приватного registry.
  • Неделя 4: Интеграция с CI/CD — автоматическая сборка образов по коммиту, прогон тестов в контейнерах.
  • Неделя 5: Миграция тестовой среды на Docker, обучение команды клиента.
  • Неделя 6: Поэтапный перевод продакшена, мониторинг, документация.

Ключевой принцип, который мы зафиксировали на старте: один контейнер — один процесс. Никаких supervisor-ов внутри контейнера, никаких init-систем. Контейнер живёт ровно столько, сколько живёт его основной процесс (PID 1), и это свойство мы использовали для автоматического перезапуска.

Dockerfile: принципы и практика

Каждый сервис получил свой Dockerfile. Мы придерживались строгих правил, выработанных на десятках проектов:

Правило 1: Минимальный базовый образ. Вместо ubuntu:22.04 (77 MB) мы использовали python:3.11-slim (50 MB) для Python-сервисов и node:18-alpine (47 MB) для Node.js. Для Go-приложений использовали multi-stage сборку с финальным образом на scratch или distroless.

Правило 2: Каждая инструкция RUN создаёт слой. Поэтому мы объединяли связанные команды через && и очищали кеш в том же слое:

FROM python:3.11-slim

# Один слой для системных зависимостей
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        libpq-dev gcc && \
    rm -rf /var/lib/apt/lists/*

# Сначала копируем только requirements — для кеширования
COPY requirements.txt /app/
WORKDIR /app
RUN pip install --no-cache-dir -r requirements.txt

# Код приложения — меняется чаще всего, поэтому последним
COPY . /app/

EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "app:create_app()"]

Правило 3: Порядок инструкций влияет на кеширование. Docker кеширует слои сверху вниз. Если слой изменился — все нижестоящие пересобираются. Поэтому мы всегда копировали файлы зависимостей (requirements.txt, package.json) до исходного кода. При изменении кода пересобирался только последний слой, экономя 3-5 минут сборки.

Правило 4: Multi-stage для production. Для Go-сервисов мы собирали бинарник в одном образе, а запускали в другом:

FROM golang:1.21-alpine AS builder
WORKDIR /build
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 gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Итоговый образ Go-сервиса весил 12 MB вместо 350 MB. Для 20 сервисов суммарная экономия дискового пространства на registry составила более 6 GB.

Правило 5: Не запускать контейнер от root. Мы добавляли пользователя в каждый Dockerfile:

RUN addgroup --system app && adduser --system --ingroup app app
USER app

Docker Compose: оркестрация 20 сервисов

Для организации всего стека мы разбили сервисы на три docker-compose файла по функциональным группам: infrastructure (базы данных, очереди), core (основные бизнес-сервисы) и auxiliary (мониторинг, логирование).

Пример файла для инфраструктурного слоя:

version: '3.8'

services:
  postgres-erp:
    image: postgres:15-alpine
    container_name: pts-postgres-erp
    environment:
      POSTGRES_DB: erp_production
      POSTGRES_USER: erp_user
      POSTGRES_PASSWORD_FILE: /run/secrets/pg_erp_password
    volumes:
      - pg_erp_data:/var/lib/postgresql/data
      - ./init-scripts/erp:/docker-entrypoint-initdb.d
    networks:
      - backend
    secrets:
      - pg_erp_password
    deploy:
      resources:
        limits:
          memory: 2G
          cpus: '2.0'
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U erp_user -d erp_production"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    container_name: pts-redis
    command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 512mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5

  rabbitmq:
    image: rabbitmq:3.12-management-alpine
    container_name: pts-rabbitmq
    environment:
      RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
      RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
      - ./rabbitmq/definitions.json:/etc/rabbitmq/definitions.json
    ports:
      - "15672:15672"   # Management UI — только из внутренней сети
    networks:
      - backend

volumes:
  pg_erp_data:
  redis_data:
  rabbitmq_data:

networks:
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16

secrets:
  pg_erp_password:
    file: ./secrets/pg_erp_password.txt

Для бизнес-сервисов мы использовали depends_on с условием service_healthy, чтобы приложение не стартовало раньше базы данных:

  erp-backend:
    build:
      context: ./services/erp-backend
      dockerfile: Dockerfile
    container_name: pts-erp-backend
    env_file: ./envs/erp.env
    depends_on:
      postgres-erp:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - erp_uploads:/app/uploads
      - ./configs/erp:/etc/erp:ro
    networks:
      - backend
      - frontend
    restart: unless-stopped

Мы разделили сети: backend для межсервисного взаимодействия, frontend для сервисов, доступных через Nginx reverse proxy. Базы данных и очереди находились только в backend и не были видны снаружи.

Volumes и хранение данных

Данные внутри контейнера живут ровно столько, сколько существует контейнер. При пересоздании контейнера всё теряется. Поэтому для любых персистентных данных мы использовали Docker volumes.

Мы применяли три стратегии хранения:

Named volumes — для баз данных, очередей, логов. Docker сам управляет расположением на диске:

volumes:
  pg_erp_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /data/postgres/erp

Bind mounts — для конфигурационных файлов, которые мы хранили в Git и монтировали в контейнер только для чтения:

volumes:
  - ./configs/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
  - ./configs/nginx/sites:/etc/nginx/conf.d:ro

tmpfs — для временных файлов, которые не должны писаться на диск (например, кеш обработки изображений):

tmpfs:
  - /app/tmp:size=256M

Для бэкапа баз данных мы написали скрипт, который запускался из отдельного контейнера через cron:

#!/bin/bash
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=/backups

# Бэкап PostgreSQL ERP
docker exec pts-postgres-erp pg_dump -U erp_user erp_production | \
  gzip > ${BACKUP_DIR}/erp_${DATE}.sql.gz

# Бэкап PostgreSQL склад
docker exec pts-postgres-warehouse pg_dump -U wh_user warehouse_db | \
  gzip > ${BACKUP_DIR}/warehouse_${DATE}.sql.gz

# Ротация — храним 30 дней
find ${BACKUP_DIR} -name "*.sql.gz" -mtime +30 -delete

echo "[$(date)] Backup completed" >> /var/log/docker-backup.log

Важный урок: мы столкнулись с тем, что один из разработчиков клиента использовал docker run --rm для тестового сервера базы данных, не подключив volume. После перезапуска контейнера потерял 3 дня тестовых данных. После этого мы добавили в CI/CD проверку: если в docker-compose.yml для сервиса с базой данных нет секции volumes — сборка падает.

Приватный Docker Registry и CI/CD

Для хранения образов мы развернули приватный Docker Registry на выделенном сервере клиента:

# Запуск registry с TLS и авторизацией
docker run -d \
  --name registry \
  --restart=always \
  -p 5000:5000 \
  -v /data/registry:/var/lib/registry \
  -v /etc/registry/certs:/certs \
  -v /etc/registry/auth:/auth \
  -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
  -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
  -e REGISTRY_AUTH=htpasswd \
  -e REGISTRY_AUTH_HTPASSWD_REALM="PTS Registry" \
  -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
  -e REGISTRY_STORAGE_DELETE_ENABLED=true \
  registry:2

CI/CD пайплайн в GitLab CI выглядел так:

stages:
  - test
  - build
  - deploy

variables:
  REGISTRY: registry.promtechservice.local:5000

test:
  stage: test
  script:
    - docker build --target test -t ${CI_PROJECT_NAME}:test .
    - docker run --rm ${CI_PROJECT_NAME}:test pytest --cov=app --cov-report=term
  only:
    - merge_requests
    - main

build:
  stage: build
  script:
    - docker build -t ${REGISTRY}/${CI_PROJECT_NAME}:${CI_COMMIT_SHA} .
    - docker tag ${REGISTRY}/${CI_PROJECT_NAME}:${CI_COMMIT_SHA} ${REGISTRY}/${CI_PROJECT_NAME}:latest
    - docker push ${REGISTRY}/${CI_PROJECT_NAME}:${CI_COMMIT_SHA}
    - docker push ${REGISTRY}/${CI_PROJECT_NAME}:latest
  only:
    - main

deploy:
  stage: deploy
  script:
    - ssh deploy@production "cd /opt/pts && docker-compose pull ${CI_PROJECT_NAME} && docker-compose up -d ${CI_PROJECT_NAME}"
  only:
    - main
  when: manual

Каждый коммит в main автоматически собирал образ, тегировал его хешем коммита и пушил в registry. Деплой на продакшен запускался вручную одной кнопкой. Среднее время от коммита до деплоя сократилось с 5 дней до 12 минут.

Для очистки старых образов мы настроили garbage collection на registry:

# Еженедельная очистка — удаляем образы старше 60 дней
0 3 * * 0 docker exec registry bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged

Сетевая архитектура и безопасность

Сетевая изоляция — одно из ключевых преимуществ Docker, и мы использовали его по максимуму. Архитектура сетей выглядела так:

  • frontend — подсеть 172.20.1.0/24. Nginx reverse proxy и сервисы, к которым нужен доступ извне (портал дилеров, API мобильного приложения).
  • backend — подсеть 172.20.2.0/24. Бизнес-сервисы, которые общаются между собой.
  • database — подсеть 172.20.3.0/24. PostgreSQL, Redis, RabbitMQ. Доступ только из backend-сети.
  • monitoring — подсеть 172.20.4.0/24. Prometheus, Grafana, Loki.

Важный нюанс: приложение внутри контейнера должно слушать на 0.0.0.0, а не на 127.0.0.1. Если привязаться к localhost, сервис будет доступен только изнутри контейнера. Эта ошибка стоила нам 4 часов отладки на одном из Python-сервисов, который по умолчанию запускался на localhost.

Для Nginx мы использовали DNS-имена контейнеров вместо IP-адресов:

upstream erp_backend {
    server pts-erp-backend:8000;
}

upstream dealer_portal {
    server pts-dealer-portal:3000;
}

server {
    listen 443 ssl;
    server_name erp.promtechservice.ru;

    ssl_certificate /etc/nginx/certs/erp.crt;
    ssl_certificate_key /etc/nginx/certs/erp.key;

    location / {
        proxy_pass http://erp_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Docker встроенный DNS-резолвер автоматически разрешает имена контейнеров в IP-адреса внутри одной сети. При пересоздании контейнера IP может измениться, но DNS-имя остаётся тем же — что гарантирует стабильное подключение.

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

Через 6 недель работы все 20 сервисов «ПромТехСервис» работали в Docker-контейнерах. Вот измеримые результаты:

МетрикаДоПосле
Время развёртывания нового сервера2-5 дней15 минут
Время деплоя одного сервиса1-3 часа12 минут
Время отката2-6 часов30 секунд (смена тега образа)
Инциденты из-за конфликтов зависимостей3-4 в месяц0
Расхождение тест/продПостоянноИдентичные образы
Суммарный размер 20 образов2.4 GB (с multi-stage)

Рекомендации для команд, начинающих контейнеризацию:

  • Начинайте с самого простого сервиса — stateless API без базы данных. Это позволит команде набить руку.
  • Не обновляйте пакеты внутри работающего контейнера — пересобирайте образ. Контейнер должен быть immutable.
  • Используйте .dockerignore — исключайте .git, node_modules, __pycache__, тестовые данные. Это ускоряет сборку в 3-5 раз.
  • Healthcheck обязателен — без него docker-compose не знает, когда сервис реально готов принимать запросы.
  • Логируйте в stdout/stderr — не пишите логи в файлы внутри контейнера. Docker сам соберёт их через logging driver.

Если вашей компании нужна помощь с контейнеризацией — команда itfresh.ru готова провести аудит и спланировать миграцию под ваш стек технологий.

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

Для проекта из 10-20 сервисов — от 4 до 8 недель, включая настройку CI/CD, обучение команды и поэтапную миграцию продакшена. Один простой сервис контейнеризируется за 1-2 дня.
Нет. В отличие от виртуальных машин, контейнеры используют ядро хоста и не требуют отдельной ОС. Overhead Docker составляет менее 2% CPU и около 50 MB RAM на сам демон. Зачастую контейнеризация снижает потребление ресурсов за счёт оптимизации базовых образов.
Да. Для большинства компаний с 5-30 сервисами docker-compose на 2-3 серверах — оптимальный вариант. Kubernetes оправдан при 50+ сервисах, потребности в автомасштабировании или мультикластерном развёртывании.
Четыре обязательных шага: запуск от непривилегированного пользователя (USER в Dockerfile), использование минимальных базовых образов (alpine, distroless), регулярное сканирование образов на уязвимости (Trivy, Grype) и ограничение ресурсов через deploy.resources.limits.
Для тестовых и staging-сред — однозначно да. Для продакшена — зависит от нагрузки. При объёмах до 500 GB и до 5000 транзакций в секунду PostgreSQL в Docker с named volumes работает стабильно. Для более нагруженных систем рекомендуем managed-решения или bare metal с репликацией.

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

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

📞 Связаться с нами
#docker#контейнеризация#dockerfile#docker-compose#devops#микросервисы#CI/CD#registry
Комментарии 0

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

загрузка...