Docker в продакшене: ошибки и уроки при миграции 15 сервисов

Клиент и задача: ФинТех Солюшнз переходит на контейнеры

Компания «ФинТех Солюшнз» — финтех-стартап с 15 сервисами на Python, Go и Node.js, развёрнутыми на 8 bare-metal серверах Ubuntu 22.04. Каждый деплой занимал до 3 часов: ручное обновление зависимостей, конфликты библиотек, несовместимости между окружениями разработчиков и продакшена.

Клиент обратился к нам с запросом: перевести все 15 сервисов в Docker-контейнеры, организовать единый CI/CD пайплайн и сократить время деплоя до 15 минут. Бюджет проекта — 3 месяца, команда ITFresh из 3 DevOps-инженеров.

На старте мы провели аудит инфраструктуры и обнаружили ряд потенциальных проблем, которые многие команды игнорируют при переходе на Docker. В этом кейсе мы расскажем о каждой из них и о том, как мы их решали — подробнее на itfresh.ru.

Проблема 1: Docker и файрвол — невидимые конфликты

Первая серьёзная проблема проявилась на второй день миграции. Docker при создании bridged-контейнеров автоматически модифицирует правила iptables/netfilter, вставляя собственные цепочки. Это привело к конфликту с существующим файрволом клиента, который управлялся через ufw.

Наша команда столкнулась с парадоксальной ситуацией: контейнер с API-шлюзом был доступен с внешних серверов, но недоступен с самого хоста, где он работал. Причина — цепочка INPUT блокировала порты контейнера, а внешний трафик шёл через таблицу NAT, минуя эту цепочку.

Решение: ручное управление правилами

Мы отключили автоматическое управление файрволом со стороны Docker-демона, добавив параметр в конфигурацию:

# /etc/docker/daemon.json
{
  "iptables": false,
  "ip-forward": true,
  "userland-proxy": false
}

Затем создали собственный набор правил iptables, которые корректно обрабатывают и трафик контейнеров, и хостовые правила безопасности:

# Разрешаем forward между контейнерами
iptables -A FORWARD -i docker0 -o docker0 -j ACCEPT
iptables -A FORWARD -i docker0 ! -o docker0 -j ACCEPT
iptables -A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

# NAT для выходящего трафика контейнеров
iptables -t nat -A POSTROUTING -s 172.18.0.0/16 ! -o docker0 -j MASQUERADE

# Проброс конкретных портов
iptables -t nat -A PREROUTING -p tcp --dport 8080 -j DNAT --to-destination 172.18.0.5:8080
iptables -A FORWARD -p tcp -d 172.18.0.5 --dport 8080 -j ACCEPT

Цепочка DOCKER-USER, которую Docker предлагает для кастомных правил, на практике оказалась недостаточно гибкой — она не позволяет контролировать NAT-таблицу и не поддерживает сложные сценарии маршрутизации.

Проблема 2: сетевая подсистема и потеря производительности

При нагрузочном тестировании мы обнаружили, что API-сервис на Go в контейнере с пробросом портов через -p 8080:8080 теряет около 5-7% пропускной способности по сравнению с native-запуском. Причина — overhead от bridge-сети, NAT-трансляции и userland-proxy.

Ещё одна неприятность: Docker по умолчанию создаёт bridge docker0 на подсети 172.17.0.0/16. У клиента внутренняя корпоративная сеть использовала диапазон 172.16.0.0/12, что привело к пересечению маршрутов и загадочным сбоям DNS-резолвинга.

Оптимизация сетевого стека

Для критичных по производительности сервисов мы применили network_mode: host, что устраняет весь overhead от bridge-сети:

# docker-compose.yml для высоконагруженного API
services:
  api-gateway:
    image: registry.internal/api-gateway:1.4.2
    network_mode: host
    environment:
      - BIND_PORT=8080
      - METRICS_PORT=9090

Для остальных сервисов мы создали кастомные сети с непересекающимися подсетями:

# daemon.json — переопределяем дефолтную подсеть
{
  "default-address-pools": [
    {
      "base": "10.200.0.0/16",
      "size": 24
    }
  ],
  "bip": "10.200.0.1/24"
}

Важный нюанс: параметр bip игнорируется docker-compose, если сеть создаётся явно. Поэтому для compose-файлов мы прописывали подсети вручную:

networks:
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: 10.201.0.0/24
  frontend:
    driver: bridge
    ipam:
      config:
        - subnet: 10.202.0.0/24

После оптимизации сети и переключения критичных сервисов на host-режим потери производительности сократились до менее 1%.

Проблема 3: безопасность контейнеров — мифы и реальность

При проведении аудита безопасности мы выявили критические уязвимости, типичные для Docker-инфраструктур. Основная из них: доступ к Docker daemon эквивалентен root-доступу к хосту. Достаточно одной команды, чтобы получить полный доступ к файловой системе сервера:

# Так НЕ должно быть в продакшене:
docker run --rm -it -v /:/rootfs ubuntu bash
# Вы получаете root-доступ ко всей ФС хоста через /rootfs

У клиента все разработчики имели доступ к Docker socket, что фактически давало каждому из них привилегии суперпользователя. Кроме того, большинство образов из Docker Hub запускали процессы от root без возможности изменить это поведение.

Комплексная защита контейнерной среды

Наша команда внедрила многоуровневую систему защиты:

1. Непривилегированные контейнеры. Каждый Dockerfile мы переписали с явным указанием пользователя:

FROM python:3.11-slim
RUN groupadd -r appuser && useradd -r -g appuser -d /app appuser
WORKDIR /app
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python", "main.py"]

2. Ограничение capabilities. В docker-compose мы явно ограничили привилегии каждого контейнера:

services:
  payment-api:
    image: registry.internal/payment-api:2.1.0
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - no-new-privileges:true
    read_only: true
    tmpfs:
      - /tmp

3. Контроль доступа к Docker socket. Мы убрали прямой доступ к socket и внедрили Portainer с RBAC для управления контейнерами.

4. Использование --mount вместо -v. Флаг -v автоматически создаёт каталоги с правами root, а --mount требует существования целевого каталога, что позволяет контролировать права.

5. Приватный registry. Мы развернули Harbor как приватный registry с автоматическим сканированием образов на уязвимости через Trivy.

Проблема 4: логирование и мониторинг контейнеров

По умолчанию Docker использует драйвер логирования json-file, который записывает логи в /var/lib/docker/containers/<hash>/<hash>-json.log без каких-либо ограничений на размер. На третьей неделе эксплуатации один из сервисов клиента сгенерировал 48 ГБ логов, заполнив диск хост-машины.

Ещё одна проблема: при высокой нагрузке приложение блокируется, если Docker не успевает обработать поток логов. При этом настройки ротации нельзя изменить на лету — требуется пересоздание контейнера.

Централизованное логирование через journald

Мы перевели все контейнеры на драйвер journald, который поддерживает команду docker logs и интегрируется с системным журналом:

# /etc/docker/daemon.json
{
  "log-driver": "journald",
  "log-opts": {
    "tag": "docker/{{.Name}}/{{.ID}}"
  }
}

Для сбора логов и отправки в ELK-стек мы настроили Promtail:

# promtail-config.yml
scrape_configs:
  - job_name: docker
    journal:
      json: true
      max_age: 12h
      labels:
        job: docker
    relabel_configs:
      - source_labels: ['__journal_container_name']
        target_label: 'container'
      - source_labels: ['__journal_container_tag']
        target_label: 'tag'
    pipeline_stages:
      - json:
          expressions:
            level: level
            msg: message
      - labels:
          level:

Также мы установили лимиты на уровне daemon.json как страховку от переполнения:

{
  "log-opts": {
    "max-size": "50m",
    "max-file": "5"
  }
}

После внедрения объём дискового пространства под логи сократился с непредсказуемого до стабильных 2-3 ГБ на хост.

Проблема 5: оркестрация запуска и depends_on

Одной из самых коварных проблем оказалась оркестрация порядка запуска контейнеров. У клиента API-сервис зависел от PostgreSQL и Redis. При запуске через docker-compose up API-сервис стартовал раньше, чем база данных была готова принимать подключения, и падал с ошибкой.

Директива depends_on в docker-compose оказалась практически бесполезной: она гарантирует только порядок запуска контейнеров, но не ждёт готовности сервисов внутри них. В формате compose версии 2.4 появилась поддержка health-check условий, но в версии 3.x (рекомендуемой для Docker Swarm) эта функциональность была удалена.

Правильная оркестрация через healthcheck

Мы использовали формат compose 2.4 с явными health-check условиями:

version: '2.4'
services:
  postgres:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 15s

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  api:
    image: registry.internal/api:1.2.0
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

Для продакшен-среды мы дополнительно обернули запуск каждого сервиса в systemd unit, что обеспечило автоматический перезапуск, корректное завершение и интеграцию с системным мониторингом:

[Unit]
Description=Payment API Container
After=docker.service postgresql-container.service
Requires=docker.service

[Service]
Type=simple
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f payment-api
ExecStart=/usr/bin/docker run --name payment-api \
  --network backend \
  -p 8080:8080 \
  registry.internal/payment-api:latest
ExecStop=/usr/bin/docker stop -t 30 payment-api

[Install]
WantedBy=multi-user.target

Проблема 6: хранение данных и совместимость версий

В процессе обновления Docker на одном из серверов мы столкнулись с тем, что переход со storage-драйвера AUFS на overlay2 привёл к полной потере всех существующих volumes и образов. Пути назад не оказалось — Docker не предоставляет механизма миграции между storage-драйверами.

Ещё один неприятный сюрприз: образы, собранные на сервере с более новой версией Docker daemon, не запускались на серверах с более старой версией. Обратная совместимость (старые образы на новом daemon) работала, а вот прямая — нет.

Стратегия управления данными и версиями

Мы разработали и внедрили несколько практик:

  • Единая версия Docker на всех серверах с синхронными обновлениями через Ansible
  • Внешнее хранение данных — все stateful-сервисы используют именованные volumes с бэкапами, а критичные данные хранятся на внешних NFS-шарах
  • Registry с версионированием — каждый образ тегируется хэшем коммита и семантической версией, тег latest запрещён в продакшене
# Ansible playbook для синхронизации версий Docker
- name: Ensure consistent Docker version
  hosts: docker_hosts
  tasks:
    - name: Pin Docker version
      apt:
        name: docker-ce=5:24.0.7-1~ubuntu.22.04~jammy
        state: present
        allow_downgrade: yes
    - name: Verify storage driver
      command: docker info --format '{{.Driver}}'
      register: storage_driver
    - name: Assert overlay2
      assert:
        that: storage_driver.stdout == 'overlay2'
        msg: "Storage driver must be overlay2"

Результаты проекта

За 3 месяца работы наша команда перевела все 15 сервисов «ФинТех Солюшнз» в Docker-контейнеры, решив каждую из описанных проблем до того, как она проявилась в продакшене. Итоговые показатели:

МетрикаДо миграцииПосле миграции
Время деплоя одного сервиса2-3 часа8 минут
Частота деплоев1-2 раза в неделю3-5 раз в день
Инциденты из-за зависимостей4-6 в месяц0
Время настройки нового сервера2 дня25 минут
Воспроизводимость окружений~70%100%
Потребление RAM (суммарно)64 ГБ41 ГБ

Ключевой вывод: Docker — мощный инструмент, но только при правильной конфигурации. Без продуманного подхода к сети, безопасности и логированию контейнеризация создаёт больше проблем, чем решает. Наша команда ITFresh накопила экспертизу, позволяющую обходить эти подводные камни ещё на этапе проектирования.

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

Docker автоматически вставляет правила в iptables для маршрутизации трафика к контейнерам через NAT. Это можно отключить параметром "iptables": false в /etc/docker/daemon.json, но тогда потребуется ручное управление правилами файрвола. Мы рекомендуем этот подход для продакшен-серверов с существующей сетевой политикой.
Для сервисов с требованиями к минимальной латентности рекомендуется network_mode: host, который устраняет overhead от bridge-сети и NAT-трансляции (около 5-7% потерь). Для остальных сервисов подходит bridge-сеть с кастомными подсетями.
Дефолтный драйвер json-file не имеет лимитов и может заполнить диск. Мы рекомендуем journald-драйвер с интеграцией в централизованную систему логирования (ELK/Loki). Обязательно установите max-size и max-file в daemon.json как страховку.
Нет. Доступ к Docker socket эквивалентен root-доступу к хосту — можно смонтировать корневую ФС и получить полный контроль. Используйте Portainer или аналогичный инструмент с RBAC для управления контейнерами без прямого доступа к socket.
Директива depends_on без условий бесполезна — она не ждёт готовности сервиса. Используйте compose формат 2.4 с condition: service_healthy и явными healthcheck для каждого зависимого сервиса. Для продакшена оберните контейнеры в systemd unit-файлы.

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

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

📞 Связаться с нами
#docker production#контейнеризация#iptables docker#docker безопасность#docker networking#docker compose#логирование контейнеров#миграция в docker
Комментарии 0

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

загрузка...