Почему контейнер убит по памяти, когда на сервере её ещё половина свободна
Контейнер падает с OOMKilled, вы заходите на хост и видите в free -h, что свободно 20-30 гигабайт из 64. Логика подсказывает, что памяти хватает, а значит дело в чём-то другом — сети, диске, баге в приложении. Но причина обычно ровно та, что написана в статусе: контейнеру действительно не хватило памяти, просто не той, что вы смотрите. У контейнера есть собственный, гораздо более тесный потолок, и ядро следит именно за ним, а не за общей картиной на сервере.
Содержание
- Два независимых механизма памяти в одном ядре
- Почему «половина сервера свободна» не имеет значения
- Что именно считает memory.max
- Reclaim перед убийством: ядро сначала пытается разгрузить cgroup
- memory.high и memory.max: троттлинг — это не то же самое, что убийство
- Как отличить cgroup OOM от общесистемного OOM killer
- Практические выводы для настройки лимитов контейнеров
Два независимых механизма памяти в одном ядре
В Linux одновременно работают два разных сторожа памяти, и их легко перепутать, потому что оба заканчиваются одним и тем же SIGKILL.
Первый — общесистемный OOM killer. Он просыпается, когда ядру больше негде взять физическую страницу для всей системы: закончилась и оперативная память, и swap (если он есть), а reclaim (сброс страничного кеша, выгрузка анонимных страниц) не успевает или не может высвободить нужный объём. Это событие уровня всего хоста: ядро перебирает процессы во всех cgroups без разбора и выбирает жертву по эвристике oom_score.
Второй — memcg OOM, то есть OOM killer конкретной cgroup памяти (memory controller). Он не имеет отношения к тому, сколько памяти свободно на хосте в целом. У него своя, гораздо более простая задача: не дать группе процессов (контейнеру) превысить лимит, который ей назначили через memory.max (cgroup v2) или memory.limit_in_bytes (cgroup v1). Как только суммарное потребление процессов внутри этой cgroup упирается в лимит и reclaim внутри самой группы не спасает, ядро убивает процесс именно в ней — независимо от того, что происходит в соседних cgroups и на хосте в целом.
Контейнеры (что Docker, что LXC, что containerd под Kubernetes) — это в первую очередь именно cgroup плюс namespaces. Когда вы пишете docker run --memory=512m, вы создаёте для процесса отдельную cgroup с memory.max=536870912 и всё, что происходит дальше, — это работа второго механизма, memcg OOM, а не общесистемного.
Подробнее о том, как cgroups вообще ограничивают контейнер по CPU, памяти и I/O, разобрано в статье про иерархию cgroups и что происходит при упоре в лимит — здесь же сосредоточимся именно на памяти и на том, почему свободная память хоста тут ни при чём.
Почему «половина сервера свободна» не имеет значения
Представьте это не как один большой бак с водой (64 ГБ памяти сервера), а как здание, где у каждой квартиры (cgroup) свой собственный счётчик и свой лимит потребления — и коммунальная служба (ядро) отключает именно ту квартиру, которая исчерпала свой лимит, даже если в здании в целом света с избытком. Соседние квартиры с лимитом повыше могут жечь сколько угодно — вашей это не поможет, потому что счётчик у неё свой.
Технически это работает так: у ядра есть глобальный аллокатор страниц, который видит всю физическую память хоста, и есть memcg-аккаунтинг поверх него, который следит, кто из cgroups сколько «занял». Когда процесс внутри контейнера делает аллокацию (malloc, рост стека, чтение файла в page cache от его имени), ядро одновременно с выделением страницы увеличивает счётчик потребления той cgroup, к которой принадлежит процесс. Если счётчик после аллокации превышает memory.max — запускается reclaim строго в рамках этой cgroup, а если он не помогает — memcg OOM killer убивает процесс в этой же группе. Ядро на этом этапе не смотрит, сколько страниц свободно глобально: физическая страница вполне может быть доступна, но лимит именно этой группы уже исчерпан.
Отсюда и типичная путаница: free -h на хосте показывает суммарную картину по всем cgroups сразу — памяти, которую съели другие контейнеры, память под page cache, действительно свободную память. Контейнер, который упёрся в свой memory.max, к этой общей картине прямого отношения не имеет: у него узкий персональный лимит, и с точки зрения ядра он «переполнен», даже когда сервер в целом простаивает по памяти.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверЧто именно считает memory.max
memory.current — счётчик, с которым ядро сравнивает memory.max, — это не просто RSS процессов внутри cgroup. Туда попадает:
- Анонимная память — куча (heap), стек, всё, что процесс аллоцировал через
malloc/mmap(MAP_ANONYMOUS)и не может выгрузить, кроме как в swap. - Page cache, привязанный к этой cgroup — файловые страницы, которые процессы контейнера прочитали или записали. Первый процесс, который затронул страницу файла, «привязывает» её к своей cgroup для целей аккаунтинга (в cgroup v2 это устроено немного тоньше через владение и переприписывание при исключительном доступе, но в первом приближении: чей процесс читал файл — того и cache).
- Память ядра, привязанная к cgroup — при включённом kmem-аккаунтинге сюда попадают slab-объекты, структуры сетевых сокетов и часть другой памяти ядра, выделенной по запросу процессов из этой группы.
- tmpfs и shared memory, которыми пользуются процессы контейнера.
Посмотреть текущее потребление и лимит для конкретного контейнера можно напрямую в cgroupfs. Для Docker с systemd cgroup driver (сейчас это дефолт на большинстве дистрибутивов) путь выглядит так:
# найти id контейнера
docker inspect --format '{{.Id}}' my-container
# посмотреть лимит и текущее потребление
cat /sys/fs/cgroup/system.slice/docker-<container_id>.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-<container_id>.scope/memory.current
# детальная раскладка: анонимная память, page cache, kernel-память
cat /sys/fs/cgroup/system.slice/docker-<container_id>.scope/memory.stat
Если драйвер cgroupfs (не systemd), путь будет вида /sys/fs/cgroup/docker/<container_id>/memory.current — какой драйвер используется, видно в docker info в поле Cgroup Driver.
Важный практический вывод отсюда: контейнер, который активно читает большие файлы (логи, датасеты, бэкапы) или пишет много данных, может упереться в свой лимит памяти не из-за утечки в коде, а из-за накопленного page cache, который ядро посчитало «его». Такой рост в норме безобиден — при нехватке места cache reclaim'ится в первую очередь, — но если сама cgroup при этом активно аллоцирует и анонимную память, reclaim может не успевать, и в дело вступает OOM killer именно этой cgroup.
Reclaim перед убийством: ядро сначала пытается разгрузить cgroup
Прежде чем убить процесс, ядро не сдаётся сразу при первом превышении лимита. Внутри cgroup срабатывает direct reclaim — попытка вытолкнуть часть занятой памяти:
- Сначала сбрасывается reclaimable page cache, привязанный к этой cgroup, — те файловые страницы, которые можно безопасно выгрузить, потому что данные всё равно есть на диске.
- Если в cgroup разрешён swap (
memory.swap.maxбольше нуля) и на хосте есть swap-пространство, часть анонимных страниц может быть вытеснена в swap. - Если после этих шагов потребление всё ещё выше
memory.max— и упасть больше некуда — вызывается memcg OOM killer, который выбирает жертву внутри группы (по умолчанию — процесс с наибольшим вкладом в потребление, либо всю группу целиком, если включена настройкаmemory.oom.group).
Именно поэтому реакция на превышение лимита не всегда мгновенная: несколько секунд контейнер может «мигать» повышенной задержкой (idle-процессы тормозятся из-за агрессивного reclaim), прежде чем случится реальный kill. Если вы видите скачки latency перед падением контейнера — это обычно и есть тот самый reclaim, который пытается отсрочить неизбежное.
memory.high и memory.max: троттлинг — это не то же самое, что убийство
У cgroup v2 memory controller есть несколько порогов, а не один, и путать их — частая ошибка при настройке лимитов:
| Параметр | Что делает | Что видит приложение |
|---|---|---|
memory.min | Гарантированный минимум, который reclaim не тронет ни при каких обстоятельствах | Ничего — это защита, а не лимит |
memory.low | Мягкая защита: reclaim старается не трогать эту cgroup, если есть куда забрать память у других | Обычно ничего, при сильном давлении — небольшой reclaim |
memory.high | Мягкий потолок: при превышении ядро начинает агрессивно throttlить и reclaim'ить процессы группы, притормаживая их | Заметное замедление (лишние паузы на аллокациях), но без kill |
memory.max | Жёсткий потолок: превышение ведёт к reclaim, а при неудаче — к OOM kill | SIGKILL для процесса или всей группы |
Docker исторически даёт доступ не ко всем этим ручкам напрямую. Флаг --memory соответствует жёсткому лимиту (memory.max в v2, memory.limit_in_bytes в v1). Флаг --memory-reservation задаёт более мягкий порог, ближе по смыслу к memory.low/memory.high в v2 или к memory.soft_limit_in_bytes в v1 — точное поведение отличается между версиями Docker и cgroup-драйвером, поэтому если вы полагаетесь именно на это поведение, стоит проверить его на своей версии, а не считать универсальным.
Практический смысл в том, что резкий OOMKilled — не единственный доступный сценарий деградации. Если выставить memory.high заметно ниже memory.max (там, где это доступно — например, напрямую через cgroupfs или через systemd-юнит для LXC-контейнера), можно получить контролируемое замедление вместо внезапной смерти процесса, и это время использовать на алерт и ручное вмешательство.
Как отличить cgroup OOM от общесистемного OOM killer
Снаружи оба варианта иногда выглядят одинаково — процесс контейнера получает SIGKILL, docker inspect показывает "OOMKilled": true. Но источник события разный, и для диагностики это важно.
Docker (через containerd/runc) узнаёт о memcg OOM, слушая события самой cgroup: в cgroup v1 это eventfd на memory.oom_control, в cgroup v2 — счётчик oom_kill в файле memory.events:
cat /sys/fs/cgroup/system.slice/docker-<container_id>.scope/memory.events
# low 0
# high 42
# max 187
# oom 3
# oom_kill 3
Ненулевой и растущий oom_kill — прямое доказательство, что убивал именно memcg OOM killer этой конкретной cgroup, а не общесистемный. Отличить причину также помогает dmesg/journalctl -k: в сообщении ядра о срабатывании OOM явно указана cgroup, в контексте которой произошло убийство:
dmesg -T | grep -i "oom-kill\|killed process"
# ...Memory cgroup out of memory: Killed process 48213 (node) ... task_memcg=/system.slice/docker-<id>.scope
Если в этой строке нет task_memcg с путём конкретного контейнера, а убийство описано как ответ на нехватку памяти во всей системе — сработал общесистемный OOM killer, и это значит, что реальная проблема шире одного контейнера: вероятно, хост в целом близок к исчерпанию памяти, и стоит смотреть на общую картину, а не на лимит этого сервиса. Подробнее о том, как ядро в общесистемном случае выбирает жертву среди всех процессов хоста, разобрано в статье про то, как OOM killer выбирает жертву.
Важный нюанс: когда общесистемный OOM killer выбирает жертву внутри какой-то cgroup (например, процесс контейнера, чей лимит вовсе не был исчерпан), счётчик oom_kill этой cgroup всё равно увеличивается — ядро атрибутирует kill той группе, в которой жертва состояла. Поэтому по одному oom_kill нельзя стопроцентно понять инициатора; за этим и нужен dmesg с явной пометкой — Memory cgroup out of memory (memcg) или обычный Out of memory: Killed process (хост).
Практические выводы для настройки лимитов контейнеров
Из механизма выше следует несколько конкретных решений, которые стоит применять при выставлении --memory в Docker или lxc.cgroup2.memory.max в LXC:
- Сначала измерьте, потом ограничивайте. Прежде чем резать лимит, запустите сервис без ограничения (или с заведомо большим лимитом) под реальной нагрузкой и посмотрите пиковое потребление через
docker statsилиcat memory.currentв моменты нагрузки — не через средние значения, а через пики. - Закладывайте запас на всплески, а не на средний режим. GC-паузы в JVM, компрессия/буферизация в Node.js, обработка больших запросов — всё это даёт кратковременные скачки RSS, которые легко пропустить, если смотреть только на «обычное» потребление.
- Настраивайте память приложения отдельно от лимита cgroup. Раньше рантаймы вроде JVM или Node.js по умолчанию ориентировались на память всего хоста, а не на лимит своей cgroup, и заводили heap больше, чем разрешал контейнер — GC срабатывал слишком поздно, и cgroup убивала процесс раньше, чем у рантайма появлялся повод почистить память сам. Современные версии cgroup-aware по умолчанию, но явный heap ниже лимита контейнера (
-XX:MaxRAMPercentage,--max-old-space-size) надёжнее, чем полагаться на автоопределение. - Не ставьте лимит впритык. Если пиковое потребление процесса — 400 МБ, лимит в 420 МБ — это гарантированные периодические
OOMKilledот малейшего колебания нагрузки, а не запас прочности. - Мониторьте
memory.events/oom_killконкретных контейнеров, а не толькоfree -hхоста. Общая картина сервера ничего не говорит о том, насколько близко конкретный сервис подошёл к своему собственному потолку. - Держите в уме суммарный оверкоммит. Если сумма
--memoryвсех контейнеров на хосте превышает физическую память сервера — это допустимая практика (не все сервисы упираются в свой лимит одновременно), но именно в этом случае может сработать уже общесистемный OOM killer, если сразу несколько контейнеров одновременно вырастут к своим лимитам. Тема разобрана подробнее в статье про оверкоммит памяти в Linux. - Для файловых воркеров учитывайте page cache отдельно. Если сервис активно читает/пишет большие файлы, часть его лимита неизбежно уйдёт под cache — либо закладывайте это в размер лимита, либо принудительно ограничивайте буферизацию на уровне приложения.
Отправная точка для настройки лимитов в Docker Compose и то, какие цифры разумны для типовых сервисов, разобраны в статье про лимиты CPU и памяти в Docker.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Если увеличить память сервера, поможет ли это контейнеру с фиксированным --memory?
Нет, пока вы не поднимете сам лимит контейнера. Больше памяти на хосте не расширяет cgroup конкретного контейнера — она лишь даёт больше пространства для роста другим cgroups и снижает риск общесистемного OOM.
Можно ли вообще не ставить лимит памяти контейнеру?
Технически да, но тогда единственной защитой хоста остаётся общесистемный OOM killer, который сработает поздно (когда реально нечего выделять) и выберет жертву без учёта того, что это «просто один из контейнеров» — под удар может попасть что угодно на хосте, включая критичные сервисы.
Почему docker stats иногда показывает потребление выше, чем ожидалось от кода приложения?
Скорее всего, разница — это page cache, привязанный к cgroup контейнера. Он не всегда является проблемой: reclaim обычно вытесняет его первым при нехватке места, но он всё равно учитывается в memory.current относительно memory.max.
OOMKilled — это всегда вина приложения (утечка, слишком большой heap)?
Не всегда. Это может быть накопленный page cache, всплеск на старте (прогрев кеша, загрузка модели), либо слишком тесный лимит относительно реального рабочего профиля — сначала стоит посмотреть memory.stat, а не сразу искать утечку в коде.
В LXC механизм такой же, как в Docker?
Да, LXC-контейнеры используют тот же memory controller cgroups — лимит задаётся через lxc.cgroup2.memory.max в конфиге контейнера (или lxc.cgroup.memory.limit_in_bytes для v1), и убийство при превышении работает по тому же принципу reclaim-затем-kill внутри группы.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →