Почему приложение внутри контейнера видит память всего сервера и считает её своей
Заходите в контейнер с лимитом памяти 512 МБ, выполняете free -h — а он честно рапортует про 32 гигабайта. Java-процесс внутри стартует, выделяет кучу в несколько гигабайт «про запас», и через пару минут его убивает OOM killer, хотя по логам приложения памяти было полно. Это не баг Docker и не редкая аномалия — это нормальное поведение контейнера, если не знать, откуда берётся расхождение между тем, что реально ограничивает ядро, и тем, что видит процесс внутри /proc.
Содержание
- Симптом: free и /proc/meminfo показывают память хоста, а не лимит контейнера
- Контейнер — это не виртуальная машина: изоляция вместо эмуляции
- Почему /proc/meminfo не «врёт», а просто не в курсе cgroup
- Что случается, когда приложение доверяет /proc, а не своему лимиту
- Практика: как явно указать приложению его реальный лимит
- Как проверить, что контейнер и приложение живут в согласии
Симптом: free и /proc/meminfo показывают память хоста, а не лимит контейнера
Возьмём сервер с 32 ГБ оперативной памяти и запустим на нём контейнер с ограничением в 512 МБ:
docker run -it --memory=512m --memory-swap=512m alpine sh
Внутри контейнера:
/ # free -h
total used free
Mem: 31Gi ... ...
/ # cat /proc/meminfo | head -2
MemTotal: 32xxxxxx kB
MemFree: ...
Оба вывода показывают память всего физического сервера (или виртуальной машины, если контейнер стоит на VPS), а не заданные при запуске 512 МБ. При этом реальный лимит применяется: превысите его — контейнер получит SIGKILL. Узнать об этом лимите через free или /proc/meminfo изнутри контейнера нельзя, они смотрят не туда. Настоящий лимит виден в файлах cgroup:
# cgroup v2 (Ubuntu 22.04+, Debian 11+, AlmaLinux 9)
cat /sys/fs/cgroup/memory.max
536870912
# cgroup v1 (более старые системы)
cat /sys/fs/cgroup/memory/memory.limit_in_bytes
536870912
536870912 байт — это ровно 512 МБ. Вот настоящий лимит контейнера, только лежит он не там, где его ищет большинство приложений по умолчанию.
Контейнер — это не виртуальная машина: изоляция вместо эмуляции
Путаница снимается, если перестать думать про Docker- или LXC-контейнер как про «лёгкую виртуалку». Полноценная виртуализация (KVM, VMware, Hyper-V) эмулирует железо: гостевая ОС получает своё собственное, урезанное представление доступной памяти и CPU. free внутри виртуалки с 2 ГБ покажет 2 ГБ, потому что для гостевого ядра это и есть вся физическая память.
Контейнер устроен иначе: внутри него работает то же самое ядро хоста, отдельного ядра у контейнера нет. Изоляция строится на двух независимых механизмах, которые часто путают:
- Namespaces изолируют *видимость* — какие процессы, точки монтирования, сеть, идентификаторы пользователей видит группа процессов. PID-namespace даёт процессу внутри контейнера PID 1 и скрывает процессы хоста. Но отдельного «memory namespace», который подменял бы для процесса цифры в
/proc/meminfo, в ядре не существует. - cgroups ограничивают *потребление* — сколько CPU, памяти, дискового ввода-вывода может использовать группа. Это квота, которую применяет планировщик и OOM killer ядра, а не подмена видимости.
Namespaces создают иллюзию отдельной системы, а cgroups — это счётчик и рубильник поверх реальных, общих для хоста ресурсов. Подробнее — в разборах namespaces и почему контейнер думает, что он один и как cgroups ограничивают контейнер.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему /proc/meminfo не «врёт», а просто не в курсе cgroup
/proc/meminfo — виртуальный файл, который ядро генерирует на лету, отдавая глобальную статистику памяти всего хоста: MemTotal, MemFree, MemAvailable, кэш страниц. Эта статистика общесистемная — считается один раз для всего ядра, а не отдельно для каждой cgroup.
Mount-namespace даёт процессу внутри контейнера доступ к /proc, но не «переписывает» содержимое meminfo под лимиты конкретной cgroup — файл честно отдаёт то же значение, что увидел бы любой процесс на хосте. Это не дыра в изоляции, а особенность архитектуры: cgroups и namespaces решают разные задачи, синхронизации между «сколько памяти разрешено группе» и «что показывает meminfo» в классической модели просто нет.
nproc, free, /proc/cpuinfo внутри контейнера по умолчанию тоже показывают характеристики хоста целиком. Реальные лимиты нужно смотреть отдельно — через файлы cgroup или снаружи контейнера: docker inspect <container> --format '{{.HostConfig.Memory}}'.
Что случается, когда приложение доверяет /proc, а не своему лимиту
Проблема становится ощутимой, когда приложение внутри контейнера пытается автоматически подстроить внутренние параметры под «доступную память» — и берёт цифру из /proc/meminfo, то есть из памяти всего хоста.
Классический пример — JVM: виртуальная машина Java исторически определяла размер кучи (-Xmx) как долю от видимой ей общей памяти системы. Если JVM видит 32 ГБ, она настроит внутренние структуры и размер кучи, рассчитывая на десятки гигабайт, хотя реальный лимит cgroup для контейнера — 512 МБ или 1 ГБ. Процесс не «знает», что упрётся в стену гораздо раньше: ориентируется на цифру, которую сам прочитал.
Похожая логика встречается и за пределами JVM: некоторые кэши и пулы соединений вычисляют максимальный размер как процент от «общей памяти системы», а раннеры с автоопределением числа воркеров по объёму памяти могут посчитать, что можно поднять в разы больше процессов, чем позволит cgroup.
Итог одинаковый: приложение резервирует память исходя из завышенного представления о лимите. Как только суммарное потребление процессов контейнера превышает memory.max из cgroup, в дело вступает cgroup-scoped OOM killer — ядро убивает процесс внутри этой конкретной cgroup, даже если на хосте в целом памяти навалом. Со стороны это выглядит как «сервер не нагружен, а приложение падает по памяти» — похожий случай разобран в статье лимит в 512 МБ убивал контейнер на пятый день, а общий механизм выбора жертвы — в материале как ядро выбирает жертву OOM killer.
Практика: как явно указать приложению его реальный лимит
Раз автоопределение через /proc/meminfo ненадёжно внутри контейнера, устойчивый подход один — задавать лимиты приложению явно.
JVM. Указывайте -Xmx и -Xms вручную, исходя из реального лимита cgroup:
java -Xms256m -Xmx400m -jar app.jar
-Xmx держите заметно ниже лимита cgroup, оставляя запас под off-heap память (метаспейс, стеки потоков, буферы GC) — точную цифру подбирайте под конкретное приложение, а не по общему правилу.
Node.js. Ограничивайте кучу V8 явно:
node --max-old-space-size=384 server.js
Python-воркеры (Gunicorn, Celery) — задавайте число процессов и лимит на процесс вручную, исходя из реального лимита контейнера, а не из объёма памяти хоста.
Entrypoint-скрипт. Если нужно прочитать лимит программно, берите значение из cgroup, а не из /proc/meminfo:
#!/bin/sh
if [ -f /sys/fs/cgroup/memory.max ]; then
LIMIT=$(cat /sys/fs/cgroup/memory.max) # cgroup v2
else
LIMIT=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes) # cgroup v1
fi
echo "Реальный лимит памяти контейнера: $LIMIT байт"
Если лимит вообще не задан (запуск без --memory), значение будет строкой max (cgroup v2) или огромным числом (cgroup v1, фактически «без ограничения») — скрипт должен уметь отличать «лимита нет» от «лимит есть и он такой-то».
Часть современных рантаймов уже умеет читать этот файл автоматически, но полагаться на это вслепую не стоит: поведение отличается между версиями и настройками, а цена ошибки — случайный OOM в проде. Явно заданный лимит в конфиге приложения — самый надёжный вариант.
Как проверить, что контейнер и приложение живут в согласии
Снаружи контейнера смотрите на реальный лимит и текущее потребление:
docker inspect <container> --format '{{.HostConfig.Memory}}'
docker stats <container> --no-stream
docker inspect <container> --format '{{.State.OOMKilled}}'
docker stats показывает живое потребление и лимит рядом — если MEM USAGE / LIMIT близко к 100%, приложение подходит к границе cgroup, а не хоста. OOMKilled: true означает, что контейнер убит cgroup-OOM killer из-за превышения заданного лимита — это отдельная история от «на сервере закончилась память». В логе ядра хоста (не внутри контейнера) есть и сама запись про убийство процесса:
dmesg -T | grep -i "killed process"
Такую проверку стоит делать на этапе тестирования нового образа, а не после первого падения в проде — общий подход к настройке лимитов cgroup описан в материале ограничения cgroups для контейнера.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему nproc тоже показывает все ядра хоста, а не лимит CPU?
По той же причине, что и с памятью: nproc читает глобальную информацию ядра, а не лимит cgroup cpu.max. Безопаснее задавать число рабочих потоков явно, а не полагаться на автоопределение.
Если задать -Xmx слишком близко к лимиту cgroup, поможет ли это избежать OOM?
Нет, скорее наоборот. JVM использует память не только под кучу — метаспейс, стеки потоков, буферы GC и JIT тоже считаются в общий лимит cgroup. Впритык — риск, что OOM killer сработает даже при нормальной работе приложения, запас нужен обязательно.
Можно ли заставить /proc/meminfo показывать лимит cgroup, а не память хоста?
В базовой конфигурации Docker и LXC — нет, файл остаётся общесистемным. Есть сторонние обёртки вроде LXCFS, которые подменяют содержимое /proc/meminfo значениями из cgroup, но это дополнительная настройка, а не поведение по умолчанию.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →