6.13. Resource limits и память Python
Цели
После этого материала вы сможете:
- задать лимиты памяти, CPU и числа процессов и объяснить, что делает каждый;
- объяснить, почему Python не «видит» лимит и какие библиотеки из-за этого ошибаются;
- отличить OOM kill от других причин кода
137; - обнаружить CPU throttling — отказ, который не проявляется как ошибка;
- объяснить, почему RSS не уменьшается после освобождения объектов;
- подобрать лимиты по измерению, а не наугад.
Предварительные знания
- 2.4. cgroups — механизм;
- 4.6. Exit codes;
- 6.11. Worker processes — расчёт числа процессов.
Ключевые термины
| Термин | Объяснение |
|---|---|
memory.max | Жёсткий лимит cgroup v2; превышение — OOM kill |
memory.high | Мягкий лимит: процесс притормаживается, но не убивается |
memory.current | Текущее потребление cgroup |
RSS | Resident Set Size — физическая память процесса |
throttling | Приостановка процесса при исчерпании квоты CPU |
pymalloc | Аллокатор CPython для объектов до 512 байт |
arena | Блок в 1 MB, которым pymalloc берёт память у ОС |
Теория
Что задают флаги
| Флаг | Файл cgroup v2 | Что делает |
|---|---|---|
--memory 512m | memory.max | Жёсткий предел. Превышение — OOM kill |
--memory-reservation 256m | memory.low | Мягкая гарантия: при нехватке на хосте отбирают в последнюю очередь |
--memory-swap 512m | memory.swap.max | Суммарный предел памяти и swap |
--cpus 1.5 | cpu.max | Квота: 1.5 CPU-секунды на секунду времени |
--cpuset-cpus 0,1 | cpuset.cpus | Разрешённые ядра (маска affinity) |
--cpu-shares 512 | cpu.weight | Относительный вес при конкуренции; без неё не ограничивает |
--pids-limit 100 | pids.max | Максимум процессов и потоков |
Два уточнения, которые чаще всего понимают неверно.
--memory-swap — не размер swap, а сумма. --memory 512m --memory-swap 1g означает 512 MB RAM плюс 512 MB swap. Чтобы запретить swap полностью, задают равные значения:
docker run --memory 512m --memory-swap 512m ...
Если --memory-swap не задан, container получает swap в размере лимита памяти — то есть вдвое больше суммарно. Для приложения это обычно нежелательно: работа в swap выглядит как загадочное замедление.
--cpu-shares не ограничивает. Это относительный вес: он влияет только когда несколько container'ов конкурируют за CPU. На незагруженном хосте container с --cpu-shares 2 использует все ядра. Ограничивает именно --cpus.
Почему Python не видит лимиты
Лимиты живут в cgroup, а стандартные системные вызовы о них не сообщают.
| Вызов | Что вернёт при --memory 512m на хосте с 16 GB |
|---|---|
os.sysconf("SC_PHYS_PAGES") * PAGE_SIZE | 16 GB |
psutil.virtual_memory().total | 16 GB |
/proc/meminfo | 16 GB |
/sys/fs/cgroup/memory.max | 512 MB ← единственный верный ответ |
Причина — procfs не виртуализирован по памяти и CPU (урок 2.3): namespace для этого не существует. Container видит /proc хоста в части статистики железа.
Практические последствия конкретны:
| Библиотека или код | Что делает | Что происходит |
|---|---|---|
multiprocessing.Pool() | Процессов по os.cpu_count() | 16 процессов при квоте 1.5 CPU |
pandas.read_csv(..., chunksize=None) | Читает файл целиком | Оценка «влезет в память» по памяти хоста |
| Кэш «10 % доступной памяти» | Считает от virtual_memory().total | 1.6 GB кэша при лимите 512 MB |
| Пулы потоков по числу CPU | os.cpu_count() | Потоков больше, чем полезно |
numpy, OpenBLAS | Потоков по числу ядер | Переподписка; лечится OMP_NUM_THREADS |
Функция, дающая верный ответ:
from pathlib import Path
def memory_limit_bytes() -> int | None:
"""Лимит памяти container'а или None, если лимит не задан."""
try:
raw = Path("/sys/fs/cgroup/memory.max").read_text().strip()
except OSError:
return None # не в container'е или cgroup v1
return None if raw == "max" else int(raw)
Библиотека psutil не решает эту задачу: она сознательно сообщает о системе, а не о cgroup.
Что происходит при исчерпании памяти
Последовательность в cgroup v2:
- Потребление достигает
memory.high(если задан) — ядро притормаживает выделение и усиливает reclaim. - Потребление достигает
memory.max— ядро пытается освободить память: сбрасывает page cache, выгружает в swap. - Освободить не удалось — срабатывает OOM killer внутри cgroup.
- Убивается процесс из этой cgroup, выбранный по
oom_score. - Если убит главный процесс — container завершается с кодом
137.
Ключевое различие, которое часто пропускают:
| Кого убил OOM killer | Что с container'ом | .State.OOMKilled |
|---|---|---|
| PID 1 | Завершается, код 137 | true |
| Дочерний процесс (worker) | Продолжает работать | обычно false |
Второй случай коварен: приложение живо, но часть worker'ов исчезла. Внешне — падение производительности без единой ошибки в логах Docker. Обнаруживается по счётчику внутри container'а:
cat /sys/fs/cgroup/memory.events
Поле oom_kill — сколько процессов было убито за время жизни cgroup. Ненулевое значение при живом container'е означает ровно этот сценарий.
Код 137 — не синоним OOM
137 = 128 + 9, то есть процесс получил SIGKILL. Причин несколько:
| Причина | Как отличить |
|---|---|
| OOM kill | .State.OOMKilled == true, oom_kill в memory.events вырос |
docker kill | OOMKilled == false, есть запись в истории команд |
Истёк grace period при docker stop | OOMKilled == false, остановка заняла ровно 10 секунд |
| OOM killer хоста | OOMKilled может быть false; след в dmesg на хосте |
Порядок диагностики:
docker inspect <c> --format 'OOMKilled={{.State.OOMKilled}} Exit={{.State.ExitCode}}'
docker inspect <c> --format '{{.HostConfig.Memory}}' # 0 — лимит не задан
dmesg -T | grep -i "killed process" | tail -5 # на хосте
CPU throttling: отказ без ошибки
Превышение лимита памяти убивает. Превышение квоты CPU — приостанавливает.
Механизм: ядро выдаёт cgroup квоту на каждый период (по умолчанию 100 мс). Исчерпав квоту, все процессы cgroup останавливаются до начала следующего периода.
Для приложения это выглядит как необъяснимая задержка: код не изменился, ошибок нет, а p99 latency вырос втрое. Ни одно исключение при этом не возникает.
Диагностика — cpu.stat внутри container'а:
nr_periods 4210 # сколько было периодов
nr_throttled 1876 # в скольких квота кончилась
throttled_usec 9241000 # суммарное время простоя
Отношение nr_throttled / nr_periods — доля периодов с throttling. Ориентиры:
| Доля | Оценка |
|---|---|
| < 1 % | Норма |
| 1–10 % | Заметно на хвостах latency |
| > 25 % | Лимит существенно занижен |
Отдельный эффект: чем больше потоков или процессов, тем быстрее расходуется квота периода. 10 worker'ов при квоте 1 CPU исчерпают квоту за 10 мс и будут простаивать 90 мс — ещё одна причина не завышать число worker'ов (урок 6.11).
Почему RSS не уменьшается
Типичное наблюдение: скрипт создал большой список, удалил его, gc.collect() выполнен — а RSS остался высоким.
Причина в устройстве аллокатора CPython:
объект ≤ 512 байт → pymalloc → pool 4 KB → arena 1 MB → mmap
объект > 512 байт → malloc libc → mmap/brk
Арена возвращается операционной системе только когда полностью свободна. Один живой объект удерживает целый мегабайт.
Отсюда:
| Наблюдение | Объяснение |
|---|---|
| RSS вырос и не падает | Арены заняты частично; фрагментация |
sys.getsizeof мало, RSS велик | Считается объект, а не удерживаемые арены |
Память падает после del большого массива | Крупные блоки идут мимо pymalloc, munmap возвращает сразу |
| Долгоживущий процесс медленно растёт | Фрагментация накапливается |
Практический вывод: лимит памяти задают по пиковому RSS, а не по «полезному» размеру данных. И перезапуск worker'а по max_requests — рабочее средство против накопленной фрагментации (урок 6.9).
Отдельный источник роста в многопоточных приложениях — арены glibc: каждый поток может получить свою (до 8 × число ядер). Ограничение:
ENV MALLOC_ARENA_MAX=2
Это заметно помогает многопоточным приложениям и ничего не меняет для однопоточных.
Page cache в счёте памяти
memory.current включает page cache — страницы прочитанных файлов. Приложение, прочитавшее гигабайтный файл, увидит рост потребления cgroup, хотя сами данные могли быть уже освобождены.
Это не приводит к OOM: page cache реклеймится под давлением. Но искажает картину при подборе лимита.
docker stats вычитает inactive_file, поэтому его число ближе к «настоящему» потреблению, чем сырой memory.current. При ручном чтении cgroup вычитание нужно делать самому.
--pids-limit и потоки
pids.max считает задачи ядра, а поток — такая же задача, как процесс. Приложение с 4 worker'ами по 10 потоков — это 40+ задач.
Значение по умолчанию в Docker — без ограничения (-1), но многие платформы задают его принудительно. Признак упирания в лимит:
BlockingIOError: [Errno 11] Resource temporarily unavailable
Это EAGAIN от fork или pthread_create — не про сеть, вопреки названию исключения.
Порядок подбора лимитов
Наугад лимиты не задают. Рабочая последовательность:
| Шаг | Действие |
|---|---|
| 1 | Запустить без лимита памяти под реалистичной нагрузкой |
| 2 | Измерить пиковое потребление: docker stats или memory.peak |
| 3 | Лимит = пик × 1.3 (запас на фрагментацию и всплески) |
| 4 | Проверить под нагрузкой: OOMKilled должен остаться false |
| 5 | Задать --cpus и проверить nr_throttled / nr_periods |
| 6 | Скорректировать число worker'ов под полученную квоту |
Шаг 1 обязателен: лимит, выбранный до измерения, либо задушит приложение, либо не защитит хост.
Внутренний механизм
Как работает квота CPU
cpu.max содержит два числа: квоту и период в микросекундах.
150000 100000 → 150 мс CPU-времени на каждые 100 мс реального времени
Планировщик ведёт счётчик на cgroup. Когда он исчерпан, все задачи cgroup снимаются с исполнения до начала следующего периода. Счётчик увеличивается на nr_throttled.
Отсюда неочевидное следствие: throttling возможен даже при средней загрузке ниже лимита. Всплеск внутри одного 100-миллисекундного окна исчерпает квоту, хотя за секунду потребление окажется меньше квоты.
Что видит OOM killer
Выбор жертвы — по oom_score, который тем выше, чем больше памяти занимает процесс. В container с Gunicorn это обычно самый «толстый» worker, а не master — потому master и переживает OOM, сообщая «Worker was sent SIGKILL».
Значение можно сместить: oom_score_adj в /proc/<pid>/oom_score_adj. Флаг --oom-score-adj у docker run задаёт его для container'а.
Флаг --oom-kill-disable отключает OOM killer для cgroup. Использовать его почти всегда неверно: процесс не убивается, но и памяти не получает — он зависает в бесконечном reclaim, что хуже быстрой гибели.
Команды и примеры
Что видит Python внутри лимита
cat > /tmp/limits.py <<'PY'
"""Сравнение того, что видит Python, с реальными лимитами cgroup."""
import os
from pathlib import Path
def cg(name: str) -> str:
p = Path("/sys/fs/cgroup") / name
try:
return p.read_text().strip()
except OSError:
return "нет"
def human(raw: str) -> str:
if raw in ("max", "нет"):
return raw
return f"{int(raw) / 1024 ** 2:.0f} MiB"
total = os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")
print(f" Python видит памяти: {total / 1024 ** 3:.1f} GiB")
print(f" cgroup memory.max: {human(cg('memory.max'))}")
print(f" cgroup memory.current:{human(cg('memory.current'))}")
print(f" Python видит CPU: {os.cpu_count()}")
print(f" cgroup cpu.max: {cg('cpu.max')}")
print(f" cgroup pids.max: {cg('pids.max')}")
PY
echo "═══ без лимитов ═══"
docker run --rm -v /tmp/limits.py:/l.py:ro python:3.13-slim python /l.py
echo "═══ --memory 512m --cpus 1.5 --pids-limit 100 ═══"
docker run --rm --memory 512m --cpus 1.5 --pids-limit 100 \
-v /tmp/limits.py:/l.py:ro python:3.13-slim python /l.py
Ожидаемый вывод на машине с 16 GB и 8 CPU:
═══ без лимитов ═══
Python видит памяти: 15.6 GiB
cgroup memory.max: max
cgroup memory.current:8 MiB
Python видит CPU: 8
cgroup cpu.max: max 100000
cgroup pids.max: max
═══ --memory 512m --cpus 1.5 --pids-limit 100 ═══
Python видит памяти: 15.6 GiB
cgroup memory.max: 512 MiB
cgroup memory.current:8 MiB
Python видит CPU: 8
cgroup cpu.max: 150000 100000
cgroup pids.max: 100
Вторая строка вывода — суть урока: Python сообщает 15.6 GiB при лимите 512 MiB. Любой код, рассчитывающий размер кэша «в процентах от доступной памяти», ошибётся в тридцать раз.
OOM kill: PID 1 против дочернего процесса
cat > /tmp/eat.py <<'PY'
"""Постепенно занимает память до срабатывания OOM killer."""
import sys
import time
chunks = []
mb = 0
while True:
chunks.append(bytearray(10 * 1024 * 1024)) # 10 MiB, реально записанных
mb += 10
print(f"занято ~{mb} MiB", flush=True)
time.sleep(0.05)
PY
echo "═══ убит главный процесс ═══"
docker run --name oom1 --memory 128m --memory-swap 128m \
-v /tmp/eat.py:/e.py:ro python:3.13-slim python /e.py > /dev/null 2>&1
printf ' ExitCode: %s\n' "$(docker inspect oom1 --format '{{.State.ExitCode}}')"
printf ' OOMKilled: %s\n' "$(docker inspect oom1 --format '{{.State.OOMKilled}}')"
docker rm oom1 > /dev/null
Ожидаемый вывод:
═══ убит главный процесс ═══
ExitCode: 137
OOMKilled: true
Теперь случай, который сложнее заметить: OOM убивает дочерний процесс, а container продолжает работу.
cat > /tmp/parent.py <<'PY'
"""Родитель переживает гибель прожорливого потомка."""
import multiprocessing as mp
import time
def glutton():
chunks = []
while True:
chunks.append(bytearray(10 * 1024 * 1024))
time.sleep(0.05)
if __name__ == "__main__":
p = mp.Process(target=glutton)
p.start()
p.join()
print(f"потомок завершился, exitcode={p.exitcode}", flush=True)
print("родитель жив, продолжаю работу", flush=True)
time.sleep(20)
PY
echo "═══ убит дочерний процесс ═══"
docker run -d --name oom2 --memory 128m --memory-swap 128m \
-v /tmp/parent.py:/p.py:ro python:3.13-slim python /p.py > /dev/null
sleep 10
printf ' статус container: %s\n' "$(docker inspect oom2 --format '{{.State.Status}}')"
printf ' OOMKilled: %s\n' "$(docker inspect oom2 --format '{{.State.OOMKilled}}')"
printf ' memory.events: %s\n' \
"$(docker exec oom2 sh -c 'grep oom_kill /sys/fs/cgroup/memory.events')"
docker logs oom2 2>&1 | tail -2 | sed 's/^/ /'
docker rm -f oom2 > /dev/null
Ожидаемый вывод:
═══ убит дочерний процесс ═══
статус container: running
OOMKilled: false
memory.events: oom_kill 1
потомок завершился, exitcode=-9
родитель жив, продолжаю работу
Разберём, что здесь важно.
OOMKilled: false при том, что OOM killer сработал — потому что флаг относится к главному процессу, а он жив. Единственное надёжное свидетельство — oom_kill 1 в memory.events.
exitcode=-9 — отрицательное число в multiprocessing означает сигнал: 9 это SIGKILL. Это тот же факт, что и 137 снаружи.
Практический вывод: мониторить нужно memory.events, а не только .State.OOMKilled. Иначе исчезновение worker'ов останется незамеченным.
Почему RSS не возвращается
cat > /tmp/frag.py <<'PY'
"""Фрагментация арен: почему RSS не падает после освобождения."""
import gc
import resource
def rss_mb() -> float:
# ru_maxrss на Linux — в килобайтах, и это ПИК, а не текущее значение
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
def current_rss_mb() -> float:
with open("/proc/self/statm") as f:
pages = int(f.read().split()[1])
return pages * 4096 / 1024 ** 2
print(f" старт: {current_rss_mb():6.1f} MiB")
# Много мелких объектов — они идут через pymalloc
small = [{"i": i, "s": f"объект-{i}"} for i in range(400_000)]
print(f" создано 400k мелких словарей:{current_rss_mb():6.1f} MiB")
# Оставляем КАЖДЫЙ СОТЫЙ: арены останутся занятыми частично
survivors = small[::100]
del small
gc.collect()
print(f" удалено 99%, gc.collect(): {current_rss_mb():6.1f} MiB ← фрагментация")
print(f" живых объектов осталось: {len(survivors)}")
# Крупный блок идёт мимо pymalloc, сразу в mmap
big = bytearray(200 * 1024 * 1024)
print(f" выделено 200 MiB одним куском:{current_rss_mb():6.1f} MiB")
del big
gc.collect()
print(f" удалён крупный блок: {current_rss_mb():6.1f} MiB ← вернулось сразу")
print(f" пик за всё время: {rss_mb():6.1f} MiB")
PY
docker run --rm -v /tmp/frag.py:/f.py:ro python:3.13-slim python /f.py
Ожидаемый вывод (числа зависят от версии Python):
старт: 9.8 MiB
создано 400k мелких словарей: 148.3 MiB
удалено 99%, gc.collect(): 102.7 MiB ← фрагментация
живых объектов осталось: 4000
выделено 200 MiB одним куском:302.9 MiB
удалён крупный блок: 102.9 MiB ← вернулось сразу
пик за всё время: 303.1 MiB
Два противоположных поведения в одном прогоне:
Мелкие объекты. Удалено 99 % — освободилось меньше трети памяти. Четыре тысячи уцелевших объектов, разбросанных по аренам, удерживают почти всё: арена возвращается ОС только целиком.
Крупный блок. 200 MiB вернулись полностью и сразу: блок такого размера идёт мимо pymalloc, и munmap отдаёт страницы немедленно.
Отсюда правило подбора лимита: считать нужно по пику (303 MiB), а не по «полезному» объёму данных.
Влияние MALLOC_ARENA_MAX
cat > /tmp/threads.py <<'PY'
"""Рост RSS в многопоточном приложении из-за арен glibc."""
import os
import threading
def current_rss_mb() -> float:
with open("/proc/self/statm") as f:
return int(f.read().split()[1]) * 4096 / 1024 ** 2
def work(barrier: threading.Barrier) -> None:
data = [bytearray(256) for _ in range(20_000)]
barrier.wait()
del data
n = 32
barrier = threading.Barrier(n + 1)
threads = [threading.Thread(target=work, args=(barrier,)) for _ in range(n)]
for t in threads:
t.start()
barrier.wait()
print(f" MALLOC_ARENA_MAX={os.environ.get('MALLOC_ARENA_MAX', 'не задан'):9} "
f"RSS: {current_rss_mb():6.1f} MiB")
for t in threads:
t.join()
PY
docker run --rm -v /tmp/threads.py:/t.py:ro python:3.13-slim python /t.py
docker run --rm -e MALLOC_ARENA_MAX=2 -v /tmp/threads.py:/t.py:ro python:3.13-slim python /t.py
Ожидаемый вывод:
MALLOC_ARENA_MAX=не задан RSS: 74.2 MiB
MALLOC_ARENA_MAX=2 RSS: 61.5 MiB
Эффект заметен, но не драматичен: большая часть аллокаций здесь проходит через pymalloc, а не через glibc. Наибольшую выгоду MALLOC_ARENA_MAX даёт приложениям с расширениями на C, которые выделяют память напрямую через malloc — numpy, драйверы баз данных, парсеры.
Не считайте эту переменную обязательной: измерьте на своём приложении.
CPU throttling
cat > /tmp/burn.py <<'PY'
"""CPU-bound нагрузка с отчётом о throttling."""
import time
from pathlib import Path
def cpu_stat() -> dict[str, int]:
text = Path("/sys/fs/cgroup/cpu.stat").read_text()
return {k: int(v) for k, v in (line.split() for line in text.splitlines())}
before = cpu_stat()
start = time.monotonic()
total = 0
for i in range(12_000_000):
total += i * i % 7
elapsed = time.monotonic() - start
after = cpu_stat()
periods = after["nr_periods"] - before["nr_periods"]
throttled = after["nr_throttled"] - before["nr_throttled"]
usec = after["throttled_usec"] - before["throttled_usec"]
share = (throttled / periods * 100) if periods else 0.0
print(f" время работы: {elapsed:6.2f} c")
print(f" периодов: {periods}")
print(f" из них с throttling: {throttled} ({share:.0f} %)")
print(f" простой из-за квоты: {usec / 1_000_000:.2f} c")
PY
echo "═══ без лимита CPU ═══"
docker run --rm -v /tmp/burn.py:/b.py:ro python:3.13-slim python /b.py
echo "═══ --cpus 0.5 ═══"
docker run --rm --cpus 0.5 -v /tmp/burn.py:/b.py:ro python:3.13-slim python /b.py
echo "═══ --cpus 0.2 ═══"
docker run --rm --cpus 0.2 -v /tmp/burn.py:/b.py:ro python:3.13-slim python /b.py
Ожидаемый вывод:
═══ без лимита CPU ═══
время работы: 2.41 c
периодов: 0
из них с throttling: 0 (0 %)
простой из-за квоты: 0.00 c
═══ --cpus 0.5 ═══
время работы: 4.86 c
периодов: 49
из них с throttling: 48 (98 %)
простой из-за квоты: 2.43 c
═══ --cpus 0.2 ═══
время работы: 12.13 c
периодов: 122
из них с throttling: 121 (99 %)
простой из-за квоты: 9.70 c
Главное наблюдение: ни одной ошибки, ни одного исключения — только время работы выросло в пять раз. Именно так CPU throttling проявляется в production: код тот же, ошибок нет, latency растёт.
Заметьте связь: при --cpus 0.5 простой составил 2.43 секунды, а работа заняла 4.86 — ровно половину времени процесс стоял. Это прямое следствие квоты в половину CPU.
Строка периодов: 0 в первом блоке — признак того, что квота не задана: ядро не ведёт учёт периодов без лимита.
Подбор лимита по измерению
cd resources/examples/flask-basic
docker build -q -t fb . > /dev/null
echo "═══ шаг 1–2: измерение без лимита ═══"
docker run -d --name m1 -e WEB_CONCURRENCY=4 -p 8000:8000 fb > /dev/null
sleep 6
for _ in $(seq 200); do curl -s localhost:8000/ > /dev/null; done
docker stats --no-stream --format ' использование: {{.MemUsage}}' m1
printf ' пик (memory.peak): %s MiB\n' \
"$(docker exec m1 sh -c 'cat /sys/fs/cgroup/memory.peak 2>/dev/null || echo 0' | awk '{printf "%.0f", $1/1048576}')"
docker rm -f m1 > /dev/null
echo "═══ шаг 3–4: лимит = пик × 1.3, проверка под нагрузкой ═══"
docker run -d --name m2 --memory 256m --memory-swap 256m \
-e WEB_CONCURRENCY=4 -p 8000:8000 fb > /dev/null
sleep 6
for _ in $(seq 200); do curl -s localhost:8000/ > /dev/null; done
printf ' статус: %s\n' "$(docker inspect m2 --format '{{.State.Status}}')"
printf ' OOMKilled: %s\n' "$(docker inspect m2 --format '{{.State.OOMKilled}}')"
printf ' oom_kill: %s\n' \
"$(docker exec m2 sh -c 'awk "/oom_kill /{print \$2}" /sys/fs/cgroup/memory.events')"
docker rm -f m2 > /dev/null
echo "═══ контроль: заведомо малый лимит ═══"
docker run -d --name m3 --memory 48m --memory-swap 48m \
-e WEB_CONCURRENCY=4 fb > /dev/null 2>&1
sleep 8
printf ' статус: %s\n' "$(docker inspect m3 --format '{{.State.Status}}')"
printf ' ExitCode: %s\n' "$(docker inspect m3 --format '{{.State.ExitCode}}')"
printf ' OOMKilled: %s\n' "$(docker inspect m3 --format '{{.State.OOMKilled}}')"
docker rm -f m3 > /dev/null; docker rmi -f fb > /dev/null
Ожидаемый вывод:
═══ шаг 1–2: измерение без лимита ═══
использование: 118.4MiB / 15.6GiB
пик (memory.peak): 126 MiB
═══ шаг 3–4: лимит = пик × 1.3, проверка под нагрузкой ═══
статус: running
OOMKilled: false
oom_kill: 0
═══ контроль: заведомо малый лимит ═══
статус: exited
ExitCode: 137
OOMKilled: true
Три блока — это весь метод. Измерили пик (126 MiB), задали 256 MiB с запасом, проверили под той же нагрузкой, что oom_kill остался нулевым. Третий блок подтверждает, что проверка вообще способна обнаружить проблему.
Файл memory.peak доступен начиная с ядра 5.19; на более старых пик приходится снимать периодическим опросом memory.current.
Уборка
rm -f /tmp/limits.py /tmp/eat.py /tmp/parent.py /tmp/frag.py /tmp/threads.py /tmp/burn.py
Практическое упражнение
Задание. Напишите модуль container_limits.py и подтвердите его работу измерениями.
Требования:
- Функция
memory_limit()возвращает лимит container'а в байтах илиNone. - Функция
cpu_limit()возвращает доступные CPU с учётом--cpusи--cpuset-cpus. - Функция
memory_usage()возвращает текущее потребление за вычетомinactive_file. - Функция
oom_kills()возвращает число OOM-убийств в cgroup. - Функция
throttling()возвращает долю периодов с throttling. - Функция
recommended_workers()считает число worker'ов от лимита CPU, а не от CPU хоста. - Модуль работает и вне container'а (возвращает
Noneили значения хоста), и без прав root. - Доказать корректность: одинаковый код при четырёх наборах лимитов даёт разные и верные ответы.
Подсказки
Подсказка 1
cpu.max содержит два числа через пробел; первое может быть строкой max.
Подсказка 2
При --cpuset-cpus квота не задана, но маска affinity сужена. Верный ответ — минимум из двух источников.
Подсказка 3
memory.stat содержит строку inactive_file — её и нужно вычесть из memory.current.
Подсказка 4
Для требования 7 достаточно возвращать None при OSError: вне container'а файлов cgroup v2 может не быть.
Решение
Сначала выполните задание самостоятельно.
Показать решение
mkdir -p /tmp/lim && cd /tmp/lim
cat > container_limits.py <<'PY'
"""Чтение реальных лимитов container'а из cgroup v2.
Стандартные вызовы Python (os.cpu_count, sysconf, psutil) сообщают о хосте,
а не о container'е: procfs не виртуализирован по памяти и CPU. Единственный
достоверный источник — файловая система cgroup.
"""
from __future__ import annotations
import math
import os
from pathlib import Path
CGROUP = Path("/sys/fs/cgroup")
def _read(name: str) -> str | None:
"""Читает файл cgroup. None, если недоступен (не container, cgroup v1, нет прав)."""
try:
return (CGROUP / name).read_text().strip()
except OSError:
return None
def _read_kv(name: str) -> dict[str, int]:
"""Читает файл вида 'ключ значение' построчно."""
raw = _read(name)
if raw is None:
return {}
out: dict[str, int] = {}
for line in raw.splitlines():
parts = line.split()
if len(parts) == 2 and parts[1].lstrip("-").isdigit():
out[parts[0]] = int(parts[1])
return out
# ── Требование 1 ──
def memory_limit() -> int | None:
"""Лимит памяти в байтах. None — лимит не задан или мы не в container'е."""
raw = _read("memory.max")
if raw is None or raw == "max":
return None
return int(raw)
# ── Требование 2 ──
def cpu_limit() -> float:
"""Доступные CPU с учётом --cpus и --cpuset-cpus.
Берётся минимум: квота ограничивает время, маска — число ядер.
Действует более строгое из двух ограничений.
"""
# sched_getaffinity учитывает --cpuset-cpus, но НЕ --cpus
affinity = float(len(os.sched_getaffinity(0)))
raw = _read("cpu.max")
if raw is None:
return affinity
parts = raw.split()
if len(parts) != 2 or parts[0] == "max":
return affinity # квота не задана — решает только маска
quota, period = int(parts[0]), int(parts[1])
return min(affinity, quota / period)
# ── Требование 3 ──
def memory_usage() -> int | None:
"""Потребление за вычетом реклеймируемого page cache.
Так же считает docker stats: сырой memory.current включает кэш
прочитанных файлов и завышает картину.
"""
raw = _read("memory.current")
if raw is None:
return None
inactive_file = _read_kv("memory.stat").get("inactive_file", 0)
return max(0, int(raw) - inactive_file)
def memory_peak() -> int | None:
"""Пиковое потребление (ядро 5.19+)."""
raw = _read("memory.peak")
return int(raw) if raw and raw.isdigit() else None
# ── Требование 4 ──
def oom_kills() -> int:
"""Число процессов, убитых OOM killer'ом в этой cgroup.
Ненулевое значение при работающем container'е означает, что погиб
дочерний процесс: .State.OOMKilled в этом случае остаётся false.
"""
return _read_kv("memory.events").get("oom_kill", 0)
# ── Требование 5 ──
def throttling() -> dict[str, float]:
"""Статистика throttling. Доля > 25 % означает заниженный лимит CPU."""
stat = _read_kv("cpu.stat")
periods = stat.get("nr_periods", 0)
throttled = stat.get("nr_throttled", 0)
return {
"periods": float(periods),
"throttled": float(throttled),
"share_percent": (throttled / periods * 100) if periods else 0.0,
"throttled_seconds": stat.get("throttled_usec", 0) / 1_000_000,
}
# ── Требование 6 ──
def recommended_workers(per_worker_mb: float = 60.0, max_workers: int = 12) -> int:
"""Число worker'ов от лимитов container'а, а не от параметров хоста.
Ограничивается и по CPU, и по памяти: берётся меньшее.
"""
by_cpu = max(1, math.floor(cpu_limit() * 2) + 1)
limit = memory_limit()
if limit is None:
by_memory = max_workers
else:
# 30 % запаса на фрагментацию и всплески
usable = limit * 0.7 / (per_worker_mb * 1024 ** 2)
by_memory = max(1, int(usable))
return max(1, min(by_cpu, by_memory, max_workers))
def report() -> str:
limit = memory_limit()
usage = memory_usage()
peak = memory_peak()
thr = throttling()
def mb(v: int | None) -> str:
return "без лимита" if v is None else f"{v / 1024 ** 2:.0f} MiB"
return "\n".join([
f" память лимит: {mb(limit)}",
f" память сейчас: {mb(usage)}",
f" память пик: {mb(peak)}",
f" CPU по cgroup: {cpu_limit():.2f}",
f" CPU по os: {os.cpu_count()}",
f" OOM-убийств: {oom_kills()}",
f" throttling: {thr['share_percent']:.0f} % "
f"({thr['throttled']:.0f}/{thr['periods']:.0f})",
f" worker'ов: {recommended_workers()}",
])
if __name__ == "__main__":
print(report())
PY
# ── Требование 8: один и тот же код при разных лимитах ──
run() {
echo "═══ $1 ═══"
# shellcheck disable=SC2086
docker run --rm $2 -v "$PWD/container_limits.py:/cl.py:ro" \
python:3.13-slim python /cl.py
}
run "без лимитов" ""
run "--memory 512m" "--memory 512m --memory-swap 512m"
run "--cpus 1.5" "--cpus 1.5"
run "--memory 256m --cpus 0.5" "--memory 256m --memory-swap 256m --cpus 0.5"
run "--cpuset-cpus 0,1" "--cpuset-cpus 0,1"
echo "═══ требование 7: вне container'а ═══"
python3 container_limits.py
Ожидаемый вывод на машине с 16 GB и 8 CPU:
═══ без лимитов ═══
память лимит: без лимита
память сейчас: 5 MiB
память пик: 9 MiB
CPU по cgroup: 8.00
CPU по os: 8
OOM-убийств: 0
throttling: 0 % (0/0)
worker'ов: 12
═══ --memory 512m ═══
память лимит: 512 MiB
память сейчас: 5 MiB
память пик: 9 MiB
CPU по cgroup: 8.00
CPU по os: 8
OOM-убийств: 0
throttling: 0 % (0/0)
worker'ов: 5
═══ --cpus 1.5 ═══
память лимит: без лимита
память сейчас: 5 MiB
память пик: 9 MiB
CPU по cgroup: 1.50
CPU по os: 8
OOM-убийств: 0
throttling: 0 % (0/0)
worker'ов: 4
═══ --memory 256m --cpus 0.5 ═══
память лимит: 256 MiB
память сейчас: 5 MiB
память пик: 9 MiB
CPU по cgroup: 0.50
CPU по os: 8
OOM-убийств: 0
throttling: 0 % (0/0)
worker'ов: 2
═══ --cpuset-cpus 0,1 ═══
память лимит: без лимита
память сейчас: 5 MiB
память пик: 9 MiB
CPU по cgroup: 2.00
CPU по os: 2
OOM-убийств: 0
throttling: 0 % (0/0)
worker'ов: 5
═══ требование 7: вне container'а ═══
память лимит: без лимита
память сейчас: ... MiB
CPU по cgroup: 8.00
CPU по os: 8
OOM-убийств: 0
throttling: 0 % (0/0)
worker'ов: 12
Требование 8 выполнено: код один, ответы разные и каждый верен.
Обратите внимание на строку CPU по os в предпоследнем блоке: при --cpuset-cpus 0,1 она равна 2 — маска affinity видна и стандартному вызову. Во всех остальных блоках os.cpu_count() упрямо показывает 8, включая случай --cpus 0.5.
Проверим, что счётчик OOM действительно работает:
cd /tmp/lim
cat > oomtest.py <<'PY'
import multiprocessing as mp
import time
import container_limits as cl
def glutton():
chunks = []
while True:
chunks.append(bytearray(8 * 1024 * 1024))
time.sleep(0.02)
if __name__ == "__main__":
print(f" до: OOM-убийств = {cl.oom_kills()}")
p = mp.Process(target=glutton)
p.start()
p.join()
print(f" после: OOM-убийств = {cl.oom_kills()}, exitcode потомка = {p.exitcode}")
print(f" главный процесс жив — .State.OOMKilled останется false")
PY
docker run --rm --memory 128m --memory-swap 128m \
-v "$PWD:/app:ro" -w /app python:3.13-slim python oomtest.py
Ожидаемый вывод:
до: OOM-убийств = 0
после: OOM-убийств = 1, exitcode потомка = -9
главный процесс жив — .State.OOMKilled останется false
Это требование 4 в действии: oom_kills() обнаруживает то, что docker inspect в этой ситуации не показывает.
cd /tmp && rm -rf /tmp/lim
Три решения, определяющие качество.
cpu_limit() возвращает минимум из квоты и маски, а не одно из двух. Флаги --cpus и --cpuset-cpus можно задать одновременно, и тогда действует более строгое ограничение. Реализация, читающая только cpu.max, ответит 8.00 при --cpuset-cpus 0,1; читающая только affinity — ответит 8 при --cpus 0.5. Оба ответа неверны в половине случаев.
memory_usage() вычитает inactive_file. Без вычитания функция показывала бы память вместе с page cache — приложение, прочитавшее большой файл, выглядело бы как близкое к лимиту, хотя кэш реклеймируется под давлением и OOM не вызывает. Именно так считает docker stats, и совпадение с ним важно: иначе ваши числа и числа в мониторинге разойдутся без причины.
recommended_workers() ограничивается и по CPU, и по памяти. Расчёт только по CPU дал бы 4 worker'а при --cpus 1.5, даже если лимит памяти 128 MiB и туда влезет один. Ограничение по обоим ресурсам — то, чего нет в исходной формуле Gunicorn (урок 6.11).
Чего модуль не делает. Он не поддерживает cgroup v1: пути и формат файлов там другие (memory.limit_in_bytes, cpu.cfs_quota_us). Для Docker Engine 29, где cgroup v1 объявлен deprecated, это осознанный отказ — но на старых системах функции вернут значения хоста, что молча даст неверный результат. Честная реализация для смешанного парка должна определять версию cgroup и явно сообщать, что лимиты прочитать не удалось.
Проверка результата
docker run --rm --memory 256m --memory-swap 256m --cpus 0.5 python:3.13-slim sh -c \
'echo "memory.max: $(cat /sys/fs/cgroup/memory.max)";
echo "cpu.max: $(cat /sys/fs/cgroup/cpu.max)";
python -c "import os; print(\"os.cpu_count():\", os.cpu_count())"'
Ожидается 268435456, 50000 100000 и число CPU хоста — то есть подтверждение, что Python лимитов не видит.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Размер кэша от psutil.virtual_memory() | Кажется правильным источником | Возвращает память хоста; читать memory.max |
multiprocessing.Pool() без аргумента | Умолчание — os.cpu_count() | Задать явно от лимита CPU |
--memory без --memory-swap | Не знали про умолчание | Container получает вдвое больше через swap |
--cpu-shares вместо --cpus | Считают, что это лимит | Вес действует только при конкуренции |
Код 137 считают синонимом OOM | Часто совпадает | Проверить .State.OOMKilled и memory.events |
Мониторят только .State.OOMKilled | Кажется достаточным | Гибель дочернего процесса так не видна |
| Лимит «на глаз» | Не измеряли | Измерить пик под нагрузкой, умножить на 1.3 |
| Ищут утечку из-за невозвращённого RSS | Приняли фрагментацию за утечку | Проверить долю живых объектов; max_requests |
| Ждут падения при исчерпании CPU | По аналогии с памятью | CPU не убивает, а тормозит; смотреть nr_throttled |
--oom-kill-disable | «Пусть не убивает» | Процесс зависает в reclaim; хуже быстрой гибели |
Забывают, что потоки считаются в pids.max | Считают только процессы | 4 worker'а × 10 потоков — это 40+ задач |
| Лимит по «полезному» объёму данных | Не учли аллокатор | Считать по пиковому RSS |
Контрольные вопросы
На понимание:
- Почему
psutil.virtual_memory().totalвозвращает память хоста? - Чем
--memory-swapотличается от «размера swap»? - Почему
.State.OOMKilledможет бытьfalseпри сработавшем OOM killer? - Почему RSS не падает после удаления множества мелких объектов?
- Чем превышение лимита CPU принципиально отличается от превышения лимита памяти?
На применение:
- Как узнать реальный лимит памяти из Python?
- Как подобрать
--memoryдля приложения с четырьмя worker'ами? - Как обнаружить throttling, если ошибок в логах нет?
На диагностику:
- Container с кодом
137. Порядок действий для определения причины? - Latency выросла втрое, ошибок нет, память в норме. Что проверить первым?
Краткое резюме
- Лимиты живут в cgroup;
/procиpsutilсообщают о хосте, а не о container'е. - Достоверные источники —
/sys/fs/cgroup/memory.max,cpu.max,pids.max. --memory-swapзадаёт сумму памяти и swap; без него swap равен лимиту памяти.--cpu-shares— вес при конкуренции, а не лимит; ограничивает--cpus.- Код
137означаетSIGKILL; OOM подтверждают.State.OOMKilledиmemory.events. - Гибель дочернего процесса от OOM не отражается в
.State.OOMKilled— нуженoom_kill. - Превышение квоты CPU не убивает, а приостанавливает: диагностика по
nr_throttled. - Throttling возможен при средней загрузке ниже лимита — из-за всплесков внутри периода.
- Арена pymalloc возвращается ОС только целиком, поэтому RSS не падает пропорционально.
- Крупные блоки идут мимо pymalloc и освобождаются сразу.
MALLOC_ARENA_MAX=2помогает многопоточным приложениям с расширениями на C.- Лимит подбирают по измеренному пику × 1.3 и проверяют под нагрузкой.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: resource constraints | https://docs.docker.com/engine/containers/resource_constraints/ | --memory, --memory-swap, --cpus, --cpu-shares, --pids-limit |
| Docker: runtime metrics | https://docs.docker.com/engine/containers/runmetrics/ | Чтение cgroup, состав memory.stat |
| cgroup v2 | https://docs.kernel.org/admin-guide/cgroup-v2.html | memory.max, memory.events, cpu.max, cpu.stat, pids.max |
| Kernel: CFS bandwidth control | https://docs.kernel.org/scheduler/sched-bwc.html | Квота, период, механизм throttling |
| Python: memory management | https://docs.python.org/3/c-api/memory.html | pymalloc, арены, пулы, PYTHONMALLOC |
Python: resource | https://docs.python.org/3/library/resource.html | getrusage, ru_maxrss |
glibc: mallopt | https://www.gnu.org/software/libc/manual/html_node/Memory-Allocation-Tunables.html | MALLOC_ARENA_MAX |
| psutil FAQ | https://psutil.readthedocs.io/en/latest/#faq | Почему в container сообщается о хосте |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление