Главная/Python внутри Container/Урок

6.13. Resource limits и память Python

Цели

После этого материала вы сможете:

  • задать лимиты памяти, CPU и числа процессов и объяснить, что делает каждый;
  • объяснить, почему Python не «видит» лимит и какие библиотеки из-за этого ошибаются;
  • отличить OOM kill от других причин кода 137;
  • обнаружить CPU throttling — отказ, который не проявляется как ошибка;
  • объяснить, почему RSS не уменьшается после освобождения объектов;
  • подобрать лимиты по измерению, а не наугад.

Предварительные знания

Ключевые термины

ТерминОбъяснение
memory.maxЖёсткий лимит cgroup v2; превышение — OOM kill
memory.highМягкий лимит: процесс притормаживается, но не убивается
memory.currentТекущее потребление cgroup
RSSResident Set Size — физическая память процесса
throttlingПриостановка процесса при исчерпании квоты CPU
pymallocАллокатор CPython для объектов до 512 байт
arenaБлок в 1 MB, которым pymalloc берёт память у ОС

Теория

Что задают флаги

ФлагФайл cgroup v2Что делает
--memory 512mmemory.maxЖёсткий предел. Превышение — OOM kill
--memory-reservation 256mmemory.lowМягкая гарантия: при нехватке на хосте отбирают в последнюю очередь
--memory-swap 512mmemory.swap.maxСуммарный предел памяти и swap
--cpus 1.5cpu.maxКвота: 1.5 CPU-секунды на секунду времени
--cpuset-cpus 0,1cpuset.cpusРазрешённые ядра (маска affinity)
--cpu-shares 512cpu.weightОтносительный вес при конкуренции; без неё не ограничивает
--pids-limit 100pids.maxМаксимум процессов и потоков

Два уточнения, которые чаще всего понимают неверно.

--memory-swap — не размер swap, а сумма. --memory 512m --memory-swap 1g означает 512 MB RAM плюс 512 MB swap. Чтобы запретить swap полностью, задают равные значения:

bash
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_SIZE16 GB
psutil.virtual_memory().total16 GB
/proc/meminfo16 GB
/sys/fs/cgroup/memory.max512 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().total1.6 GB кэша при лимите 512 MB
Пулы потоков по числу CPUos.cpu_count()Потоков больше, чем полезно
numpy, OpenBLASПотоков по числу ядерПереподписка; лечится OMP_NUM_THREADS

Функция, дающая верный ответ:

python
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:

  1. Потребление достигает memory.high (если задан) — ядро притормаживает выделение и усиливает reclaim.
  2. Потребление достигает memory.max — ядро пытается освободить память: сбрасывает page cache, выгружает в swap.
  3. Освободить не удалось — срабатывает OOM killer внутри cgroup.
  4. Убивается процесс из этой cgroup, выбранный по oom_score.
  5. Если убит главный процесс — container завершается с кодом 137.

Ключевое различие, которое часто пропускают:

Кого убил OOM killerЧто с container'ом.State.OOMKilled
PID 1Завершается, код 137true
Дочерний процесс (worker)Продолжает работатьобычно false

Второй случай коварен: приложение живо, но часть worker'ов исчезла. Внешне — падение производительности без единой ошибки в логах Docker. Обнаруживается по счётчику внутри container'а:

bash
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 killOOMKilled == false, есть запись в истории команд
Истёк grace period при docker stopOOMKilled == false, остановка заняла ровно 10 секунд
OOM killer хостаOOMKilled может быть false; след в dmesg на хосте

Порядок диагностики:

bash
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'а:

text
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:

text
объект ≤ 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 × число ядер). Ограничение:

dockerfile
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), но многие платформы задают его принудительно. Признак упирания в лимит:

text
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 содержит два числа: квоту и период в микросекундах.

text
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 внутри лимита

bash
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:

text
═══ без лимитов ═══
  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 против дочернего процесса

bash
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

Ожидаемый вывод:

text
═══ убит главный процесс ═══
  ExitCode:  137
  OOMKilled: true

Теперь случай, который сложнее заметить: OOM убивает дочерний процесс, а container продолжает работу.

bash
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

Ожидаемый вывод:

text
═══ убит дочерний процесс ═══
  статус 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 не возвращается

bash
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):

text
  старт:                          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

bash
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

Ожидаемый вывод:

text
  MALLOC_ARENA_MAX=не задан RSS:   74.2 MiB
  MALLOC_ARENA_MAX=2        RSS:   61.5 MiB

Эффект заметен, но не драматичен: большая часть аллокаций здесь проходит через pymalloc, а не через glibc. Наибольшую выгоду MALLOC_ARENA_MAX даёт приложениям с расширениями на C, которые выделяют память напрямую через mallocnumpy, драйверы баз данных, парсеры.

Не считайте эту переменную обязательной: измерьте на своём приложении.

CPU throttling

bash
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

Ожидаемый вывод:

text
═══ без лимита 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 в первом блоке — признак того, что квота не задана: ядро не ведёт учёт периодов без лимита.

Подбор лимита по измерению

bash
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

Ожидаемый вывод:

text
═══ шаг 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.

Уборка

bash
rm -f /tmp/limits.py /tmp/eat.py /tmp/parent.py /tmp/frag.py /tmp/threads.py /tmp/burn.py

Практическое упражнение

Задание. Напишите модуль container_limits.py и подтвердите его работу измерениями.

Требования:

  1. Функция memory_limit() возвращает лимит container'а в байтах или None.
  2. Функция cpu_limit() возвращает доступные CPU с учётом --cpus и --cpuset-cpus.
  3. Функция memory_usage() возвращает текущее потребление за вычетом inactive_file.
  4. Функция oom_kills() возвращает число OOM-убийств в cgroup.
  5. Функция throttling() возвращает долю периодов с throttling.
  6. Функция recommended_workers() считает число worker'ов от лимита CPU, а не от CPU хоста.
  7. Модуль работает и вне container'а (возвращает None или значения хоста), и без прав root.
  8. Доказать корректность: одинаковый код при четырёх наборах лимитов даёт разные и верные ответы.

Подсказки

Подсказка 1

cpu.max содержит два числа через пробел; первое может быть строкой max.

Подсказка 2

При --cpuset-cpus квота не задана, но маска affinity сужена. Верный ответ — минимум из двух источников.

Подсказка 3

memory.stat содержит строку inactive_file — её и нужно вычесть из memory.current.

Подсказка 4

Для требования 7 достаточно возвращать None при OSError: вне container'а файлов cgroup v2 может не быть.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
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:

text
═══ без лимитов ═══
  память лимит:     без лимита
  память сейчас:    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 действительно работает:

bash
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

Ожидаемый вывод:

text
  до:    OOM-убийств = 0
  после: OOM-убийств = 1, exitcode потомка = -9
  главный процесс жив — .State.OOMKilled останется false

Это требование 4 в действии: oom_kills() обнаруживает то, что docker inspect в этой ситуации не показывает.

bash
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 и явно сообщать, что лимиты прочитать не удалось.

Проверка результата

bash
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

Контрольные вопросы

На понимание:

  1. Почему psutil.virtual_memory().total возвращает память хоста?
  2. Чем --memory-swap отличается от «размера swap»?
  3. Почему .State.OOMKilled может быть false при сработавшем OOM killer?
  4. Почему RSS не падает после удаления множества мелких объектов?
  5. Чем превышение лимита CPU принципиально отличается от превышения лимита памяти?

На применение:

  1. Как узнать реальный лимит памяти из Python?
  2. Как подобрать --memory для приложения с четырьмя worker'ами?
  3. Как обнаружить throttling, если ошибок в логах нет?

На диагностику:

  1. Container с кодом 137. Порядок действий для определения причины?
  2. Latency выросла втрое, ошибок нет, память в норме. Что проверить первым?

Краткое резюме

  1. Лимиты живут в cgroup; /proc и psutil сообщают о хосте, а не о container'е.
  2. Достоверные источники — /sys/fs/cgroup/memory.max, cpu.max, pids.max.
  3. --memory-swap задаёт сумму памяти и swap; без него swap равен лимиту памяти.
  4. --cpu-shares — вес при конкуренции, а не лимит; ограничивает --cpus.
  5. Код 137 означает SIGKILL; OOM подтверждают .State.OOMKilled и memory.events.
  6. Гибель дочернего процесса от OOM не отражается в .State.OOMKilled — нужен oom_kill.
  7. Превышение квоты CPU не убивает, а приостанавливает: диагностика по nr_throttled.
  8. Throttling возможен при средней загрузке ниже лимита — из-за всплесков внутри периода.
  9. Арена pymalloc возвращается ОС только целиком, поэтому RSS не падает пропорционально.
  10. Крупные блоки идут мимо pymalloc и освобождаются сразу.
  11. MALLOC_ARENA_MAX=2 помогает многопоточным приложениям с расширениями на C.
  12. Лимит подбирают по измеренному пику × 1.3 и проверяют под нагрузкой.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: resource constraintshttps://docs.docker.com/engine/containers/resource_constraints/--memory, --memory-swap, --cpus, --cpu-shares, --pids-limit
Docker: runtime metricshttps://docs.docker.com/engine/containers/runmetrics/Чтение cgroup, состав memory.stat
cgroup v2https://docs.kernel.org/admin-guide/cgroup-v2.htmlmemory.max, memory.events, cpu.max, cpu.stat, pids.max
Kernel: CFS bandwidth controlhttps://docs.kernel.org/scheduler/sched-bwc.htmlКвота, период, механизм throttling
Python: memory managementhttps://docs.python.org/3/c-api/memory.htmlpymalloc, арены, пулы, PYTHONMALLOC
Python: resourcehttps://docs.python.org/3/library/resource.htmlgetrusage, ru_maxrss
glibc: mallopthttps://www.gnu.org/software/libc/manual/html_node/Memory-Allocation-Tunables.htmlMALLOC_ARENA_MAX
psutil FAQhttps://psutil.readthedocs.io/en/latest/#faqПочему в container сообщается о хосте

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление

Markdown на GitHub ↗