Сколько контейнеров реально влезет на машину: считаем не по RAM, а по лимитам ядра
Планируя плотность контейнеров на сервере, почти все считают одинаково: делят объём RAM на лимит памяти одного контейнера, сверяют с числом vCPU — и получают красивую цифру «на этой машине влезет 200 контейнеров». А потом на сотом-полутора-сотом контейнере что-то начинает падать без видимой связи с памятью или процессором: не создаются новые контейнеры, перестают срабатывать file watcher'ы, обрываются сетевые соединения. Причина обычно не в ресурсах, которые вы считали, а в лимитах ядра Linux, которые общие на весь хост и с плотностью контейнеров никак не масштабируются автоматически.
Содержание
- Почему потолок плотности — не там, где вы его ищете
- Пространства имён: max_user_namespaces
- inotify: watches и instances съедают лимит незаметно
- PID: kernel.pid_max и cgroup pids limit
- Сеть: veth-интерфейсы, ARP-таблица и conntrack
- Файловые дескрипторы: системный потолок и как его поднять
- Как посчитать реальный потолок плотности контейнеров
Почему потолок плотности — не там, где вы его ищете
RAM и CPU — это ресурсы, которые cgroups честно делят между контейнерами: выдали лимит, контейнер его исчерпал — получил OOM или throttling, но сосед не пострадал. С рядом других ресурсов ядра всё устроено иначе: они не делятся автоматически между контейнерами, а расходуются из одного общего пула на весь хост. Пространства имён, inotify-вотчеры, PID, записи в таблице conntrack, файловые дескрипторы — это системные счётчики, у которых есть жёсткий потолок независимо от того, сколько у вас памяти и ядер.
Проблема в том, что эти лимиты почти никогда не показываются в мониторинге ресурсов контейнеров — Docker Desktop, Portainer, cAdvisor рисуют графики RAM и CPU, но не рисуют графики использования user namespaces или занятых PID. Поэтому первый симптом исчерпания такого лимита — это не плавная деградация, а внезапный отказ: docker run падает с невнятной ошибкой, хотя памяти и ядер видимо в избытке. Разберём по порядку, какие лимиты ограничивают плотность контейнеров помимо памяти, как посмотреть их текущее значение и как поднять с запасом, а не только на один инцидент вперёд.
Пространства имён: max_user_namespaces
Каждый контейнер Docker или LXC изолируется через набор пространств имён ядра: PID, mount, net, UTS, IPC, cgroup, а при rootless-режиме или user-remapping — ещё и user namespace. На создание каждого такого набора ядро тратит счётчик, ограниченный параметром user.max_user_namespaces (в некоторых дистрибутивах — kernel.max_user_namespaces в устаревшем написании). Посмотреть текущее значение:
sysctl user.max_user_namespaces
cat /proc/sys/user/max_user_namespaces
На современных дистрибутивах значение по умолчанию обычно исчисляется десятками тысяч и на глаз кажется огромным запасом. Но проблема вылезает не от количества контейнеров как такового, а от вложенности: если внутри контейнеров запускаются вложенные неймспейсы (rootless Docker-in-Docker, sandboxing в CI-раннерах, Kubernetes с включённым user namespace remapping для подов), каждый такой уровень вложенности отъедает от того же общего счётчика хоста. На практике это редко становится первым ограничением на обычном хосте с плоскими контейнерами без вложенности, но стоит проверить у себя, а не полагаться на общее ощущение «там много».
Поднять лимит:
sysctl -w user.max_user_namespaces=250000
echo 'user.max_user_namespaces = 250000' >> /etc/sysctl.d/99-containers.conf
sysctl --system
Важный нюанс: сам по себе высокий max_user_namespaces не гарантирует, что namespace создастся — если упёрлись в память ядра (kernel memory, не user-space RAM) под структуры namespace, увеличение только этого параметра не поможет. Но на подавляющем большинстве серверов до этого дело не доходит раньше, чем в другие лимиты из этой статьи.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверinotify: watches и instances съедают лимит незаметно
Это один из самых коварных лимитов, потому что упирается в него не сам факт запуска контейнера, а то, что происходит внутри: файловые сборщики логов (Filebeat, Promtail), dev-серверы с hot reload (webpack, vite, nodemon), IDE-серверы, синхронизация конфигов через inotify — все они регистрируют вотчеры на файлы и директории. У ядра лимит на такие вотчеры общий на весь хост и считается по двум параметрам:
sysctl fs.inotify.max_user_instances
sysctl fs.inotify.max_user_watches
max_user_instances — сколько независимых inotify-дескрипторов (условно, «сколько разных процессов слушают файлы») может завести один пользователь (а внутри контейнера все процессы обычно работают от одного UID хоста, если не настроен user namespace remapping). max_user_watches — суммарное число отслеживаемых файлов и директорий на этого пользователя. Если запускаете 150 контейнеров, и в каждом крутится агент логирования, который слушает директорию с логами через inotify, — это 150 инстансов и потенциально тысячи вотчеров, если директории с логами большие.
Посмотреть, кто и сколько уже использует:
for f in /proc/*/fd/*; do readlink -f "$f" 2>/dev/null; done | grep -c inotify
find /proc/*/fdinfo -type f 2>/dev/null | xargs grep -l inotify 2>/dev/null | wc -l
Поднять лимиты с запасом на плотность, а не «на один сервис»:
cat >> /etc/sysctl.d/99-containers.conf <<'EOF'
fs.inotify.max_user_instances = 8192
fs.inotify.max_user_watches = 1048576
EOF
sysctl --system
Значения по умолчанию сильно различаются между дистрибутивами и версиями ядра — где-то max_user_watches стоит на уровне нескольких тысяч, где-то уже на сотнях тысяч. Не берите цифры из этой статьи как готовый рецепт — проверьте текущее значение у себя командой выше и решайте, во сколько раз его нужно увеличить исходя из реального числа файлов, которые слушают ваши контейнеры.
PID: kernel.pid_max и cgroup pids limit
Здесь два разных лимита, которые часто путают. Первый — kernel.pid_max, системный потолок на количество PID, которые вообще может выдать ядро одновременно, на весь хост целиком:
sysctl kernel.pid_max
cat /proc/sys/kernel/pid_max
Каждый процесс и каждый поток (thread) внутри контейнера отъедает от этого же общего пула — контейнеры не имеют отдельного пространства PID с точки зрения этого лимита, даже если у каждого свой PID namespace: изнутри контейнера PID 1 видит себя как «первый процесс», но ядро всё равно расходует запись из общего pid_max. Если у вас 300 контейнеров, и в каждом фоновый воркер плюс несколько потоков плюс шелл для отладки — легко набегает несколько тысяч PID суммарно, и на дефолтных значениях pid_max на некоторых системах это уже заметная доля лимита.
Второй лимит — per-cgroup pids.max, который вы сами (или Docker/LXC по умолчанию) выставляете на каждый контейнер отдельно:
docker run --pids-limit=200 myimage
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/pids.max
cat /sys/fs/cgroup/system.slice/docker-<id>.scope/pids.current
Это защита от форк-бомбы внутри одного контейнера — она не спасает от исчерпания общего kernel.pid_max, если вы просто умножаете число контейнеров. Поднять системный потолок:
sysctl -w kernel.pid_max=4194304
echo 'kernel.pid_max = 4194304' >> /etc/sysctl.d/99-containers.conf
На 64-битных системах верхняя граница pid_max может быть поднята существенно выше значения по умолчанию — конкретный максимум зависит от версии ядра, проверяйте, до какого значения параметр реально принимается на вашей системе, прежде чем закладывать его в расчёт плотности.
Сеть: veth-интерфейсы, ARP-таблица и conntrack
Каждый контейнер в bridge-режиме получает пару veth-интерфейсов (один в контейнере, один на хосте). Жёсткого системного лимита на количество veth-пар как такового нет, но у них есть побочные эффекты, которые сами по себе становятся потолком:
- Таблица соседей (ARP/neighbor) — при большом числе veth-интерфейсов на одном bridge растёт число записей ARP. Лимит контролируется
net.ipv4.neigh.default.gc_thresh1/2/3(для IPv6 — аналогичныеnet.ipv6.neigh.default.*). Упирание в него выглядит как «контейнеры на bridge иногда не видят друг друга по сети» — записи вытесняются раньше времени. - Таблица conntrack — каждое отслеживаемое соединение (включая NAT для контейнеров за bridge) занимает запись в общей на хост таблице
nf_conntrack. Лимит и текущее заполнение:
sysctl net.netfilter.nf_conntrack_max
cat /proc/sys/net/netfilter/nf_conntrack_count
При росте числа контейнеров растёт и число одновременных соединений — включая внутренние health-check'и, keep-alive к базам, вебхуки. Если 200 контейнеров держат по 20-30 постоянных соединений каждый, это уже несколько тысяч записей conntrack без единого внешнего клиента. Поднять лимит и таблицу соседей:
cat >> /etc/sysctl.d/99-containers.conf <<'EOF'
net.netfilter.nf_conntrack_max = 1048576
net.ipv4.neigh.default.gc_thresh1 = 4096
net.ipv4.neigh.default.gc_thresh2 = 8192
net.ipv4.neigh.default.gc_thresh3 = 16384
EOF
sysctl --system
Конкретные значения зависят от объёма RAM хоста (conntrack-запись занимает память ядра) и от реального профиля соединений ваших сервисов — считайте от текущего nf_conntrack_count под нагрузкой, а не от абстрактного «побольше».
Файловые дескрипторы: системный потолок и как его поднять
Помимо привычного ulimit -n для отдельного процесса, есть системный лимит на все файловые дескрипторы хоста разом — fs.file-max. Он ограничивает суммарное число открытых файлов (включая сокеты) для всех процессов на машине одновременно:
sysctl fs.file-max
cat /proc/sys/fs/file-nr
Вывод file-nr — это три числа: выделено сейчас, свободно в кэше, максимум. Если каждый из 300 контейнеров держит по паре сотен открытых файлов и сокетов (что для веб-сервиса с пулом соединений к базе и логированием совершенно нормально), суммарный расход легко достигает десятков тысяч дескрипторов — и это без учёта того, что сам хост-процесс Docker/containerd тоже держит дескрипторы на каждый контейнер отдельно. Отдельно от системного лимита есть лимит на процесс через ulimit — и оба должны быть согласованы: поднятие только системного fs.file-max не поможет, если внутри контейнера процесс упирается в собственный nofile.
sysctl -w fs.file-max=2097152
echo 'fs.file-max = 2097152' >> /etc/sysctl.d/99-containers.conf
Для Docker лимит на процессы внутри контейнера задаётся через --ulimit nofile=65536:65536 при запуске или в daemon.json через секцию default-ulimits, чтобы не прописывать флаг для каждого контейнера отдельно.
Как посчитать реальный потолок плотности контейнеров
Правильный расчёт — это не деление RAM на лимит контейнера, а таблица из нескольких строк, где по каждому ресурсу считается свой потолок, а итоговая плотность — минимум из всех строк. Общая формула для каждой строки:
max_containers_by_resource = (системный_лимит_ресурса − системный_резерв) / расход_ресурса_на_один_контейнер
Где «системный резерв» — это то, что уже потребляет хост-система без единого контейнера (сам Docker daemon, systemd, мониторинг, SSH-сессии), а «расход на контейнер» — измеренное (не предполагаемое) значение с уже запущенного у вас образа. Пример таблицы для условного хоста — цифры ниже иллюстративные, у вас они получатся другими, важен сам метод:
| Ресурс | Как посмотреть лимит | Как измерить расход на 1 контейнер | |
|---|---|---|---|
| RAM | free -m, лимит cgroup | docker stats по вашему образу под типичной нагрузкой | |
| CPU | nproc, cpu.max cgroup | реальная утилизация из docker stats / cgroup cpu.stat | |
| inotify instances | sysctl fs.inotify.max_user_instances | число процессов с открытым inotify внутри контейнера | |
| inotify watches | sysctl fs.inotify.max_user_watches | число файлов/директорий, которые слушает контейнер | |
| PID | sysctl kernel.pid_max минус текущий расход хоста | среднее число процессов+потоков контейнера из pids.current | |
| conntrack | sysctl net.netfilter.nf_conntrack_max | среднее число активных соединений контейнера под нагрузкой | |
| file descriptors | sysctl fs.file-max | пиковое число открытых fd контейнера (`ls /proc/<pid>/fd \ | wc -l`) |
Считаете каждую строку отдельно, берёте минимум — это и есть реальный потолок плотности, а не тот, что получается из деления RAM. На практике для лёгких сервисов (статический сайт, простой API без файловых вотчеров) первым лимитом обычно оказывается память или CPU — этот случай ничем не отличается от привычных расчётов. Но для сервисов с активным I/O, логированием через файловые вотчеры или большим числом сетевых соединений первым в потолок часто упирается именно inotify, conntrack или PID — задолго до того, как закончится RAM. Разница между «плотностью по RAM» и «реальной плотностью» на таких профилях нагрузки может быть в разы, и узнаёте вы об этом не из графика мониторинга, а из падающих docker run в проде.
Практический порядок действий: разверните ожидаемый профиль нагрузки на 10-20 контейнерах, снимите фактический расход по каждой строке таблицы, экстраполируйте до планируемого числа контейнеров и сравните с системными лимитами хоста — с запасом хотя бы в 30-40%, а не впритык, потому что расход по некоторым ресурсам (особенно conntrack и inotify) неравномерен и скачет при пиковой нагрузке, а не держится на среднем уровне.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
У меня хватает и RAM, и CPU с большим запасом — зачем вообще проверять эти лимиты?
Затем, что они не связаны с RAM и CPU напрямую — можно иметь свободными 80% памяти и сотни свободных ядер и всё равно упереться в fs.inotify.max_user_watches или nf_conntrack_max, потому что это отдельные, независимо ограниченные ресурсы ядра.
Можно ли поднять эти лимиты до бесконечности «на всякий случай»?
Технически параметры допускают очень большие значения, но каждая запись (conntrack, inotify watch, namespace) занимает память ядра — при экстремально завышенных лимитах вы просто переносите риск с «упёрлись в лимит» на «ядро съело больше памяти, чем рассчитывали», особенно под атакой или аномальной нагрузкой. Разумный запас — в разы, а не на порядки.
Отличаются ли эти лимиты между Docker и LXC/LXD?
Сами лимиты ядра общие для любых контейнеров на этом хосте — они не зависят от рантайма. Разница в том, что LXC-контейнеры (в отличие от типичного Docker-сервиса) чаще запускают полноценный init-процесс с несколькими системными сервисами внутри, из-за чего расход PID и file descriptors на один LXC-контейнер обычно выше, чем на легковесный Docker-контейнер с одним процессом.
Как понять, что я уже упёрся именно в лимит ядра, а не в баг приложения?
Смотрите dmesg и системный журнал (journalctl -k) — большинство таких отказов ядро логирует явно: inotify_add_watch: no space left on device при исчерпании inotify, fork: retry: Resource temporarily unavailable при исчерпании PID, nf_conntrack: table full, dropping packet при переполнении conntrack.
Нужно ли поднимать эти лимиты, если контейнеров немного (десятки, не сотни)?
Обычно нет — дефолтные значения на современных дистрибутивах рассчитаны с запасом на десятки контейнеров с обычным профилем нагрузки. Отдельно проверять стоит, если хотя бы один контейнер активно использует file watchers (dev-инструменты, синхронизация) или держит много одновременных соединений — тогда лимит может исчерпаться и при небольшом числе контейнеров.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →