Когда контейнер «внезапно» перезапускается, приложение падает без стека, а в логах только «Killed», чаще всего виноват OOM (out-of-memory). И почти всегда рядом живут две проблемы: лимиты ресурсов настроены не так, как вы думаете, и диагностика ведётся не там, где нужно. Ниже — практический разбор, как в Docker Compose задавать ограничения по CPU/памяти/PID, настраивать ulimits (например, nofile), и как подтвердить OOM через dmesg на Linux, особенно на cgroup v2.
Ограничения Docker Compose: что реально работает и где
В Compose важно различать два режима, потому что синтаксис похож, а поведение — нет:
- Обычный Docker Engine (команда
docker composeна одном хосте): лимиты задаются напрямую в параметрах сервиса и превращаются в настройки контейнера. - Swarm (команда
docker stack deploy): лимиты живут вdeploy.resources.
Типичная ошибка: написать лимиты в deploy и ожидать их работы на одиночном Docker Engine. В этом сценарии секция deploy обычно игнорируется.
Проверяйте факт применения лимитов
Не доверяйте YAML «на глаз». После запуска подтверждайте, что лимиты реально применились:
docker compose up -d
docker compose ps
docker inspect -f '{{.HostConfig.NanoCpus}} {{.HostConfig.Memory}} {{.HostConfig.PidsLimit}}' $(docker compose ps -q app)
Для быстрой «картинки» по факту потребления ресурсов удобен docker stats:
docker stats --no-stream $(docker compose ps -q app)
Если используете systemd на хосте и хотите навести порядок в иерархии cgroup, пригодится материал: systemd slices и cgroup: как разнести сервисы по группам.
Лимиты CPU: cpus, квоты и типичные грабли
Параметр cpus — самый понятный способ «выдать» контейнеру долю процессора. Он задаёт квоту CPU (по времени), а не жёсткую привязку к конкретным ядрам.
Пример: ограничить сервис до 1.5 CPU
services:
app:
image: your-image
cpus: 1.5
Практический смысл: в среднем контейнер сможет потреблять эквивалент 1.5 ядра суммарно, но при пиках будет «резаться» планировщиком.
Почему «вдруг тормозит» при низком cpus
- Срезаются пики: приложения с короткими burst-нагрузками начинают копить очередь (особенно заметно на PHP/Node воркерах и фоновых джобах).
- Неверные ожидания:
cpus: 1не означает «одно ядро навсегда», это именно квота по времени. - Шум соседей: при конкуренции за CPU даже без лимитов возможны задержки, а с квотой «потолок» становится жёстче.
Если нужно именно привязать к ядрам, используют CPU sets (в Compose встречается как cpuset или cpuset_cpus в зависимости от версии/реализации). Но для большинства веб-сервисов квота через cpus — практичнее и проще сопровождать.

Если хотите, чтобы лимиты и поведение контейнеров были предсказуемыми (особенно по памяти и swap), удобнее держать окружение на выделенных ресурсах. Для таких задач обычно берут VDS, где вы контролируете конфигурацию хоста и политику памяти.
Лимиты памяти: mem_limit, swap и ловушки OOM
С памятью чаще всего ломаются ожидания: «я поставил лимит, значит OOM не будет». OOM будет, просто он станет предсказуемым и локальным (в пределах контейнера), если лимит выбран правильно.
Для одиночного Docker Engine в Compose обычно используют:
mem_limit— лимит RAM для контейнера;memswap_limit— лимит RAM+swap (если swap доступен контейнерам и не отключён политиками хоста);- в Swarm —
deploy.resources.limits.memory.
Пример: жёсткий лимит памяти 512M
services:
app:
image: your-image
mem_limit: 512m
При превышении лимита контейнер может быть убит OOM-механизмом на уровне cgroup. Важно различать два сценария:
- OOM внутри cgroup контейнера: контейнер превысил свой лимит (
mem_limit). - Системный OOM на хосте: закончилась память на сервере, и ядро выбирает жертву среди процессов всего хоста (включая контейнеры).
Что меняется на cgroup v2
На cgroup v2 диагностика становится более «cgroup-ориентированной»: появляется файл memory.events со счётчиками событий (включая OOM) и более прозрачная картина того, что именно произошло.
Если видите «Killed», не ограничивайтесь логами приложения. На cgroup v2 часто быстрее подтвердить причину через сообщения ядра и счётчики cgroup, чем пытаться угадать по поведению сервиса.
Ограничение числа процессов: pids_limit как страховка от размножения
pids_limit — недооценённый, но очень полезный лимит. Он ограничивает количество процессов/тредов (в терминах ядра), которые может создать контейнер. Это помогает локализовать утечки процессов и защищает хост от перегрузки таблицы PID.
Пример: ограничить контейнер до 200 PID
services:
worker:
image: your-worker
pids_limit: 200
Когда лимит выбран уместно, вы получаете контролируемую деградацию: вместо «упал весь сервер» сервис начнёт получать ошибки создания тредов/процессов, и вы быстрее поймёте, где проблема.
Практическая ремарка: Java, Node.js (workers), PHP-FPM, PostgreSQL и некоторые агенты мониторинга создают заметное число тредов. Подбирайте pids_limit по факту: сначала измерьте, потом ограничивайте.
ulimits: nofile и ошибки «too many open files»
Запрос «ulimits nofile» обычно появляется после ошибок вида «too many open files». В контейнерном мире это норма для нагруженных API, прокси, очередей и сервисов с большим числом одновременных соединений.
Пример: поднять лимит nofile
services:
api:
image: your-api
ulimits:
nofile:
soft: 65536
hard: 65536
- soft — рабочий лимит по умолчанию, hard — верхняя граница, до которой процесс может подняться сам.
- Проверьте, что лимит наследует именно тот процесс, который обслуживает нагрузку (особенно если есть init/supervisor).
- Если у хоста низкие системные лимиты, контейнер упрётся в них раньше. Тогда нужно смотреть ограничения systemd и настройки демона Docker.
Как проверить текущие ulimits внутри контейнера
docker exec -it $(docker compose ps -q api) sh -lc 'ulimit -n; ulimit -a'
Если образ distroless и shell отсутствует, проверка через docker exec может быть невозможна. В таких случаях ориентируйтесь на метрики приложения и поведение под нагрузкой, либо используйте отдельный debug-контейнер в той же сети/неймспейсах (в рамках вашей политики безопасности).
Диагностика: как подтвердить, что контейнер умер от OOM
У вас есть три источника правды: Docker (флаги состояния), ядро (логи) и счётчики cgroup.
1) Docker: флаг OOMKilled и код завершения
cid=$(docker compose ps -q app)
docker inspect -f 'State={{.State.Status}} ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} Error={{.State.Error}}' $cid
OOMKilled=true — сильный индикатор OOM внутри лимита контейнера, но при системном OOM на хосте картина может быть менее очевидной. Поэтому всегда смотрим логи ядра.
2) Ядро: dmesg и journalctl -k
Классика поиска: «Out of memory», «oom-kill», «Killed process», а для контейнеров часто встречается «Memory cgroup out of memory».
dmesg -T | grep -E 'Out of memory|Killed process|oom-kill|Memory cgroup out of memory'
journalctl -k -b --no-pager | grep -E 'Out of memory|Killed process|oom-kill|Memory cgroup out of memory'
- Строка про Memory cgroup out of memory обычно означает OOM в пределах лимита cgroup контейнера.
- Строка Killed process даёт PID и имя процесса, часто рядом видно путь cgroup.
3) cgroup v2: memory.events, memory.current, memory.max
Если знаете путь cgroup контейнера в /sys/fs/cgroup, то счётчики OOM можно подтвердить напрямую. В примере ниже угловые скобки показаны как текст (подставьте реальный путь):
cat /sys/fs/cgroup/<path>/memory.current
cat /sys/fs/cgroup/<path>/memory.max
cat /sys/fs/cgroup/<path>/memory.events
В memory.events ищите счётчики oom и oom_kill. Это самый прямой ответ на вопрос «было ли OOM-событие в этой cgroup».
Как отличить: «упёрлись в лимит контейнера» или «на хосте кончилась память»
Это ключевой практический вопрос: лечится по-разному.
Признаки OOM из-за лимита контейнера
- В
docker inspectвидноOOMKilled=true. - В логах ядра есть «Memory cgroup out of memory».
- Остальные сервисы на хосте продолжают работать, нет общей «волны» падений.
Признаки системного OOM на хосте
- OOM случается «волнами»: падают разные контейнеры и/или системные службы.
- В логах ядра видно, что выбиралась жертва среди процессов хоста, а не только одного cgroup.
- Перед падением наблюдается резкий рост потребления памяти и/или активный swap (если он включён).
Если контейнеры стабильно упираются в память на одном узле, проще масштабировать или вынести тяжёлые сервисы на отдельные ресурсы. Для этого часто выбирают виртуальный хостинг под лёгкие сайты и отдельный VDS под контейнеры с гарантированными лимитами и контролем swap.
Практическая стратегия настройки лимитов в Compose
Лимиты — это не про «закрутить гайки», а про предсказуемость. Лучше ограниченно деградировать один сервис, чем устроить системный OOM и уронить весь хост.
Рекомендованный порядок
- Снимите базовую статистику: пики RAM/CPU, число соединений, открытых файлов, процессов/тредов.
- Поставьте мягкие, но реальные лимиты: память с запасом 20–40% от наблюдаемого пика, CPU по вашему SLA,
pids_limitиnofileпо фактической нагрузке. - Подтвердите применённые значения через
docker inspect,docker statsиulimitвнутри контейнера. - Проведите тест отказа: нагрузочный тест и контролируемое давление по памяти. Зафиксируйте, как выглядит инцидент в логах и какие команды вы запускаете.
Базовый compose-фрагмент (CPU + RAM + PIDs + ulimits)
services:
app:
image: your-image
cpus: 1.0
mem_limit: 768m
pids_limit: 300
ulimits:
nofile:
soft: 65536
hard: 65536
Это не «идеальные числа», а каркас. Веб-API чаще упирается в nofile, воркеры очередей — в CPU, а сервисы с утечками — в память и PID.
Падения по OOM часто маскируются политиками перезапуска и healthcheck: контейнер успевает умереть и подняться, а корень причины теряется. Если у вас сложные зависимости, проверьте настройки осознанно: Docker healthcheck и restart policy: как не устроить бесконечный рестарт.

Частые ошибки и быстрые проверки
Ошибка 1: лимиты написаны в deploy, но вы не в Swarm
Симптом: контейнер ест всю память/CPU, хотя «в compose всё ограничили». Лечение: переносите лимиты в параметры сервиса (например, cpus, mem_limit, pids_limit) или действительно включайте Swarm-режим и деплойте стеком.
Ошибка 2: OOM «прячется», потому что смотрят только логи приложения
Симптом: приложение просто «умирает» без ошибок. Лечение: проверяйте docker inspect и сообщения ядра в dmesg/journalctl -k.
Ошибка 3: подняли mem_limit, а упёрлись в дескрипторы
Симптом: сетевые ошибки под нагрузкой, «too many open files», странные отказы подключений. Лечение: подберите ulimits.nofile и убедитесь, что лимит реально виден основному процессу.
Мини-набор команд «на инцидент»
docker compose ps
docker inspect -f 'Exit={{.State.ExitCode}} OOM={{.State.OOMKilled}} Finished={{.State.FinishedAt}}' $(docker compose ps -q app)
dmesg -T | tail -n 200
journalctl -k -b --no-pager | tail -n 200
docker stats --no-stream
Итог
Лимиты в Docker Compose — это про управляемость. Минимальный практический набор: cpus для CPU, mem_limit для памяти, pids_limit как защита от «размножения» и ulimits (особенно nofile) для сетевых/веб-нагрузок. А если что-то упало — подтверждайте OOM через docker inspect и сообщения ядра в dmesg/journalctl -k; на cgroup v2 дополнительно опирайтесь на memory.events, чтобы быстро понять, было ли OOM внутри cgroup.


