Docker в продакшене: разбор падения и восстановление платёжной системы ПэйГейт

4 часа без транзакций: что произошло

Финтех-компания «ПэйГейт» обрабатывает платежи для 200+ интернет-магазинов. В ночь с 15 на 16 марта 2026 года процессинг остановился на 4 часа 12 минут. За это время было потеряно транзакций на 2.3 миллиона рублей, а 47 мерчантов получили таймауты от платёжного шлюза.

Причина: Docker daemon завис и перестал отвечать на API-запросы. Все 14 контейнеров на production-сервере стали unreachable, docker ps не возвращал результат, а docker restart зависал бесконечно. Единственный способ восстановить работу — kill -9 dockerd и перезапуск, что привело к потере состояния in-flight транзакций.

После экстренного восстановления руководство «ПэйГейт» обратилось к itfresh.ru для расследования причин и предотвращения повторения.

Расследование: overlay2 и переполнение диска

Первая обнаруженная проблема — storage driver overlay2 исчерпал inodes на /var/lib/docker. Docker продолжал работать, но не мог создавать новые файлы в diff-слоях контейнеров:

# Проверяем inodes
df -i /var/lib/docker
# Filesystem     Inodes    IUsed    IFree IUse% Mounted on
# /dev/sda1      6553600  6553597        3  100% /

# Кто занял все inodes?
find /var/lib/docker/overlay2 -maxdepth 3 -type d | wc -l
# 892431  ← почти миллион директорий!

# Старые, неиспользуемые слои
docker system df
# TYPE          TOTAL   ACTIVE  SIZE      RECLAIMABLE
# Images        147     14      48.2GB    39.7GB (82%)
# Containers    14      14      12.3GB    0B (0%)
# Local Volumes 23      8       34.1GB    28.9GB (84%)
# Build Cache   0       0       0B        0B

147 образов при 14 активных контейнерах — за полгода никто не чистил старые образы. Docker не имеет встроенного garbage collection, и это одна из его фундаментальных проблем.

# Экстренная очистка
docker system prune -a --volumes -f

# Настраиваем автоматическую очистку через cron
cat > /etc/cron.daily/docker-cleanup << 'CRON'
#!/bin/bash
# Удаляем образы старше 7 дней, не привязанные к контейнерам
docker image prune -a --filter "until=168h" -f
# Удаляем остановленные контейнеры старше 24 часов
docker container prune --filter "until=24h" -f
# Удаляем неиспользуемые volumes
docker volume prune -f
# Логируем результат
echo "$(date): Docker cleanup completed" >> /var/log/docker-cleanup.log
CRON
chmod +x /etc/cron.daily/docker-cleanup

Проблема PID 1 и zombie-процессы

Второе открытие — три контейнера накопили сотни zombie-процессов. В Linux, когда родительский процесс не вызывает wait() для дочерних, они остаются в состоянии zombie (defunct). В обычной системе init (PID 1) «подбирает» осиротевших зомби. Но в контейнере PID 1 — это ваше приложение, и оно, как правило, не умеет быть init.

# Проверяем zombie-процессы в контейнере
docker exec payment-worker ps aux | grep defunct
# root  1247  0.0  0.0  0  0 ?  Z  Mar15  0:00 [python] 
# root  1248  0.0  0.0  0  0 ?  Z  Mar15  0:00 [python] 
# ... ещё 347 строк

# Считаем
docker exec payment-worker ps aux | grep -c defunct
# 349

349 zombie-процессов! Каждый занимает запись в таблице процессов ядра. При лимите 32768 PID на систему (sysctl kernel.pid_max) это уже заметная утечка.

Решение — использовать tini как init-процесс в контейнере:

# Dockerfile — ДО (проблемный)
FROM python:3.11-slim
COPY . /app
CMD ["python", "worker.py"]

# Dockerfile — ПОСЛЕ (с tini)
FROM python:3.11-slim
RUN apt-get update && apt-get install -y tini && rm -rf /var/lib/apt/lists/*
COPY . /app
ENTRYPOINT ["tini", "--"]
CMD ["python", "worker.py"]

Альтернативно, в Docker 1.13+ можно использовать флаг --init:

# docker-compose.yml
services:
  payment-worker:
    image: paygate/worker:latest
    init: true  # Docker сам добавит tini

OOM killer и log rotation

Третья проблема — два контейнера были убиты OOM killer, но Docker не перезапустил их корректно. Причина: контейнеры не имели лимитов памяти и потребляли RAM без ограничений:

# Проверяем OOM-события
dmesg | grep -i 'out of memory\|oom-killer'
# [452167.234] Out of memory: Killed process 18234 (java)
#              total-vm:8234567kB, anon-rss:4123456kB

# Текущее потребление памяти контейнерами
docker stats --no-stream --format \
  'table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.PIDs}}'
# NAME              MEM USAGE / LIMIT     MEM %   PIDS
# payment-gateway   1.8GiB / 15.5GiB      11.6%   45
# payment-worker    4.2GiB / 15.5GiB      27.1%   387
# notification-svc  892MiB / 15.5GiB       5.6%   12

Лимит «15.5 GiB» — это вся RAM хоста. Контейнер без лимита может сожрать всю память и убить соседей.

# docker-compose.yml — устанавливаем лимиты
services:
  payment-gateway:
    image: paygate/gateway:latest
    deploy:
      resources:
        limits:
          memory: 2G
          cpus: '2.0'
        reservations:
          memory: 512M
          cpus: '0.5'
    # Политика перезапуска
    restart: unless-stopped

Отдельная бомба замедленного действия — логи контейнеров. Docker по умолчанию хранит stdout/stderr контейнеров в JSON-файлах без ротации:

# Размер логов контейнеров
du -sh /var/lib/docker/containers/*/
# 8.7G  /var/lib/docker/containers/a1b2c3.../  ← один контейнер!

ls -lh /var/lib/docker/containers/a1b2c3*/*-json.log
# -rw-r----- 1 root root 8.7G Mar 16 /var/lib/docker/containers/a1b2c3.../*-json.log

8.7 GB логов одного контейнера за полгода! Настраиваем ротацию глобально:

# /etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "50m",
    "max-file": "5"
  },
  "storage-driver": "overlay2",
  "default-ulimits": {
    "nofile": {
      "Name": "nofile",
      "Hard": 65536,
      "Soft": 65536
    }
  }
}

Docker daemon hangs и healthchecks

Корневая причина 4-часового простоя — зависание Docker daemon. При overlay2 с миллионом директорий daemon периодически блокировался на I/O при обращении к storage backend. Когда несколько контейнеров одновременно пишут большие diff-слои, daemon становится однопоточным бутылочным горлышком.

Мониторинг Docker daemon — критически важный и при этом часто пропускаемый аспект:

# Проверка здоровья daemon
curl -s --unix-socket /var/run/docker.sock http://localhost/version | jq '.Version'

# Скрипт мониторинга с алертом в Telegram
cat > /usr/local/bin/docker-health-check.sh << 'HEALTH'
#!/bin/bash
TIMEOUT=5
BOT_TOKEN="YOUR_BOT_TOKEN"
CHAT_ID="YOUR_CHAT_ID"

if ! timeout $TIMEOUT docker info > /dev/null 2>&1; then
    MSG="CRITICAL: Docker daemon not responding on $(hostname)"
    curl -s "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
        -d "chat_id=${CHAT_ID}&text=${MSG}" > /dev/null
    echo "$(date): Docker daemon unresponsive" >> /var/log/docker-health.log
fi
HEALTH
chmod +x /usr/local/bin/docker-health-check.sh

# Проверяем каждую минуту
echo '* * * * * root /usr/local/bin/docker-health-check.sh' > /etc/cron.d/docker-health

Healthchecks для самих контейнеров — чтобы Docker автоматически перезапускал зависшие сервисы:

# Dockerfile с healthcheck
FROM python:3.11-slim
COPY . /app
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD curl -f http://localhost:8080/health || exit 1
CMD ["python", "app.py"]

# docker-compose.yml с healthcheck
services:
  payment-gateway:
    image: paygate/gateway:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    restart: unless-stopped

Signal handling: graceful shutdown

При перезапуске контейнеров Docker отправляет SIGTERM и ждёт 10 секунд, затем SIGKILL. Проблема: Python-приложения «ПэйГейт» не обрабатывали SIGTERM и не завершали in-flight транзакции корректно:

# Проблемный код — не обрабатывает сигналы
from flask import Flask
app = Flask(__name__)
app.run(host='0.0.0.0', port=8080)
# При SIGTERM: мгновенное завершение, потеря данных

Правильная обработка сигналов:

import signal
import sys
import threading
from flask import Flask

app = Flask(__name__)
shutdown_event = threading.Event()

def graceful_shutdown(signum, frame):
    """Обработка SIGTERM для graceful shutdown."""
    print(f"Received signal {signum}, shutting down gracefully...")
    shutdown_event.set()
    
    # Ждём завершения текущих транзакций (макс 25 сек)
    # Docker отправит SIGKILL через 30 сек
    active = get_active_transactions()
    for tx in active:
        tx.wait_completion(timeout=20)
        if not tx.completed:
            tx.rollback()
            print(f"Transaction {tx.id} rolled back")
    
    print("All transactions completed, exiting")
    sys.exit(0)

signal.signal(signal.SIGTERM, graceful_shutdown)
signal.signal(signal.SIGINT, graceful_shutdown)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080)

Увеличиваем stop_grace_period в docker-compose, чтобы дать приложению время на завершение:

# docker-compose.yml
services:
  payment-gateway:
    image: paygate/gateway:latest
    stop_grace_period: 30s  # 30 секунд вместо 10 по умолчанию
    init: true
    deploy:
      resources:
        limits:
          memory: 2G

Podman как альтернатива Docker

После расследования мы предложили «ПэйГейт» оценить Podman как альтернативу Docker для критичных production-систем. Ключевые преимущества:

  • Daemonless — нет единой точки отказа. Каждый контейнер — отдельный процесс, зависание одного не блокирует остальные
  • Rootless — контейнеры работают без root-привилегий, снижая поверхность атаки
  • Systemd-интеграция — каждый контейнер управляется как systemd-юнит с полноценным мониторингом
  • Совместимость — Podman понимает Dockerfile и docker-compose (через podman-compose)
# Установка Podman на Debian 12
apt install -y podman podman-compose

# Podman использует тот же CLI, что и Docker
alias docker=podman

# Запуск контейнера (идентично Docker)
podman run -d --name payment-gateway \
  --memory 2g --cpus 2 \
  -p 8080:8080 \
  --init \
  --restart unless-stopped \
  paygate/gateway:latest

# Генерация systemd-юнита из контейнера
podman generate systemd --new --name payment-gateway \
  > /etc/systemd/system/container-payment-gateway.service

systemctl daemon-reload
systemctl enable container-payment-gateway

Принципиальное отличие: при зависании одного контейнера в Podman остальные продолжают работать. В Docker зависший daemon блокирует все контейнеры на хосте.

# Rootless mode — запуск без root
# Создаём пользователя для контейнеров
useradd -m -s /bin/bash poduser
loginctl enable-linger poduser

# Запускаем контейнер от непривилегированного пользователя
su - poduser -c 'podman run -d --name gateway paygate/gateway:latest'

Результаты и извлечённые уроки

После расследования и внедрения исправлений инфраструктура «ПэйГейт» была перестроена:

  • Лимиты ресурсов — каждый контейнер имеет memory limit и CPU limit
  • Log rotation — максимум 250 MB логов на контейнер (5 файлов по 50 MB)
  • Init process — все контейнеры запускаются с --init (tini)
  • Healthchecks — каждый сервис имеет /health endpoint и HEALTHCHECK в Dockerfile
  • Signal handling — все приложения корректно обрабатывают SIGTERM
  • Автоочистка — ежедневное удаление неиспользуемых образов и volumes
  • Мониторинг daemon — ежеминутная проверка с алертом в Telegram

Результаты за 3 месяца после внедрения:

МетрикаДоПосле
Незапланированные простои3 инцидента/мес0
Потерянные транзакции при перезапускедо 2000
Использование диска /var/lib/docker94%35%
Zombie-процессыдо 3500
Время graceful shutdownмгновенно (потеря данных)5-15 сек (корректно)

Главный урок: Docker — мощный инструмент, но он требует осознанной production-конфигурации. Установка по умолчанию не готова к продакшену: нет ротации логов, нет лимитов памяти, нет garbage collection, нет init-процесса. Специалисты itfresh.ru подготовили чеклист из 15 пунктов production-readiness для Docker, который «ПэйГейт» теперь применяет на каждом новом сервере.

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

Частые причины: переполнение inodes в overlay2, одновременная запись множества контейнеров в storage backend, утечки goroutine в dockerd. Решения: регулярная очистка образов, мониторинг daemon (docker info с таймаутом), ограничение количества контейнеров на хосте, рассмотрение Podman для критичных систем.
Zombie-процессы занимают записи в таблице PID ядра (лимит kernel.pid_max, обычно 32768). При накоплении сотен зомби система может не создать новые процессы. Решение: запускать контейнер с --init (добавляет tini как PID 1) или использовать ENTRYPOINT ["tini", "--"] в Dockerfile.
На 95% — да. Podman понимает Dockerfile, тот же CLI (podman run, podman build), работает с Docker Hub. Отличия: нет docker-compose напрямую (нужен podman-compose), другая модель сети (CNI/netavark вместо docker0 bridge), нет daemon (каждый контейнер — отдельный процесс).
В /etc/docker/daemon.json: {"log-driver": "json-file", "log-opts": {"max-size": "50m", "max-file": "5"}}. Это ограничит логи каждого контейнера до 250 MB (5 файлов по 50 MB). Важно: настройка применяется только к новым контейнерам — существующие нужно пересоздать.
Да, но с обязательной production-конфигурацией: лимиты ресурсов, ротация логов, healthchecks, graceful shutdown, init process, мониторинг daemon. Без этих мер Docker — бомба замедленного действия. Для mission-critical систем рассмотрите Podman (daemonless) или Kubernetes (оркестрация с самовосстановлением).

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

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

📞 Связаться с нами
#docker#podman#overlay2#pid 1#zombie#oom killer#healthcheck#signal handling
Комментарии 0

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

загрузка...