Ресурсы и логи в проде
Без лимитов один контейнер способен съесть всю память и положить хост, а логи — забить диск; учимся ограничивать ресурсы и управлять логами.
Лимиты ресурсов — потолки на память и 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даёт быстрый срез потребления и близости к лимитам.