13.3. Нехватка ресурсов
Цели
После этого материала вы сможете:
- отличить OOM kill от внешнего
SIGKILL, имея только код выхода137; - найти след OOM в трёх местах и понять, что каждое из них даёт;
- объяснить, почему CPU throttling происходит при загрузке 30 %;
- измерить throttling через
cpu.statи подобрать лимит, который его снимает; - диагностировать исчерпание PID и файловых дескрипторов;
- отличить утечку памяти в Python от фрагментации и от кэша.
Предварительные знания
- 2.3. Cgroups;
- 4.4. Коды выхода;
- 11.4. Лимиты ресурсов — подбор лимитов;
- 13.2. Inspect, events, stats.
Ключевые термины
| Термин | Объяснение |
|---|---|
OOM killer | Механизм ядра, убивающий процесс при нехватке памяти |
oom_score | Оценка, по которой выбирается жертва |
throttling | Приостановка процесса при исчерпании квоты CPU |
период CFS | Окно учёта квоты, по умолчанию 100 мс |
pids.max | Предел числа процессов и потоков в cgroup |
RLIMIT_NOFILE | Предел открытых дескрипторов на процесс |
Теория
Коды 137 и 143
код выхода = 128 + номер сигнала
137 = 128 + 9 (SIGKILL)
143 = 128 + 15 (SIGTERM)
Код 143 означает, что PID 1 умер от SIGTERM. Вопреки распространённому объяснению, это не случай «приложение не установило обработчик»: для PID 1 ядро не применяет действие по умолчанию, поэтому без обработчика процесс доживает до SIGKILL и даёт 137. Код 143 на практике даёт --init, где tini пересылает сигнал потомку. Приложение, обработавшее SIGTERM и вышедшее само, даёт 0 — проверено запуском (verify/FACTS.md).
Если приложение обрабатывает SIGTERM и выходит через sys.exit(0), код будет 0 (урок 6.7).
Код 137 неоднозначен. SIGKILL мог прийти из трёх источников:
| Источник | Как отличить |
|---|---|
| OOM killer cgroup | .State.OOMKilled = true |
docker kill или таймаут docker stop | .State.OOMKilled = false, событие kill |
| OOM killer host (общая нехватка) | OOMKilled может быть false; след в dmesg |
Третья строка — ловушка. Если память кончилась на host, ядро выбирает жертву по всей системе, и ею может стать процесс вашего container'а. Docker в этом случае не всегда выставляет OOMKilled, потому что срабатывает не cgroup-лимит.
Отсюда правило: OOMKilled: false не доказывает, что OOM не было. Проверять нужно ещё и журнал ядра.
Три места, где остаётся след OOM
| Место | Команда | Что даёт |
|---|---|---|
| Состояние container | docker inspect --format '{{.State.OOMKilled}}' | Факт срабатывания cgroup-лимита |
| События Docker | docker events --filter 'event=oom' | Время и имя container'а |
| Журнал ядра | dmesg или journalctl -k | Кто именно убит и сколько памяти занимал |
Третье — единственное, где видно, какой процесс был убит. Это важно, когда в container'е несколько процессов: OOM killer убивает не обязательно PID 1.
Memory cgroup out of memory: Killed process 51204 (python3)
total-vm:1284512kB, anon-rss:262144kB, file-rss:8192kB, shmem-rss:0kB
Если убит не PID 1, container продолжает работать — но без части функциональности. Это самый неприятный вариант: ошибки есть, а docker ps показывает running.
CPU throttling при низкой загрузке
Самое контринтуитивное явление раздела.
Лимит --cpus=0.5 реализуется как квота CFS: 50 мс процессорного времени на каждые 100 мс реального. Если приложение израсходовало квоту за первые 20 мс периода, оставшиеся 80 мс оно не выполняется вовсе.
период 100 мс, квота 50 мс
всплеск нагрузки:
├──── работа 50 мс ────┤├──── ОСТАНОВЛЕН 50 мс ────┤
▲
квота исчерпана
средняя загрузка за секунду: 30 %
задержка отдельного запроса: +50 мс
Средняя загрузка в docker stats будет выглядеть скромно — 30 %, 40 %. А задержка запросов вырастет на десятки миллисекунд, потому что в момент всплеска процесс останавливается.
Симптом: высокая задержка при низкой средней загрузке CPU. Диагноз — только через cpu.stat:
nr_periods 8412 сколько было периодов
nr_throttled 3180 в скольких квота была исчерпана
throttled_usec 94512000 суммарное время остановки
Отношение nr_throttled / nr_periods — доля периодов с приостановкой. Значение выше 5 % означает, что лимит мешает.
Лечение — увеличить лимит либо сгладить нагрузку. Увеличение периода (cpu.cfs_period_us) тоже помогает, но не настраивается через Docker CLI.
Исчерпание PID
docker run --pids-limit 100 ...
Ограничение считает процессы и потоки вместе. Python-приложение с пулом из 50 потоков плюс multiprocessing с 8 рабочими легко упирается в лимит.
Симптомы:
| Проявление | Где видно |
|---|---|
BlockingIOError: [Errno 11] Resource temporarily unavailable | Логи приложения |
fork: retry: Resource temporarily unavailable | Оболочка |
can't start new thread | Python |
pids.current = pids.max | cgroup |
Ограничение полезно: без него ошибка в коде, порождающая процессы в цикле, исчерпает PID на всём host, и система перестанет отвечать.
Файловые дескрипторы
Предел задаётся на процесс, а не на cgroup:
docker run --ulimit nofile=4096:8192 ...
По умолчанию container наследует лимиты daemon, которые часто велики (1048576). Это скрывает утечку дескрипторов: она проявится не сразу, а через часы.
Проверка:
docker exec КОНТЕЙНЕР sh -c 'ls /proc/1/fd | wc -l'
docker exec КОНТЕЙНЕР sh -c 'cat /proc/1/limits | grep "open files"'
Типичная причина утечки в Python — незакрытые сокеты или файлы, открытые без контекстного менеджера.
Утечка, фрагментация и кэш
Три причины роста памяти, требующие разных действий:
| Причина | Признак | Действие |
|---|---|---|
| Page cache | Растёт file в memory.stat, anon стабилен | Ничего: освободится |
| Утечка | Растёт anon, растут объекты в tracemalloc | Искать в коде |
| Фрагментация | Растёт anon, объекты в tracemalloc стабильны | Настройка аллокатора, перезапуск |
Третий случай специфичен для Python: аллокатор pymalloc возвращает память ядру только целыми аренами по 1 МиБ. Если в арене остался хоть один живой объект, вся арена удерживается (урок 11.4).
Различить утечку и фрагментацию можно только сравнив два источника: anon из cgroup и сумму объектов из tracemalloc. Расхождение, растущее со временем, — фрагментация.
Внутренний механизм
Как OOM killer выбирает жертву
При исчерпании памяти в cgroup ядро перебирает процессы этой cgroup и считает для каждого oom_score — грубо пропорциональный потреблению памяти, скорректированный на oom_score_adj.
Убивается процесс с наибольшей оценкой. Обычно это самый прожорливый — но не обязательно PID 1.
Посмотреть оценку: cat /proc/PID/oom_score. Скорректировать: запись в /proc/PID/oom_score_adj (от -1000 до 1000).
Docker выставляет oom_score_adj для container'ов, повышая приоритет собственных процессов daemon: при общей нехватке памяти ядро скорее убьёт приложение, чем daemon.
Почему docker stats не показывает throttling
Колонка CPU % — это доля потреблённого времени, усреднённая за интервал между замерами (около секунды). Приостановки внутри секунды в среднем растворяются.
Данные о throttling существуют отдельно, в cpu.stat, и docker stats их не читает. Единственный способ увидеть — прочитать файл cgroup изнутри container'а или на host.
Команды и примеры
OOM: воспроизведение и три следа
mkdir -p /tmp/res && cd /tmp/res
cat > eat.py <<'PY'
"""Потребляет память до срабатывания OOM killer."""
import sys
import time
chunks = []
mib = 0
try:
while True:
chunks.append(bytearray(8 * 1024 * 1024))
# Касаемся страниц, иначе память не будет выделена физически
chunks[-1][::4096] = b"\x01" * (len(chunks[-1]) // 4096)
mib += 8
print(f"выделено {mib} МиБ", flush=True)
time.sleep(0.2)
except MemoryError:
print("MemoryError — аллокатор отказал", flush=True)
sys.exit(2)
PY
echo "═══ запуск с лимитом 64 МиБ ═══"
docker run -d --name oomtest --memory 64m --memory-swap 64m \
-e PYTHONUNBUFFERED=1 -v "$PWD/eat.py:/e.py:ro" \
python:3.13-slim python /e.py > /dev/null
sleep 12
echo "═══ след 1: состояние container ═══"
docker inspect oomtest --format \
' статус: {{.State.Status}}
код: {{.State.ExitCode}}
OOMKilled: {{.State.OOMKilled}}'
echo "═══ след 2: события Docker ═══"
docker events --since 2m --until 0s --filter 'container=oomtest' \
--format ' {{.Action}}' 2>/dev/null | tail -4
echo "═══ след 3: журнал ядра ═══"
if sudo dmesg -T 2>/dev/null | tail -40 | grep -i 'killed process' | tail -2; then
:
else
cat <<'TXT'
(dmesg требует прав root — недоступен)
При наличии доступа было бы видно:
Memory cgroup out of memory: Killed process 51204 (python3)
total-vm:1284512kB, anon-rss:62144kB, file-rss:8192kB
Это ЕДИНСТВЕННОЕ место, где видно, КАКОЙ процесс убит.
Важно, когда в container'е их несколько.
TXT
fi
echo "═══ что успел вывести процесс ═══"
docker logs oomtest 2>&1 | tail -2 | sed 's/^/ /'
docker rm -f oomtest > /dev/null
Ожидаемый вывод:
═══ запуск с лимитом 64 МиБ ═══
═══ след 1: состояние container ═══
статус: exited
код: 137
OOMKilled: true
═══ след 2: события Docker ═══
oom
die
═══ след 3: журнал ядра ═══
(dmesg требует прав root — недоступен)
При наличии доступа было бы видно:
Memory cgroup out of memory: Killed process 51204 (python3)
total-vm:1284512kB, anon-rss:62144kB, file-rss:8192kB
Это ЕДИНСТВЕННОЕ место, где видно, КАКОЙ процесс убит.
Важно, когда в container'е их несколько.
═══ что успел вывести процесс ═══
выделено 40 МиБ
выделено 48 МиБ
Три следа дополняют друг друга. OOMKilled: true подтверждает факт, событие oom даёт время, журнал ядра — имя процесса и его размер.
Обратите внимание на последнюю строку: приложение успело сообщить о 48 МиБ при лимите 64. Разница — это интерпретатор и накладные расходы; полезной памяти было меньше лимита.
137 без OOM: как отличить
cd /tmp/res
echo "═══ случай 1: убит по нехватке памяти ═══"
docker run -d --name kill-oom --memory 48m -e PYTHONUNBUFFERED=1 \
-v "$PWD/eat.py:/e.py:ro" python:3.13-slim python /e.py > /dev/null
sleep 10
docker inspect kill-oom --format ' код={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}'
echo "═══ случай 2: убит командой docker kill ═══"
docker run -d --name kill-cmd python:3.13-slim sleep 300 > /dev/null
sleep 2
docker kill kill-cmd > /dev/null
sleep 1
docker inspect kill-cmd --format ' код={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}'
echo "═══ случай 3: не успел завершиться по SIGTERM ═══"
docker run -d --name kill-timeout python:3.13-slim \
python -c "
import signal, time
signal.signal(signal.SIGTERM, lambda s, f: print('игнорирую SIGTERM', flush=True))
while True:
time.sleep(1)
" > /dev/null
sleep 3
docker stop -t 2 kill-timeout > /dev/null
docker inspect kill-timeout --format ' код={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}'
echo "═══ случай 4: штатное завершение по SIGTERM ═══"
docker run -d --name term-ok python:3.13-slim sleep 300 > /dev/null
sleep 2
docker stop -t 5 term-ok > /dev/null
docker inspect term-ok --format ' код={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}'
echo "═══ таблица различения ═══"
printf ' %-34s %-6s %-11s %s\n' "случай" "код" "OOMKilled" "как отличить"
printf ' %s\n' "────────────────────────────────────────────────────────────────────────────"
printf ' %-34s %-6s %-11s %s\n' "нехватка памяти в cgroup" "137" "true" "поле OOMKilled"
printf ' %-34s %-6s %-11s %s\n' "docker kill" "137" "false" "событие kill в events"
printf ' %-34s %-6s %-11s %s\n' "таймаут docker stop" "137" "false" "события stop, затем kill"
printf ' %-34s %-6s %-11s %s\n' "штатный SIGTERM" "143" "false" "код 143, не 137"
printf ' %-34s %-6s %-11s %s\n' "нехватка памяти на host" "137" "может быть false" "только dmesg"
docker rm -f kill-oom kill-cmd kill-timeout term-ok > /dev/null 2>&1
Ожидаемый вывод:
═══ случай 1: убит по нехватке памяти ═══
код=137 OOMKilled=true
═══ случай 2: убит командой docker kill ═══
код=137 OOMKilled=false
═══ случай 3: не успел завершиться по SIGTERM ═══
код=137 OOMKilled=false
═══ случай 4: штатное завершение по SIGTERM ═══
код=143 OOMKilled=false
═══ таблица различения ═══
случай код OOMKilled как отличить
────────────────────────────────────────────────────────────────────────────
нехватка памяти в cgroup 137 true поле OOMKilled
docker kill 137 false событие kill в events
таймаут docker stop 137 false события stop, затем kill
штатный SIGTERM 143 false код 143, не 137
нехватка памяти на host 137 может быть false только dmesg
Три разных причины дают один и тот же код 137. Различает их только OOMKilled плюс события.
Последняя строка таблицы — та, из-за которой нельзя останавливаться на OOMKilled: false.
CPU throttling при низкой средней загрузке
cd /tmp/res
cat > burst.py <<'PY'
"""Нагрузка всплесками: короткая работа, затем пауза.
Средняя загрузка низкая, но каждый всплеск исчерпывает квоту CFS
за долю периода — остаток периода процесс не выполняется.
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
def cpu_stat() -> dict[str, int]:
out = {}
for path in ("/sys/fs/cgroup/cpu.stat",
"/sys/fs/cgroup/cpu/cpu.stat"):
try:
for line in Path(path).read_text().splitlines():
k, _, v = line.partition(" ")
if v.strip().lstrip("-").isdigit():
out[k] = int(v)
return out
except OSError:
continue
return out
def burn(seconds: float) -> None:
end = time.monotonic() + seconds
x = 0
while time.monotonic() < end:
for _ in range(10_000):
x = (x * 31 + 7) % 1_000_003
if __name__ == "__main__":
duration = float(sys.argv[1]) if len(sys.argv) > 1 else 6.0
before = cpu_stat()
start = time.monotonic()
busy_total = 0.0
latencies = []
while time.monotonic() - start < duration:
t0 = time.monotonic()
burn(0.03) # всплеск работы 30 мс
latencies.append((time.monotonic() - t0) * 1000)
busy_total += 0.03
time.sleep(0.07) # пауза 70 мс
elapsed = time.monotonic() - start
after = cpu_stat()
delta = {k: after.get(k, 0) - before.get(k, 0) for k in after}
latencies.sort()
n = len(latencies)
print(json.dumps({
"длительность_с": round(elapsed, 2),
"полезная_работа_с": round(busy_total, 2),
"средняя_загрузка_%": round(busy_total / elapsed * 100, 1),
"всплесков": n,
"задержка_мс": {
"медиана": round(latencies[n // 2], 1),
"p95": round(latencies[int(n * 0.95)], 1),
"максимум": round(latencies[-1], 1),
},
"cpu_stat_дельта": {
"nr_periods": delta.get("nr_periods", 0),
"nr_throttled": delta.get("nr_throttled", 0),
"throttled_usec": delta.get("throttled_usec", 0),
},
"доля_периодов_с_throttling_%": round(
delta.get("nr_throttled", 0) / max(delta.get("nr_periods", 1), 1) * 100, 1),
}, ensure_ascii=False))
PY
show() {
python3 -c "
import json, sys
d = json.load(sys.stdin)
lat = d['задержка_мс']
cs = d['cpu_stat_дельта']
print(f\" средняя загрузка: {d['средняя_загрузка_%']} %\")
print(f\" задержка всплеска: медиана {lat['медиана']} мс, p95 {lat['p95']} мс, max {lat['максимум']} мс\")
print(f\" периодов CFS: {cs['nr_periods']}\")
print(f\" из них с throttling: {cs['nr_throttled']} ({d['доля_периодов_с_throttling_%']} %)\")
print(f\" суммарная остановка: {cs['throttled_usec'] / 1000:.0f} мс\")
"
}
echo "═══ без лимита CPU ═══"
docker run --rm -v "$PWD/burst.py:/b.py:ro" python:3.13-slim python /b.py 6 2>/dev/null | show
echo "═══ с лимитом --cpus=0.2 ═══"
docker run --rm --cpus 0.2 -v "$PWD/burst.py:/b.py:ro" \
python:3.13-slim python /b.py 6 2>/dev/null | show
echo "═══ вывод ═══"
cat <<'TXT'
Средняя загрузка в обоих случаях около 30 % — docker stats
показал бы примерно одно и то же.
Но с лимитом 0.2 CPU квота (20 мс на период 100 мс) исчерпывается
всплеском 30 мс, и процесс останавливается до конца периода.
Задержка всплеска вырастает в разы.
Симптом: высокая задержка при НИЗКОЙ средней загрузке.
Диагноз: только через cpu.stat, docker stats этого не покажет.
Порог внимания: nr_throttled / nr_periods выше 5 %.
TXT
Ожидаемый вывод:
═══ без лимита CPU ═══
средняя загрузка: 30.0 %
задержка всплеска: медиана 30.2 мс, p95 31.1 мс, max 34.8 мс
периодов CFS: 0
из них с throttling: 0 (0.0 %)
суммарная остановка: 0 мс
═══ с лимитом --cpus=0.2 ═══
средняя загрузка: 29.4 %
задержка всплеска: медиана 118.4 мс, p95 152.7 мс, max 171.2 мс
периодов CFS: 61
из них с throttling: 47 (77.0 %)
суммарная остановка: 3892 мс
═══ вывод ═══
Средняя загрузка в обоих случаях около 30 % — docker stats
показал бы примерно одно и то же.
Но с лимитом 0.2 CPU квота (20 мс на период 100 мс) исчерпывается
всплеском 30 мс, и процесс останавливается до конца периода.
Задержка всплеска вырастает в разы.
Симптом: высокая задержка при НИЗКОЙ средней загрузке.
Диагноз: только через cpu.stat, docker stats этого не покажет.
Порог внимания: nr_throttled / nr_periods выше 5 %.
TXT
Ключевые числа: средняя загрузка 29 % — и 77 % периодов с приостановкой, медиана задержки выросла с 30 до 118 мс.
Это ровно тот случай, когда мониторинг по средней загрузке CPU не показывает ничего, а пользователи жалуются на медленный сервис.
Исчерпание PID
cd /tmp/res
cat > threads.py <<'PY'
"""Создаёт потоки до исчерпания лимита PID."""
from __future__ import annotations
import json
import threading
import time
from pathlib import Path
def pids_state() -> tuple[str, str]:
def read(*paths: str) -> str:
for p in paths:
try:
return Path(p).read_text().strip()
except OSError:
continue
return "?"
return (read("/sys/fs/cgroup/pids.current", "/sys/fs/cgroup/pids/pids.current"),
read("/sys/fs/cgroup/pids.max", "/sys/fs/cgroup/pids/pids.max"))
stop = threading.Event()
def worker() -> None:
stop.wait()
if __name__ == "__main__":
created = 0
error = None
threads = []
try:
for _ in range(500):
t = threading.Thread(target=worker, daemon=True)
t.start()
threads.append(t)
created += 1
except RuntimeError as e:
error = f"{type(e).__name__}: {e}"
except OSError as e:
error = f"{type(e).__name__}: {e}"
cur, mx = pids_state()
print(json.dumps({
"создано_потоков": created,
"ошибка": error,
"pids_current": cur,
"pids_max": mx,
}, ensure_ascii=False))
stop.set()
PY
echo "═══ без ограничения PID ═══"
docker run --rm -v "$PWD/threads.py:/t.py:ro" python:3.13-slim python /t.py 2>/dev/null \
| python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" создано потоков: {d['создано_потоков']}\")
print(f\" pids: {d['pids_current']} из {d['pids_max']}\")
print(f\" ошибка: {d['ошибка'] or 'нет'}\")
"
echo "═══ с --pids-limit 64 ═══"
docker run --rm --pids-limit 64 -v "$PWD/threads.py:/t.py:ro" \
python:3.13-slim python /t.py 2>/dev/null \
| python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" создано потоков: {d['создано_потоков']}\")
print(f\" pids: {d['pids_current']} из {d['pids_max']}\")
print(f\" ошибка: {d['ошибка'] or 'нет'}\")
"
echo "═══ зачем ограничивать ═══"
cat <<'TXT'
Лимит считает ПРОЦЕССЫ И ПОТОКИ вместе. Пул из 50 потоков
плюс multiprocessing с 8 рабочими — это уже около 60.
Без лимита ошибка в коде, порождающая процессы в цикле,
исчерпает PID на ВСЁМ host: система перестанет отвечать,
потому что новые процессы не создаются даже для оболочки.
Разумное значение: измеренный максимум × 2.
Проверить текущее: docker exec КОНТЕЙНЕР cat /sys/fs/cgroup/pids.current
TXT
Ожидаемый вывод:
═══ без ограничения PID ═══
создано потоков: 500
pids: 502 из max
ошибка: нет
═══ с --pids-limit 64 ═══
создано потоков: 62
pids: 64 из 64
ошибка: RuntimeError: can't start new thread
═══ зачем ограничивать ═══
Лимит считает ПРОЦЕССЫ И ПОТОКИ вместе. Пул из 50 потоков
плюс multiprocessing с 8 рабочими — это уже около 60.
Без лимита ошибка в коде, порождающая процессы в цикле,
исчерпает PID на ВСЁМ host: система перестанет отвечать,
потому что новые процессы не создаются даже для оболочки.
Разумное значение: измеренный максимум × 2.
Проверить текущее: docker exec КОНТЕЙНЕР cat /sys/fs/cgroup/pids.current
TXT
Сообщение can't start new thread — то, что вы увидите в логах приложения. Связать его с --pids-limit без этого знания непросто.
Файловые дескрипторы
cd /tmp/res
cat > fds.py <<'PY'
"""Открывает файлы, не закрывая их, — воспроизведение утечки дескрипторов."""
from __future__ import annotations
import json
import os
import resource
import sys
from pathlib import Path
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
opened = []
error = None
try:
for i in range(min(soft + 100, 20000)):
opened.append(open("/etc/hostname", "rb"))
except OSError as e:
error = f"{type(e).__name__}: {e.strerror} (errno {e.errno})"
print(json.dumps({
"лимит_мягкий": soft,
"лимит_жёсткий": hard,
"открыто": len(opened),
"дескрипторов_в_proc": len(os.listdir("/proc/self/fd")),
"ошибка": error,
}, ensure_ascii=False))
PY
echo "═══ лимит по умолчанию ═══"
docker run --rm -v "$PWD/fds.py:/f.py:ro" python:3.13-slim python /f.py 2>/dev/null \
| python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" мягкий лимит: {d['лимит_мягкий']}, жёсткий: {d['лимит_жёсткий']}\")
print(f\" открыто: {d['открыто']}\")
print(f\" ошибка: {d['ошибка'] or 'нет'}\")
"
echo "═══ с --ulimit nofile=256:512 ═══"
docker run --rm --ulimit nofile=256:512 -v "$PWD/fds.py:/f.py:ro" \
python:3.13-slim python /f.py 2>/dev/null \
| python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" мягкий лимит: {d['лимит_мягкий']}, жёсткий: {d['лимит_жёсткий']}\")
print(f\" открыто: {d['открыто']}\")
print(f\" ошибка: {d['ошибка'] or 'нет'}\")
"
echo "═══ проверка работающего container ═══"
docker run -d --name fdcheck python:3.13-slim sleep 60 > /dev/null
sleep 2
docker exec fdcheck sh -c '
printf " открыто дескрипторов: %s\n" "$(ls /proc/1/fd | wc -l)"
grep "open files" /proc/1/limits | sed "s/^/ /"
'
docker rm -f fdcheck > /dev/null
echo "═══ почему лимит по умолчанию скрывает утечку ═══"
cat <<'TXT'
Container наследует лимиты daemon, часто около миллиона.
Утечка дескрипторов при таком лимите проявится через часы
или дни — когда связь с изменением кода уже потеряна.
Явный небольшой лимит превращает медленную деградацию
в быстрый и заметный отказ. Это желательно.
Мониторинг: ls /proc/1/fd | wc -l в течение часа.
Монотонный рост при постоянной нагрузке = утечка.
TXT
Ожидаемый вывод:
═══ лимит по умолчанию ═══
мягкий лимит: 1048576, жёсткий: 1048576
открыто: 20000
ошибка: нет
═══ с --ulimit nofile=256:512 ═══
мягкий лимит: 256, жёсткий: 512
открыто: 246
ошибка: OSError: Too many open files (errno 24)
═══ проверка работающего container ═══
открыто дескрипторов: 4
Max open files 1048576 1048576 files
═══ почему лимит по умолчанию скрывает утечку ═══
Container наследует лимиты daemon, часто около миллиона.
Утечка дескрипторов при таком лимите проявится через часы
или дни — когда связь с изменением кода уже потеряна.
Явный небольшой лимит превращает медленную деградацию
в быстрый и заметный отказ. Это желательно.
Мониторинг: ls /proc/1/fd | wc -l в течение часа.
Монотонный рост при постоянной нагрузке = утечка.
TXT
Лимит 1048576 по умолчанию — не защита, а отсрочка. Утечка дойдёт до него нескоро, и к этому моменту причину найти сложнее.
Утечка против фрагментации
cd /tmp/res
cat > leaktest.py <<'PY'
"""Различает утечку и фрагментацию по расхождению двух источников.
tracemalloc считает объекты, которые Python СЧИТАЕТ живыми.
anon из cgroup считает страницы, которые ядро НЕ ЗАБРАЛО обратно.
Утечка: оба растут согласованно.
Фрагментация: anon растёт, tracemalloc — нет.
"""
from __future__ import annotations
import gc
import json
import sys
import tracemalloc
from pathlib import Path
LEAKED: list[bytes] = []
def anon_bytes() -> int:
for p in ("/sys/fs/cgroup/memory.stat", "/sys/fs/cgroup/memory/memory.stat"):
try:
for line in Path(p).read_text().splitlines():
k, _, v = line.partition(" ")
if k in ("anon", "total_rss") and v.strip().isdigit():
return int(v)
except OSError:
continue
return 0
def traced_bytes() -> int:
current, _ = tracemalloc.get_traced_memory()
return current
def cycle_leak(n: int) -> None:
"""Настоящая утечка: объекты остаются достижимыми."""
for _ in range(n):
LEAKED.append(bytes(64 * 1024))
def cycle_fragment(n: int) -> None:
"""Фрагментация: объекты освобождаются, но арены удерживаются."""
keep = []
for i in range(n * 40):
block = bytes(4096)
if i % 40 == 0: # каждый сороковой остаётся жив
keep.append(block)
del keep
gc.collect()
def measure(label: str) -> dict[str, object]:
gc.collect()
return {"этап": label, "anon": anon_bytes(), "traced": traced_bytes()}
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "leak"
tracemalloc.start()
stages = [measure("старт")]
for round_no in range(1, 4):
if mode == "leak":
cycle_leak(120)
else:
cycle_fragment(120)
stages.append(measure(f"цикл {round_no}"))
base = stages[0]
last = stages[-1]
d_anon = last["anon"] - base["anon"]
d_traced = last["traced"] - base["traced"]
print(json.dumps({
"режим": mode,
"этапы": stages,
"прирост_anon": d_anon,
"прирост_traced": d_traced,
"расхождение": d_anon - d_traced,
"диагноз": ("утечка" if d_traced > d_anon * 0.5
else "фрагментация или накладные расходы"),
}, ensure_ascii=False))
PY
report() {
python3 -c "
import json, sys
d = json.load(sys.stdin)
mib = lambda n: f'{n / 1024 / 1024:6.1f}'
print(f\" {'этап':<12} {'anon':>10} {'tracemalloc':>13}\")
for s in d['этапы']:
print(f\" {s['этап']:<12} {mib(s['anon']):>10} {mib(s['traced']):>13}\")
print(f\" прирост anon: {mib(d['прирост_anon'])} МиБ\")
print(f\" прирост tracemalloc: {mib(d['прирост_traced'])} МиБ\")
print(f\" ДИАГНОЗ: {d['диагноз']}\")
"
}
echo "═══ настоящая утечка ═══"
docker run --rm --memory 512m -v "$PWD/leaktest.py:/l.py:ro" \
python:3.13-slim python /l.py leak 2>/dev/null | report
echo "═══ фрагментация ═══"
docker run --rm --memory 512m -v "$PWD/leaktest.py:/l.py:ro" \
python:3.13-slim python /l.py fragment 2>/dev/null | report
echo "═══ как читать результат ═══"
cat <<'TXT'
Оба случая выглядят одинаково снаружи: docker stats показывает
рост MEM USAGE. Различает их только сравнение двух источников.
Утечка: anon и tracemalloc растут согласованно
→ искать в коде объекты, которые не освобождаются
Фрагментация: anon растёт, tracemalloc почти нет
→ память освобождена Python, но не возвращена ядру;
причина — арены pymalloc с одним живым объектом
Действия различаются принципиально: в первом случае правят код,
во втором — меняют схему выделения или мирятся с перезапусками.
TXT
cd /tmp && rm -rf /tmp/res
Ожидаемый вывод:
═══ настоящая утечка ═══
этап anon tracemalloc
старт 14.2 0.1
цикл 1 22.6 7.6
цикл 2 30.9 15.2
цикл 3 39.1 22.7
прирост anon: 24.9 МиБ
прирост tracemalloc: 22.6 МиБ
ДИАГНОЗ: утечка
═══ фрагментация ═══
этап anon tracemalloc
старт 14.1 0.1
цикл 1 19.4 0.5
цикл 2 19.7 0.5
цикл 3 19.9 0.5
прирост anon: 5.8 МиБ
прирост tracemalloc: 0.4 МиБ
ДИАГНОЗ: фрагментация или накладные расходы
═══ как читать результат ═══
Оба случая выглядят одинаково снаружи: docker stats показывает
рост MEM USAGE. Различает их только сравнение двух источников.
Утечка: anon и tracemalloc растут согласованно
→ искать в коде объекты, которые не освобождаются
Фрагментация: anon растёт, tracemalloc почти нет
→ память освобождена Python, но не возвращена ядру;
причина — арены pymalloc с одним живым объектом
Действия различаются принципиально: в первом случае правят код,
во втором — меняют схему выделения или мирятся с перезапусками.
TXT
Столбцы говорят сами за себя. При утечке оба числа растут вместе; при фрагментации tracemalloc стоит на месте, а anon растёт.
Практическое упражнение
Задание. Постройте диагностику нехватки ресурсов, дающую диагноз, а не симптом.
Требования:
- Воспроизвести OOM и найти след во всех доступных местах.
- Показать четыре разных причины, дающие код
137или143, и способ различить их. - Показать CPU throttling при средней загрузке ниже 40 % и измерить его через
cpu.stat. - Показать исчерпание PID и сообщение, которое увидит приложение.
- Различить утечку и фрагментацию памяти по двум источникам.
- Написать диагностический инструмент, ставящий диагноз по коду выхода и состоянию.
Подсказки
Подсказка 1
Для пункта 2 нужны четыре container'а: с лимитом памяти, убитый docker kill, игнорирующий SIGTERM, и обычный.
Подсказка 2
Пункт 3: чтобы средняя загрузка была низкой, работайте всплесками с паузами. Квота исчерпывается всплеском, пауза снижает среднее.
Подсказка 3
Для пункта 5 сравнивайте anon из memory.stat с tracemalloc.get_traced_memory().
Решение
Показать решение
mkdir -p /tmp/reslab && cd /tmp/reslab
# ─── Потребитель памяти ───────────────────────────────────────────────
cat > eater.py <<'PY'
"""Потребляет память шагами до OOM, сообщая прогресс."""
import sys
import time
blocks = []
mib = 0
while True:
blocks.append(bytearray(4 * 1024 * 1024))
blocks[-1][::4096] = b"\x01" * (len(blocks[-1]) // 4096)
mib += 4
print(f"выделено {mib} МиБ", flush=True)
time.sleep(0.15)
PY
# ─── Нагрузка всплесками ──────────────────────────────────────────────
cat > burst.py <<'PY'
"""Всплески работы с паузами: низкая средняя загрузка, высокий throttling."""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
def cpu_stat() -> dict[str, int]:
for path in ("/sys/fs/cgroup/cpu.stat", "/sys/fs/cgroup/cpu/cpu.stat"):
try:
out = {}
for line in Path(path).read_text().splitlines():
k, _, v = line.partition(" ")
if v.strip().lstrip("-").isdigit():
out[k] = int(v)
return out
except OSError:
continue
return {}
def burn(seconds: float) -> None:
end = time.monotonic() + seconds
x = 0
while time.monotonic() < end:
for _ in range(20_000):
x = (x * 1103515245 + 12345) % 2147483648
if __name__ == "__main__":
duration = float(sys.argv[1]) if len(sys.argv) > 1 else 6.0
burst_s = 0.03
pause_s = 0.07
before = cpu_stat()
start = time.monotonic()
latencies = []
while time.monotonic() - start < duration:
t0 = time.monotonic()
burn(burst_s)
latencies.append((time.monotonic() - t0) * 1000)
time.sleep(pause_s)
elapsed = time.monotonic() - start
after = cpu_stat()
delta = {k: after.get(k, 0) - before.get(k, 0) for k in after}
latencies.sort()
n = len(latencies)
periods = max(delta.get("nr_periods", 0), 0)
throttled = max(delta.get("nr_throttled", 0), 0)
print(json.dumps({
"средняя_загрузка": round(len(latencies) * burst_s / elapsed * 100, 1),
"всплесков": n,
"задержка": {
"медиана": round(latencies[n // 2], 1),
"p95": round(latencies[min(int(n * 0.95), n - 1)], 1),
"максимум": round(latencies[-1], 1),
},
"nr_periods": periods,
"nr_throttled": throttled,
"throttled_ms": round(delta.get("throttled_usec", 0) / 1000, 0),
"доля_throttled": round(throttled / periods * 100, 1) if periods else 0.0,
}, ensure_ascii=False))
PY
# ─── Потоки ───────────────────────────────────────────────────────────
cat > threads.py <<'PY'
"""Создаёт потоки до отказа; сообщает точное сообщение об ошибке."""
from __future__ import annotations
import json
import threading
from pathlib import Path
def read_first(*paths: str) -> str:
for p in paths:
try:
return Path(p).read_text().strip()
except OSError:
continue
return "?"
stop = threading.Event()
created = 0
error = None
try:
for _ in range(400):
threading.Thread(target=stop.wait, daemon=True).start()
created += 1
except (RuntimeError, OSError) as e:
error = f"{type(e).__name__}: {e}"
print(json.dumps({
"создано": created,
"ошибка": error,
"pids_current": read_first("/sys/fs/cgroup/pids.current",
"/sys/fs/cgroup/pids/pids.current"),
"pids_max": read_first("/sys/fs/cgroup/pids.max",
"/sys/fs/cgroup/pids/pids.max"),
}, ensure_ascii=False))
stop.set()
PY
# ─── Утечка против фрагментации ───────────────────────────────────────
cat > memdiag.py <<'PY'
"""Диагноз роста памяти по двум независимым источникам.
tracemalloc — что Python считает живым.
anon из cgroup — что ядро не забрало обратно.
Согласованный рост = утечка. Расхождение = фрагментация.
"""
from __future__ import annotations
import gc
import json
import sys
import tracemalloc
from pathlib import Path
HELD: list[bytes] = []
def anon() -> int:
for p in ("/sys/fs/cgroup/memory.stat", "/sys/fs/cgroup/memory/memory.stat"):
try:
for line in Path(p).read_text().splitlines():
k, _, v = line.partition(" ")
if k in ("anon", "total_rss") and v.strip().isdigit():
return int(v)
except OSError:
continue
return 0
def probe(label: str) -> dict[str, object]:
gc.collect()
return {"этап": label, "anon": anon(), "traced": tracemalloc.get_traced_memory()[0]}
def do_leak() -> None:
for _ in range(150):
HELD.append(bytes(64 * 1024))
def do_fragment() -> None:
keep = []
for i in range(6000):
b = bytes(4096)
if i % 50 == 0:
keep.append(b)
del keep
gc.collect()
if __name__ == "__main__":
mode = sys.argv[1]
tracemalloc.start()
stages = [probe("старт")]
for i in range(1, 4):
(do_leak if mode == "leak" else do_fragment)()
stages.append(probe(f"цикл {i}"))
d_anon = stages[-1]["anon"] - stages[0]["anon"]
d_traced = stages[-1]["traced"] - stages[0]["traced"]
ratio = d_traced / d_anon if d_anon > 0 else 0.0
print(json.dumps({
"режим": mode,
"этапы": stages,
"прирост_anon": d_anon,
"прирост_traced": d_traced,
"отношение": round(ratio, 2),
"диагноз": "утечка" if ratio > 0.5 else "фрагментация",
}, ensure_ascii=False))
PY
# ─── Диагностический инструмент ───────────────────────────────────────
cat > diagnose.sh <<'SH'
#!/usr/bin/env bash
# Диагноз по состоянию container: не симптом, а причина.
set -uo pipefail
TARGET="${1:?укажите container}"
info="$(docker inspect "$TARGET" --format \
'{{.State.Status}}|{{.State.ExitCode}}|{{.State.OOMKilled}}|{{.RestartCount}}|{{.HostConfig.Memory}}|{{.HostConfig.NanoCpus}}|{{.HostConfig.PidsLimit}}' 2>/dev/null)" \
|| { echo " container не найден"; exit 3; }
IFS='|' read -r status code oom restarts mem cpus pids <<EOF
$info
EOF
printf '\n Диагностика: %s\n' "$TARGET"
printf ' статус=%s код=%s OOMKilled=%s перезапусков=%s\n' \
"$status" "$code" "$oom" "$restarts"
verdict=""
action=""
if [ "$oom" = "true" ]; then
verdict="убит по нехватке памяти в cgroup"
action="увеличить --memory или найти причину роста (см. anon против tracemalloc)"
elif [ "$code" = "137" ]; then
# 137 без OOMKilled: kill, таймаут stop или OOM на host
kills="$(docker events --since 30m --until 0s --filter "container=$TARGET" \
--format '{{.Action}}' 2>/dev/null | grep -c '^kill$' || echo 0)"
stops="$(docker events --since 30m --until 0s --filter "container=$TARGET" \
--format '{{.Action}}' 2>/dev/null | grep -c '^stop$' || echo 0)"
if [ "${stops:-0}" -gt 0 ]; then
verdict="не завершился по SIGTERM за отведённое время — добит SIGKILL"
action="обработать SIGTERM в приложении или увеличить --time у docker stop"
elif [ "${kills:-0}" -gt 0 ]; then
verdict="остановлен командой docker kill"
action="проверить, кто и зачем выполнил kill"
else
verdict="SIGKILL неизвестного происхождения — возможен OOM на уровне host"
action="проверить dmesg: sudo dmesg -T | grep -i 'killed process'"
fi
elif [ "$code" = "143" ]; then
verdict="штатное завершение по SIGTERM"
action="норма; если хотите код 0 — обработайте сигнал и выйдите через sys.exit(0)"
elif [ "$code" = "0" ]; then
verdict="завершился штатно"
action="норма"
elif [ "$status" = "running" ]; then
verdict="работает"
action=""
else
verdict="завершился с кодом $code"
action="смотреть docker logs: причина в приложении"
fi
printf ' ДИАГНОЗ: %s\n' "$verdict"
[ -n "$action" ] && printf ' ДЕЙСТВИЕ: %s\n' "$action"
# Проверки для работающих container'ов
if [ "$status" = "running" ]; then
cpu_stat="$(docker exec "$TARGET" sh -c \
'cat /sys/fs/cgroup/cpu.stat 2>/dev/null || cat /sys/fs/cgroup/cpu/cpu.stat 2>/dev/null' 2>/dev/null || true)"
if [ -n "$cpu_stat" ]; then
periods="$(echo "$cpu_stat" | awk '/nr_periods/ {print $2}')"
thr="$(echo "$cpu_stat" | awk '/nr_throttled/ {print $2}')"
if [ "${periods:-0}" -gt 0 ] 2>/dev/null; then
pct="$(python3 -c "print(round($thr / $periods * 100, 1))" 2>/dev/null || echo 0)"
printf ' CPU throttling: %s из %s периодов (%s %%)\n' "$thr" "$periods" "$pct"
over="$(python3 -c "print(1 if $pct > 5 else 0)" 2>/dev/null || echo 0)"
[ "$over" = "1" ] && \
printf ' ⚠ выше 5 %%: лимит CPU ограничивает работу, растёт задержка\n'
fi
fi
pids_cur="$(docker exec "$TARGET" sh -c \
'cat /sys/fs/cgroup/pids.current 2>/dev/null' 2>/dev/null || echo "")"
pids_max="$(docker exec "$TARGET" sh -c \
'cat /sys/fs/cgroup/pids.max 2>/dev/null' 2>/dev/null || echo "")"
if [ -n "$pids_cur" ] && [ "$pids_max" != "max" ] && [ -n "$pids_max" ]; then
printf ' PID: %s из %s\n' "$pids_cur" "$pids_max"
near="$(python3 -c "print(1 if $pids_cur > $pids_max * 0.8 else 0)" 2>/dev/null || echo 0)"
[ "$near" = "1" ] && printf ' ⚠ выше 80 %% лимита PID\n'
fi
fi
printf '\n'
case "$verdict" in
*"нехватк"*|*"OOM"*) exit 2 ;;
*"не завершился"*|*"неизвестного"*) exit 1 ;;
*) exit 0 ;;
esac
SH
chmod +x diagnose.sh
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Требование 1: OOM и его следы ═══\n'
docker run -d --name r-oom --memory 64m --memory-swap 64m \
-e PYTHONUNBUFFERED=1 -v "$PWD/eater.py:/e.py:ro" \
python:3.13-slim python /e.py > /dev/null
sleep 14
oom_flag="$(docker inspect r-oom --format '{{.State.OOMKilled}}')"
oom_code="$(docker inspect r-oom --format '{{.State.ExitCode}}')"
printf ' след 1 (inspect): OOMKilled=%s код=%s\n' "$oom_flag" "$oom_code"
oom_events="$(docker events --since 3m --until 0s --filter 'container=r-oom' \
--format '{{.Action}}' 2>/dev/null | tr '\n' ' ')"
printf ' след 2 (events): %s\n' "$oom_events"
if sudo dmesg -T 2>/dev/null | grep -i 'killed process' | tail -1 > dmesg.txt 2>/dev/null \
&& [ -s dmesg.txt ]; then
printf ' след 3 (dmesg): %s\n' "$(cut -c1-70 < dmesg.txt)"
dmesg_ok=1
else
printf ' след 3 (dmesg): НЕДОСТУПЕН (нужны права root)\n'
dmesg_ok=0
fi
printf ' последний вывод приложения: %s\n' "$(docker logs r-oom 2>&1 | tail -1)"
[ "$oom_flag" = "true" ] && [ "$oom_code" = "137" ] \
&& ok "OOM подтверждён двумя следами (dmesg доступен: $dmesg_ok)" \
|| bad "OOMKilled=$oom_flag код=$oom_code"
printf '\n═══ Требование 2: четыре причины кода 137/143 ═══\n'
docker run -d --name r-kill python:3.13-slim sleep 300 > /dev/null
sleep 2 && docker kill r-kill > /dev/null && sleep 1
docker run -d --name r-timeout python:3.13-slim python -c "
import signal, time
signal.signal(signal.SIGTERM, lambda s, f: None)
while True: time.sleep(1)
" > /dev/null
sleep 3 && docker stop -t 2 r-timeout > /dev/null
docker run -d --name r-term python:3.13-slim sleep 300 > /dev/null
sleep 2 && docker stop -t 5 r-term > /dev/null
printf ' %-14s %-6s %-11s %s\n' "container" "код" "OOMKilled" "причина"
printf ' %s\n' "──────────────────────────────────────────────────────────────────"
for c in r-oom r-kill r-timeout r-term; do
line="$(docker inspect "$c" --format '{{.State.ExitCode}}|{{.State.OOMKilled}}' 2>/dev/null)"
code="${line%%|*}"; oomf="${line##*|}"
case "$c" in
r-oom) why="нехватка памяти в cgroup" ;;
r-kill) why="docker kill" ;;
r-timeout) why="таймаут docker stop" ;;
r-term) why="штатный SIGTERM" ;;
esac
printf ' %-14s %-6s %-11s %s\n' "$c" "$code" "$oomf" "$why"
done
codes="$(for c in r-oom r-kill r-timeout r-term; do
docker inspect "$c" --format '{{.State.ExitCode}}'; done | tr '\n' ' ')"
[ "$codes" = "137 137 137 143 " ] \
&& ok "три причины дают 137, штатная — 143; различает только OOMKilled и events" \
|| bad "коды: $codes"
printf '\n═══ Требование 3: throttling при низкой загрузке ═══\n'
show_burst() {
python3 -c "
import json, sys
d = json.load(sys.stdin)
l = d['задержка']
print(f\" загрузка {d['средняя_загрузка']:>5} % \"
f\"задержка med={l['медиана']:>6} p95={l['p95']:>6} max={l['максимум']:>6} мс\")
print(f\" throttled {d['nr_throttled']}/{d['nr_periods']} периодов \"
f\"({d['доля_throttled']} %), остановка {d['throttled_ms']:.0f} мс\")
print(d['доля_throttled'], d['средняя_загрузка'])
"
}
printf ' без лимита:\n'
no_limit="$(docker run --rm -v "$PWD/burst.py:/b.py:ro" python:3.13-slim \
python /b.py 6 2>/dev/null)"
echo "$no_limit" | show_burst | head -2
printf ' с --cpus=0.2:\n'
with_limit="$(docker run --rm --cpus 0.2 -v "$PWD/burst.py:/b.py:ro" \
python:3.13-slim python /b.py 6 2>/dev/null)"
echo "$with_limit" | show_burst | head -2
thr_pct="$(echo "$with_limit" | python3 -c "import json,sys; print(json.load(sys.stdin)['доля_throttled'])")"
load_pct="$(echo "$with_limit" | python3 -c "import json,sys; print(json.load(sys.stdin)['средняя_загрузка'])")"
low_load="$(python3 -c "print(1 if $load_pct < 40 else 0)")"
high_thr="$(python3 -c "print(1 if $thr_pct > 5 else 0)")"
printf ' средняя загрузка %s %%, throttling %s %%\n' "$load_pct" "$thr_pct"
[ "$low_load" = "1" ] && [ "$high_thr" = "1" ] \
&& ok "throttling при загрузке ниже 40 % — docker stats этого не покажет" \
|| bad "загрузка=$load_pct throttling=$thr_pct"
printf '\n═══ Требование 4: исчерпание PID ═══\n'
for limit in "" "--pids-limit 48"; do
label="${limit:-без ограничения}"
printf ' %-22s ' "$label"
docker run --rm $limit -v "$PWD/threads.py:/t.py:ro" python:3.13-slim \
python /t.py 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
err = d['ошибка'] or 'нет'
print(f\"создано {d['создано']:>3}, pids {d['pids_current']}/{d['pids_max']}, {err[:40]}\")
"
done
limited_err="$(docker run --rm --pids-limit 48 -v "$PWD/threads.py:/t.py:ro" \
python:3.13-slim python /t.py 2>/dev/null \
| python3 -c "import json,sys; print(json.load(sys.stdin)['ошибка'] or '')")"
case "$limited_err" in
*"can't start new thread"*|*"Resource temporarily"*)
ok "приложение видит: ${limited_err:0:44}" ;;
*) bad "неожиданная ошибка: $limited_err" ;;
esac
printf '\n═══ Требование 5: утечка против фрагментации ═══\n'
diag() {
python3 -c "
import json, sys
d = json.load(sys.stdin)
mib = lambda n: n / 1024 / 1024
print(f\" прирост anon {mib(d['прирост_anon']):6.1f} МиБ, \"
f\"tracemalloc {mib(d['прирост_traced']):6.1f} МиБ, \"
f\"отношение {d['отношение']:.2f} → {d['диагноз'].upper()}\")
print(d['диагноз'])
"
}
printf ' утечка:\n'
leak_res="$(docker run --rm --memory 512m -v "$PWD/memdiag.py:/m.py:ro" \
python:3.13-slim python /m.py leak 2>/dev/null)"
echo "$leak_res" | diag | head -1
printf ' фрагментация:\n'
frag_res="$(docker run --rm --memory 512m -v "$PWD/memdiag.py:/m.py:ro" \
python:3.13-slim python /m.py fragment 2>/dev/null)"
echo "$frag_res" | diag | head -1
d_leak="$(echo "$leak_res" | python3 -c "import json,sys; print(json.load(sys.stdin)['диагноз'])")"
d_frag="$(echo "$frag_res" | python3 -c "import json,sys; print(json.load(sys.stdin)['диагноз'])")"
[ "$d_leak" = "утечка" ] && [ "$d_frag" = "фрагментация" ] \
&& ok "два случая различены сравнением anon и tracemalloc" \
|| bad "диагнозы: $d_leak и $d_frag"
printf '\n═══ Требование 6: диагностический инструмент ═══\n'
for c in r-oom r-timeout r-term; do
./diagnose.sh "$c" | grep -E 'Диагностика|ДИАГНОЗ|ДЕЙСТВИЕ' | sed 's/^/ /'
done
docker run -d --name r-live --cpus 0.2 --pids-limit 64 \
-v "$PWD/burst.py:/b.py:ro" python:3.13-slim python /b.py 30 > /dev/null
sleep 8
printf ' работающий container с лимитом CPU:\n'
./diagnose.sh r-live | grep -E 'ДИАГНОЗ|throttling|PID:|⚠' | sed 's/^/ /'
docker rm -f r-live > /dev/null
./diagnose.sh r-oom > /dev/null 2>&1; rc_oom=$?
./diagnose.sh r-term > /dev/null 2>&1; rc_term=$?
printf ' коды возврата: OOM=%s штатный=%s\n' "$rc_oom" "$rc_term"
[ "$rc_oom" -eq 2 ] && [ "$rc_term" -eq 0 ] \
&& ok "инструмент ставит диагноз и различает тяжесть кодом возврата" \
|| bad "коды: $rc_oom и $rc_term"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
[ "$dmesg_ok" = "0" ] && echo " примечание: журнал ядра не читался — нет прав root"
docker rm -f r-oom r-kill r-timeout r-term > /dev/null 2>&1
cd /tmp && rm -rf /tmp/reslab
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: OOM и его следы ═══
след 1 (inspect): OOMKilled=true код=137
след 2 (events): create start oom die
след 3 (dmesg): НЕДОСТУПЕН (нужны права root)
последний вывод приложения: выделено 44 МиБ
✓ OOM подтверждён двумя следами (dmesg доступен: 0)
═══ Требование 2: четыре причины кода 137/143 ═══
container код OOMKilled причина
──────────────────────────────────────────────────────────────────
r-oom 137 true нехватка памяти в cgroup
r-kill 137 false docker kill
r-timeout 137 false таймаут docker stop
r-term 143 false штатный SIGTERM
✓ три причины дают 137, штатная — 143; различает только OOMKilled и events
═══ Требование 3: throttling при низкой загрузке ═══
без лимита:
загрузка 30.1 % задержка med= 30.3 p95= 31.4 max= 36.2 мс
throttled 0/0 периодов (0.0 %), остановка 0 мс
с --cpus=0.2:
загрузка 29.6 % задержка med= 121.7 p95= 158.3 max= 176.4 мс
throttled 46/60 периодов (76.7 %), остановка 3915 мс
средняя загрузка 29.6 %, throttling 76.7 %
✓ throttling при загрузке ниже 40 % — docker stats этого не покажет
═══ Требование 4: исчерпание PID ═══
без ограничения создано 400, pids 402/max, нет
--pids-limit 48 создано 46, pids 48/48, RuntimeError: can't start new thread
✓ приложение видит: RuntimeError: can't start new thread
═══ Требование 5: утечка против фрагментации ═══
утечка:
прирост anon 11.4 МиБ, tracemalloc 9.6 МиБ, отношение 0.84 → УТЕЧКА
фрагментация:
прирост anon 4.7 МиБ, tracemalloc 0.2 МиБ, отношение 0.04 → ФРАГМЕНТАЦИЯ
✓ два случая различены сравнением anon и tracemalloc
═══ Требование 6: диагностический инструмент ═══
Диагностика: r-oom
ДИАГНОЗ: убит по нехватке памяти в cgroup
ДЕЙСТВИЕ: увеличить --memory или найти причину роста (см. anon против tracemalloc)
Диагностика: r-timeout
ДИАГНОЗ: не завершился по SIGTERM за отведённое время — добит SIGKILL
ДЕЙСТВИЕ: обработать SIGTERM в приложении или увеличить --time у docker stop
Диагностика: r-term
ДИАГНОЗ: штатное завершение по SIGTERM
ДЕЙСТВИЕ: норма; если хотите код 0 — обработайте сигнал и выйдите через sys.exit(0)
работающий container с лимитом CPU:
ДИАГНОЗ: работает
CPU throttling: 44 из 58 периодов (75.9 %)
⚠ выше 5 %: лимит CPU ограничивает работу, растёт задержка
PID: 3 из 64
коды возврата: OOM=2 штатный=0
✓ инструмент ставит диагноз и различает тяжесть кодом возврата
═══ ИТОГ ═══
все требования выполнены
примечание: журнал ядра не читался — нет прав root
Все требования выполнены; журнал ядра прочитать не удалось, и это отмечено отдельной строкой.
Требование 3 — главный результат урока. Загрузка 29,6 % и throttling 76,7 % в одной строке: мониторинг по средней загрузке CPU показал бы, что всё в порядке, тогда как медиана задержки выросла в четыре раза.
Три решения, определяющие качество.
Нагрузка построена всплесками с паузами, а не постоянной. Постоянная нагрузка при --cpus=0.2 дала бы загрузку 20 % и throttling — но связь была бы очевидной: «уперлись в лимит». Всплески воспроизводят реальную картину веб-сервиса: короткая обработка запроса, затем ожидание. Именно она даёт низкое среднее и высокий throttling одновременно, а это и есть случай, который не диагностируют.
Диагноз кода 137 строится по событиям, а не только по OOMKilled. Поле различает лишь два случая из четырёх. Инструмент дополнительно смотрит docker events: наличие события stop перед kill означает таймаут, наличие только kill — ручную остановку, отсутствие обоих — вероятный OOM на уровне host. Последний вывод не утверждается как факт: он ведёт к dmesg.
Различение утечки и фрагментации построено на отношении двух приростов, а не на пороге для каждого. Абсолютные значения зависят от нагрузки и длительности; отношение tracemalloc / anon — нет. При утечке оно близко к единице, при фрагментации — к нулю, и порог 0,5 разделяет их устойчиво.
Чего решение не делает. dmesg не читался — на этой машине нет прав root, и решение об этом сообщает, а не имитирует вывод. Случай «OOM на уровне host» не воспроизводился: для него нужно исчерпать память всей машины. Throttling измерен на одном сочетании всплеска и паузы; зависимость от их соотношения не исследована. Диагноз утечки различает два случая из трёх — рост page cache в тесте не участвует, хотя на практике он самая частая причина ложной тревоги. Наконец, инструмент ставит диагноз по состоянию, но не предсказывает: container, который скоро упрётся в лимит, он покажет как работающий.
Проверка результата
docker inspect КОНТЕЙНЕР --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
docker exec КОНТЕЙНЕР sh -c 'cat /sys/fs/cgroup/cpu.stat'
docker exec КОНТЕЙНЕР sh -c 'cat /sys/fs/cgroup/pids.current /sys/fs/cgroup/pids.max'
docker exec КОНТЕЙНЕР sh -c 'ls /proc/1/fd | wc -l'
sudo dmesg -T | grep -i 'killed process' | tail -3
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
137 считают синонимом OOM | Частый случай | Три причины дают 137; проверять OOMKilled |
OOMKilled: false считают доказательством | Логично предположить | OOM на host его не выставляет; смотреть dmesg |
Ищут throttling в docker stats | Естественный инструмент | Данные только в cpu.stat |
| Считают низкую загрузку CPU признаком здоровья | Число выглядит хорошо | Throttling бывает при 30 % |
143 считают ошибкой | Ненулевой код | Штатное завершение по SIGTERM |
Не ставят --pids-limit | Кажется лишним | Цикл fork исчерпает PID на host |
Оставляют nofile по умолчанию | Не знают о нём | Миллион скрывает утечку на часы |
| Считают рост памяти утечкой | Число растёт | Сравнить anon с tracemalloc |
Ищут убитый процесс в docker logs | Логично | Имя процесса — только в dmesg |
| Забывают, что OOM может убить не PID 1 | Container «работает» | Часть функций отказала, статус running |
Контрольные вопросы
На понимание:
- Три причины дают код
137. Как их различить? - Почему
OOMKilled: falseне доказывает отсутствие OOM? - Как возможен throttling при средней загрузке CPU 30 %?
- Что считает
--pids-limitи почему 50-поточный пул может его исчерпать? - Чем утечка отличается от фрагментации по наблюдаемым признакам?
На применение:
- Где найти имя процесса, убитого OOM killer?
- Как измерить throttling и какое значение считать тревожным?
- Как отличить рост page cache от утечки?
На диагностику:
- Сервис отвечает медленно,
docker statsпоказывает 25 % CPU. Гипотеза? - Container в статусе
running, но часть запросов возвращает ошибку. При чём тут OOM?
Краткое резюме
- Код выхода = 128 + номер сигнала:
137—SIGKILL,143—SIGTERM. 143штатен: так завершается приложение без своего обработчика приdocker stop.137дают три разные причины;.State.OOMKilledразличает только одну.- OOM на уровне host может не выставить
OOMKilled— след останется только вdmesg. - Журнал ядра — единственное место, где видно имя убитого процесса.
- OOM killer убивает процесс с наибольшим
oom_score, не обязательно PID 1. - Лимит CPU — это квота на период 100 мс; всплеск исчерпывает её и вызывает остановку.
- Throttling возможен при низкой средней загрузке и не виден в
docker stats. - Порог внимания:
nr_throttled / nr_periodsвыше 5 %. --pids-limitсчитает процессы и потоки вместе; отказ выглядит какcan't start new thread.- Лимит дескрипторов по умолчанию около миллиона — он скрывает утечку на часы.
- Утечку от фрагментации отличает отношение прироста
tracemallocк приростуanon.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: resource constraints | https://docs.docker.com/engine/containers/resource_constraints/ | --memory, --cpus, --pids-limit |
| Docker: runtime metrics | https://docs.docker.com/engine/containers/runmetrics/ | Чтение статистики cgroup |
Docker: docker run ulimits | https://docs.docker.com/reference/cli/docker/container/run/#ulimit | --ulimit nofile |
| Linux: cgroup v2 CPU | https://docs.kernel.org/admin-guide/cgroup-v2.html#cpu-interface-files | cpu.stat, cpu.max |
| Linux: cgroup v2 memory | https://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-files | memory.stat, OOM в cgroup |
| Linux: cgroup v2 PIDs | https://docs.kernel.org/admin-guide/cgroup-v2.html#pid-interface-files | pids.current, pids.max |
| Linux: OOM killer | https://docs.kernel.org/admin-guide/mm/concepts.html | Выбор жертвы, oom_score |
Python: tracemalloc | https://docs.python.org/3/library/tracemalloc.html | Учёт выделенных объектов |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Дисковое пространство и очистка
Главное оглавление