Ресурсы и логи в проде

Без лимитов один контейнер способен съесть всю память и положить хост, а логи — забить диск; учимся ограничивать ресурсы и управлять логами.

Лимиты ресурсов — потолки на память и CPU, выставляемые через cgroups, чтобы один контейнер не отобрал ресурсы у соседей и у самого хоста.

Зачем это на практике

По умолчанию контейнер может использовать всю память и все ядра хоста. Утечка памяти в одном сервисе — и ядро запускает OOM-killer, который убивает «самый прожорливый» процесс на всём сервере, иногда вовсе не виновный. Аналогично логи: образ пишет в stdout/stderr, Docker по умолчанию складывает это в файл json-file, который растёт без предела и однажды забивает диск под ноль. В проде и то, и другое нужно ограничивать заранее.

Лимиты памяти и CPU

Память ограничивают флагом --memory, процессорное время — --cpus:

docker run \
  --memory=512m \
  --memory-reservation=256m \
  --cpus=1.5 \
  myapp:latest

--memory=512m — жёсткий потолок: при превышении контейнер получает OOM. --memory-reservation=256m — мягкий резерв: под давлением памяти ядро в первую очередь ужимает тех, кто выше своего резерва. --cpus=1.5 означает «полтора ядра»: контейнер получит до 150% одного CPU. В Compose (v3, через Swarm-совместимый блок) лимиты задаются так:

services:
  app:
    image: myapp:latest
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 512M
        reservations:
          cpus: "0.5"
          memory: 256M

OOM-killer: что происходит при нехватке памяти

Когда контейнер упирается в --memory, ядро Linux вызывает OOM-killer внутри его cgroup и убивает процесс. В логах контейнера вы увидите внезапную смерть, а docker inspect покажет "OOMKilled": true. Это критично отличать от обычного падения: код приложения корректен, ему просто не хватило выделенного потолка. Лечится либо поднятием лимита (если 512 МБ действительно мало), либо устранением утечки. Жёсткий лимит — это страховка для хоста: лучше пусть умрёт один контейнер в своих границах, чем OOM-killer положит случайный процесс на всём сервере.

docker inspect --format='{{.State.OOMKilled}}' myapp

Вывод:

true

Драйверы логирования

Docker перенаправляет stdout/stderr контейнера в драйвер логирования. По умолчанию это json-file — простой и удобный для docker logs, но без ротации он опасен. Основные драйверы:

ДрайверКогда применять
json-fileпо умолчанию; работает docker logs; обязательно настроить ротацию
journaldотдать логи systemd-журналу хоста (единый journalctl, ротация системой)
localкомпактный бинарный формат с ротацией из коробки
noneотключить сбор логов совсем (для очень болтливых сервисов)

Ротация логов

Чтобы json-file не съел диск, задают максимальный размер файла и число архивов. Для одного контейнера — через опции драйвера:

docker run \
  --log-driver json-file \
  --log-opt max-size=10m \
  --log-opt max-file=3 \
  myapp:latest

Это держит максимум 3 файла по 10 МБ — итого не больше 30 МБ логов на контейнер; старое затирается. В Compose:

services:
  app:
    image: myapp:latest
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Лучше задать ротацию глобально в /etc/docker/daemon.json, чтобы она применялась ко всем контейнерам по умолчанию и вы не забыли её для нового сервиса:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

Наблюдение: docker stats

Текущее потребление ресурсов смотрят через docker stats — это «top» для контейнеров в реальном времени:

docker stats --no-stream

Вывод:

CONTAINER ID   NAME   CPU %   MEM USAGE / LIMIT   MEM %   NET I/O      BLOCK I/O
a1b2c3d4e5f6   app    12.4%   210MiB / 512MiB     41.0%   1.2MB/3MB    0B/0B

Колонка MEM USAGE / LIMIT сразу показывает, близок ли контейнер к потолку --memory (здесь 210 из 512 МБ — запас есть). Флаг --no-stream снимает один срез и выходит; без него идёт живой поток.

У лимита CPU есть неочевидный побочный эффект — троттлинг. В отличие от памяти, где превышение приводит к убийству процесса, превышение квоты CPU процесс не убивает, а притормаживает: ядро не даёт контейнеру процессорное время до следующего периода. На графиках память и аптайм при этом идеальны, а пользователи жалуются на задержки. Если приложению периодически нужны всплески вычислений (компиляция, обработка пачки данных), слишком жёсткий --cpus превратит короткий пик в долгий «тормоз». Поэтому CPU-лимит подбирают по реальному профилю нагрузки и подтверждают метриками троттлинга, а не ставят «на глаз». Память же, наоборот, чаще задают с небольшим запасом над пиком — внезапный OOM дороже, чем мегабайты резерва.

Как это работает под капотом

Лимиты памяти и CPU реализованы через cgroups v2 ядра Linux. --memory пишется в memory.max соответствующей cgroup, и при превышении срабатывает встроенный OOM-killer именно этой группы — он не трогает процессы вне неё. --cpus транслируется в пару cpu.max (квота и период): «1.5 ядра» — это разрешить тратить 150 мс CPU-времени за каждые 100 мс реального времени. Логи же — это файлы на хосте: для json-file они лежат в /var/lib/docker/containers/<id>/<id>-json.log, и ротацию Docker делает сам, не привлекая системный logrotate.

Частые ошибки

  • Запуск без лимитов в проде. Одна утечка памяти — и OOM-killer кладёт чужой процесс на всём хосте.
  • Путать OOMKilled с багом приложения. Сначала проверьте docker inspect: возможно, коду просто мало выделенного лимита.
  • Забыть ротацию json-file. Болтливый сервис за недели забивает диск, и падает уже весь сервер, а не один контейнер.
  • Ставить --cpus слишком низко. Жёсткое урезание CPU вызывает троттлинг и рост задержек, хотя «по графику» сервис вроде живой.
  • Писать логи в файл внутри контейнера. Их не видно в docker logs, они теряются при пересоздании и не попадают в централизованный сбор. Пишите в stdout/stderr.

Итоги

  • Всегда задавайте --memory и --cpus (или блок deploy.resources) — это страховка хоста.
  • OOMKilled означает «уперлись в лимит памяти», а не обязательно баг; проверяйте docker inspect.
  • Настройте ротацию логов (max-size/max-file), лучше глобально в daemon.json.
  • Логи приложения шлите в stdout/stderr, а не в файл внутри контейнера.
  • docker stats даёт быстрый срез потребления и близости к лимитам.
Проверьте себя
1. Что означает статус OOMKilled: true в выводе docker inspect?
AВ коде приложения есть фатальная ошибка
BКонтейнер превысил лимит --memory, и ядро убило его процесс через OOM-killer
CКонтейнер потерял сеть
DОбраз собран неправильно
2. Зачем для драйвера json-file задают max-size и max-file?
AЧтобы ускорить запись логов
BЧтобы включить шифрование логов
CЧтобы ограничить размер логов и не дать им забить диск (ротация)
DЭто обязательные поля, без них контейнер не стартует
3. Куда правильно направлять логи приложения в контейнере?
AВ файл /var/log/app.log внутри контейнера
BВ stdout/stderr, откуда их подхватывает драйвер логирования Docker
CВ отдельную базу данных, иначе они потеряются
DНикуда — логи в контейнерах не поддерживаются