Главная/Observability и диагностика/Урок

13.3. Нехватка ресурсов

Цели

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

  • отличить OOM kill от внешнего SIGKILL, имея только код выхода 137;
  • найти след OOM в трёх местах и понять, что каждое из них даёт;
  • объяснить, почему CPU throttling происходит при загрузке 30 %;
  • измерить throttling через cpu.stat и подобрать лимит, который его снимает;
  • диагностировать исчерпание PID и файловых дескрипторов;
  • отличить утечку памяти в Python от фрагментации и от кэша.

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

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

ТерминОбъяснение
OOM killerМеханизм ядра, убивающий процесс при нехватке памяти
oom_scoreОценка, по которой выбирается жертва
throttlingПриостановка процесса при исчерпании квоты CPU
период CFSОкно учёта квоты, по умолчанию 100 мс
pids.maxПредел числа процессов и потоков в cgroup
RLIMIT_NOFILEПредел открытых дескрипторов на процесс

Теория

Коды 137 и 143

text
код выхода = 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

МестоКомандаЧто даёт
Состояние containerdocker inspect --format '{{.State.OOMKilled}}'Факт срабатывания cgroup-лимита
События Dockerdocker events --filter 'event=oom'Время и имя container'а
Журнал ядраdmesg или journalctl -kКто именно убит и сколько памяти занимал

Третье — единственное, где видно, какой процесс был убит. Это важно, когда в container'е несколько процессов: OOM killer убивает не обязательно PID 1.

text
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 мс оно не выполняется вовсе.

text
период 100 мс, квота 50 мс

всплеск нагрузки:
├──── работа 50 мс ────┤├──── ОСТАНОВЛЕН 50 мс ────┤
                        ▲
                    квота исчерпана

средняя загрузка за секунду: 30 %
задержка отдельного запроса: +50 мс

Средняя загрузка в docker stats будет выглядеть скромно — 30 %, 40 %. А задержка запросов вырастет на десятки миллисекунд, потому что в момент всплеска процесс останавливается.

Симптом: высокая задержка при низкой средней загрузке CPU. Диагноз — только через cpu.stat:

text
nr_periods 8412        сколько было периодов
nr_throttled 3180      в скольких квота была исчерпана
throttled_usec 94512000  суммарное время остановки

Отношение nr_throttled / nr_periods — доля периодов с приостановкой. Значение выше 5 % означает, что лимит мешает.

Лечение — увеличить лимит либо сгладить нагрузку. Увеличение периода (cpu.cfs_period_us) тоже помогает, но не настраивается через Docker CLI.

Исчерпание PID

bash
docker run --pids-limit 100 ...

Ограничение считает процессы и потоки вместе. Python-приложение с пулом из 50 потоков плюс multiprocessing с 8 рабочими легко упирается в лимит.

Симптомы:

ПроявлениеГде видно
BlockingIOError: [Errno 11] Resource temporarily unavailableЛоги приложения
fork: retry: Resource temporarily unavailableОболочка
can't start new threadPython
pids.current = pids.maxcgroup

Ограничение полезно: без него ошибка в коде, порождающая процессы в цикле, исчерпает PID на всём host, и система перестанет отвечать.

Файловые дескрипторы

Предел задаётся на процесс, а не на cgroup:

bash
docker run --ulimit nofile=4096:8192 ...

По умолчанию container наследует лимиты daemon, которые часто велики (1048576). Это скрывает утечку дескрипторов: она проявится не сразу, а через часы.

Проверка:

bash
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: воспроизведение и три следа

bash
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

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

text
═══ запуск с лимитом 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: как отличить

bash
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

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

text
═══ случай 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 при низкой средней загрузке

bash
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

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

text
═══ без лимита 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

bash
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

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

text
═══ без ограничения 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 без этого знания непросто.

Файловые дескрипторы

bash
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

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

text
═══ лимит по умолчанию ═══
  мягкий лимит: 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 по умолчанию — не защита, а отсрочка. Утечка дойдёт до него нескоро, и к этому моменту причину найти сложнее.

Утечка против фрагментации

bash
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

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

text
═══ настоящая утечка ═══
    этап              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 растёт.


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

Задание. Постройте диагностику нехватки ресурсов, дающую диагноз, а не симптом.

Требования:

  1. Воспроизвести OOM и найти след во всех доступных местах.
  2. Показать четыре разных причины, дающие код 137 или 143, и способ различить их.
  3. Показать CPU throttling при средней загрузке ниже 40 % и измерить его через cpu.stat.
  4. Показать исчерпание PID и сообщение, которое увидит приложение.
  5. Различить утечку и фрагментацию памяти по двум источникам.
  6. Написать диагностический инструмент, ставящий диагноз по коду выхода и состоянию.

Подсказки

Подсказка 1

Для пункта 2 нужны четыре container'а: с лимитом памяти, убитый docker kill, игнорирующий SIGTERM, и обычный.

Подсказка 2

Пункт 3: чтобы средняя загрузка была низкой, работайте всплесками с паузами. Квота исчерпывается всплеском, пауза снижает среднее.

Подсказка 3

Для пункта 5 сравнивайте anon из memory.stat с tracemalloc.get_traced_memory().

Решение

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

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

text
═══ Требование 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, который скоро упрётся в лимит, он покажет как работающий.

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

bash
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 1Container «работает»Часть функций отказала, статус running

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

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

  1. Три причины дают код 137. Как их различить?
  2. Почему OOMKilled: false не доказывает отсутствие OOM?
  3. Как возможен throttling при средней загрузке CPU 30 %?
  4. Что считает --pids-limit и почему 50-поточный пул может его исчерпать?
  5. Чем утечка отличается от фрагментации по наблюдаемым признакам?

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

  1. Где найти имя процесса, убитого OOM killer?
  2. Как измерить throttling и какое значение считать тревожным?
  3. Как отличить рост page cache от утечки?

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

  1. Сервис отвечает медленно, docker stats показывает 25 % CPU. Гипотеза?
  2. Container в статусе running, но часть запросов возвращает ошибку. При чём тут OOM?

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

  1. Код выхода = 128 + номер сигнала: 137SIGKILL, 143SIGTERM.
  2. 143 штатен: так завершается приложение без своего обработчика при docker stop.
  3. 137 дают три разные причины; .State.OOMKilled различает только одну.
  4. OOM на уровне host может не выставить OOMKilled — след останется только в dmesg.
  5. Журнал ядра — единственное место, где видно имя убитого процесса.
  6. OOM killer убивает процесс с наибольшим oom_score, не обязательно PID 1.
  7. Лимит CPU — это квота на период 100 мс; всплеск исчерпывает её и вызывает остановку.
  8. Throttling возможен при низкой средней загрузке и не виден в docker stats.
  9. Порог внимания: nr_throttled / nr_periods выше 5 %.
  10. --pids-limit считает процессы и потоки вместе; отказ выглядит как can't start new thread.
  11. Лимит дескрипторов по умолчанию около миллиона — он скрывает утечку на часы.
  12. Утечку от фрагментации отличает отношение прироста tracemalloc к приросту anon.

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

ИсточникСсылкаЧто подтверждает
Docker: resource constraintshttps://docs.docker.com/engine/containers/resource_constraints/--memory, --cpus, --pids-limit
Docker: runtime metricshttps://docs.docker.com/engine/containers/runmetrics/Чтение статистики cgroup
Docker: docker run ulimitshttps://docs.docker.com/reference/cli/docker/container/run/#ulimit--ulimit nofile
Linux: cgroup v2 CPUhttps://docs.kernel.org/admin-guide/cgroup-v2.html#cpu-interface-filescpu.stat, cpu.max
Linux: cgroup v2 memoryhttps://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-filesmemory.stat, OOM в cgroup
Linux: cgroup v2 PIDshttps://docs.kernel.org/admin-guide/cgroup-v2.html#pid-interface-filespids.current, pids.max
Linux: OOM killerhttps://docs.kernel.org/admin-guide/mm/concepts.htmlВыбор жертвы, oom_score
Python: tracemallochttps://docs.python.org/3/library/tracemalloc.htmlУчёт выделенных объектов

Навигация

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

Markdown на GitHub ↗