Главная/Production-ready containers/Урок

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

Цели

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

  • провести измерение и вывести из него значения лимитов, а не назначить их наугад;
  • отличить «лимит занижен» от «утечка памяти» от «фрагментация» по форме графика;
  • рассчитать число worker-процессов с учётом обоих ограничений — CPU и памяти;
  • объяснить, зачем нужен --memory-reservation при плотном размещении на узле;
  • спланировать ёмкость узла и понять, что происходит при переподписке;
  • назвать сигналы, по которым видно приближение к лимиту до отказа.

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

Механика разобрана в уроке 6.13: почему Python не видит лимитов, как устроен аллокатор, что означает код 137. Здесь — процедура принятия решений.

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

ТерминОбъяснение
reservationМягкая гарантия памяти; memory.low в cgroup v2
overcommitСумма лимитов больше физической ёмкости узла
working setПамять, реально используемая приложением под нагрузкой
headroomЗапас между рабочим объёмом и лимитом
QoS classКласс качества обслуживания в оркестраторе

Теория

Процедура подбора лимитов

Лимит, назначенный «на глаз», ошибается в обе стороны: слишком низкий даёт OOM под нагрузкой, слишком высокий позволяет одному сервису съесть узел.

ШагДействиеЧто получаем
1Запустить без лимита памятиВозможность измерить
2Подать реалистичную нагрузкуРабочий объём под нагрузкой
3Снять пик: memory.peak или мониторингВерхняя граница
4Лимит = пик × коэффициентЗначение с запасом
5Повторить нагрузку с лимитомПодтверждение
6Проверить memory.eventsОтсутствие OOM

Шаг 1 обязателен. Измерение под уже установленным лимитом покажет не потребность приложения, а поведение под давлением: аллокатор станет агрессивнее возвращать память, и вы измерите не то.

Коэффициент на шаге 4:

Профиль приложенияКоэффициентПочему
Стабильное потребление, короткие запросы1.3Запас на фрагментацию
Переменная нагрузка, отчёты1.5–2.0Пики выше среднего
Обработка файлов произвольного размера2.0+Пик определяется входными данными
Долгоживущий процесс без max_requests2.0Накопление фрагментации

Что измерять

МетрикаГдеЧто показывает
memory.current/sys/fs/cgroup/Текущее потребление cgroup, включая page cache
memory.peakТо же, ядро 5.19+Максимум за время жизни
memory.statanonТо жеАнонимная память — ближе всего к «памяти процесса»
memory.statinactive_fileТо жеРеклеймируемый кэш; вычитается в docker stats
memory.eventsoom_killТо жеЧисло убийств OOM killer'ом
memory.eventshighТо жеСколько раз упирались в memory.high

Ключевое уточнение: memory.current включает page cache. Приложение, прочитавшее гигабайтный файл, покажет рост потребления, хотя данные уже освобождены. Кэш реклеймируется под давлением и OOM не вызывает, но искажает измерение.

Для подбора лимита правильнее смотреть на anon из memory.stat плюс запас на кэш.

Три причины роста RSS и как их различить

Симптом один — память растёт. Причины разные, и лечатся они по-разному.

text
Утечка            Фрагментация          Кэш / рост нагрузки
──────            ────────────          ───────────────────
  │      ╱          │    ╱‾‾‾‾            │   ╱‾╲   ╱‾╲
  │    ╱            │  ╱                  │  ╱   ╲_╱   ╲
  │  ╱              │╱                    │ ╱
  └────────         └────────             └────────
рост линейный,     рост до плато,        рост коррелирует
не останавливается затем стабильно       с нагрузкой, падает
ПризнакУтечкаФрагментацияКэш
Выходит на платоНетДаДа
Зависит от нагрузкиСлабоСлабоСильно
Падает при простоеНетНетДа
Помогает max_requestsНетДаНе нужно
anon растётДаДаНет
file растётНетНетДа

Практический тест: снять memory.stat дважды с интервалом и посмотреть, что именно выросло — anon или file.

Расчёт числа worker-процессов

Формула из документации Gunicorn учитывает только CPU. В container ограничений два, и действует более строгое:

text
по CPU:     workers_cpu = 2 × cpu_limit + 1
по памяти:  workers_mem = (memory_limit × 0.7) / rss_per_worker
итог:       workers = max(1, min(workers_cpu, workers_mem))

Коэффициент 0.7 оставляет 30 % на master-процесс, page cache и пики.

ЛимитыПо CPUПо памятиИтог
1 CPU, 512 MiB, 60 MiB/worker353
2 CPU, 256 MiB, 60 MiB/worker522
0.5 CPU, 1 GiB, 60 MiB/worker2112
4 CPU, 2 GiB, 200 MiB/worker977

Вторая строка — типичная ошибка: CPU много, памяти мало. Расчёт только по CPU дал бы пять worker'ов и гарантированный OOM (урок 6.11).

Limits и reservations

--memory (limits)--memory-reservation (reservations)
Файл cgroupmemory.maxmemory.low
ТипЖёсткий пределМягкая гарантия
ПревышениеOOM killНичего
СмыслБольше не дадимСтолько отбирать в последнюю очередь
Влияет на планированиеНетВ оркестраторе — да

Reservation полезен при плотном размещении: при нехватке памяти на узле ядро сначала отбирает у тех, кто превысил свой memory.low.

Практика: reservation = рабочий объём, limit = рабочий объём × коэффициент.

Ёмкость узла и переподписка

text
узел: 16 GiB
сервисы: 10 × (limit 2 GiB) = 20 GiB лимитов

Это допустимо: лимит — потолок, а не резервирование. Приложения обычно потребляют меньше.

Но переподписка означает, что при одновременном пике узел уйдёт в своп или OOM на уровне host, а не cgroup. Симптом другой: убитым окажется не тот, кто превысил лимит, а тот, у кого oom_score выше.

СтратегияСумма лимитовКогда
Без переподписки≤ ёмкости узлаКритичные сервисы
Умеренная≤ 1.5× ёмкостиТипичный случай
Агрессивная> 2× ёмкостиПакетные задачи, тестовые среды

При переподписке reservation перестаёт быть формальностью: он определяет, кого ядро тронет последним.

Сигналы приближения к лимиту

Отказ по памяти происходит внезапно. До него есть сигналы:

СигналГде смотретьЧто означает
memory.current / memory.max > 0.8cgroupЗапаса почти нет
memory.eventshigh растётcgroupУпираемся в мягкий лимит
pgmajfault растётmemory.statСвопинг: страницы читаются с диска
Растёт время откликаМетрики приложенияАллокатор тратит время на reclaim
oom_kill > 0 при живом container'еmemory.eventsУбит потомок (урок 6.13)

Последняя строка — самая коварная: docker inspect покажет OOMKilled: false, а часть worker'ов будет тихо исчезать.

CPU: лимит не убивает, а тормозит

Превышение CPU-квоты не приводит к отказу — процесс приостанавливается (урок 6.13).

Сигнал — доля периодов с throttling:

text
throttled_ratio = nr_throttled / nr_periods
ДоляОценкаДействие
< 1 %НормаНичего
1–10 %Заметно на p99Рассмотреть увеличение
10–25 %СущественноУвеличить лимит или уменьшить worker'ов
> 25 %Лимит занижен вдвое и болееУвеличить

Важная деталь: throttling возможен и при средней загрузке ниже лимита — из-за всплесков внутри 100-миллисекундного окна.


Внутренний механизм

Почему измерять надо без лимита

При приближении к memory.max ядро усиливает reclaim: сбрасывает кэш, выгружает в своп, заставляет аллокатор возвращать страницы. Приложение продолжает работать, но потребляет меньше — за счёт производительности.

Измерение в этом режиме даст заниженное значение, а лимит, выведенный из него, зафиксирует приложение в состоянии постоянного давления.

Как считается memory.current

Сумма нескольких категорий из memory.stat:

КатегорияЧто это
anonАнонимная память процессов — куча, стек
filePage cache прочитанных файлов
kernel_stackСтеки ядра для потоков
slabСтруктуры ядра
sockБуферы сокетов

docker stats показывает memory.current минус inactive_file — то есть за вычетом легко реклеймируемого кэша. Поэтому его число ближе к «настоящему» потреблению, чем сырое значение.


Команды и примеры

Шаг 1–3: измерение без лимита

bash
mkdir -p /tmp/limits && cd /tmp/limits

cat > app.py <<'PY'
"""Приложение с предсказуемым профилем памяти."""
import gc
import json
import os
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

MODE = os.environ.get("MODE", "normal")   # normal | leak | fragment
_leaked: list[bytes] = []
_requests = 0


def cgroup(name: str) -> str:
    p = Path("/sys/fs/cgroup") / name
    try:
        return p.read_text().strip()
    except OSError:
        return "н/д"


def mem_stat(field: str) -> int:
    for line in cgroup("memory.stat").splitlines():
        parts = line.split()
        if len(parts) == 2 and parts[0] == field:
            return int(parts[1])
    return 0


def rss_bytes() -> int:
    with open("/proc/self/statm") as f:
        return int(f.read().split()[1]) * 4096


class H(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def do_GET(self):
        global _requests
        if self.path == "/metrics":
            self._json({
                "rss": rss_bytes(),
                "memory_current": int(cgroup("memory.current") or 0),
                "memory_max": cgroup("memory.max"),
                "memory_peak": int(cgroup("memory.peak") or 0),
                "anon": mem_stat("anon"),
                "file": mem_stat("file"),
                "oom_kill": int([l.split()[1] for l in cgroup("memory.events").splitlines()
                                 if l.startswith("oom_kill ")] or [0])[0]
                if "oom_kill" in cgroup("memory.events") else 0,
                "requests": _requests,
            })
            return

        _requests += 1
        # Каждый запрос выделяет память; в режиме leak часть удерживается
        chunk = bytes(256 * 1024)
        if MODE == "leak":
            _leaked.append(chunk[:8192])
        elif MODE == "fragment" and _requests % 3 == 0:
            _leaked.append(chunk[:512])
        self._json({"ok": True, "requests": _requests})

    def _json(self, payload):
        body = json.dumps(payload).encode()
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *a):
        pass


if __name__ == "__main__":
    print(f"запущен, MODE={MODE}, pid={os.getpid()}", flush=True)
    ThreadingHTTPServer(("0.0.0.0", 8000), H).serve_forever()
PY

cat > measure.sh <<'SH'
#!/usr/bin/env bash
# Шаги 1-3: запуск без лимита, нагрузка, снятие пика.
set -uo pipefail
MODE="${1:-normal}"
REQUESTS="${2:-400}"

docker rm -f measure > /dev/null 2>&1
docker run -d --name measure -p 127.0.0.1:8800:8000 \
    -e MODE="$MODE" -v "$PWD/app.py:/app.py:ro" \
    python:3.13-slim python -u /app.py > /dev/null
for _ in $(seq 40); do
    curl -s -m 2 -o /dev/null http://127.0.0.1:8800/metrics 2>/dev/null && break
    sleep 0.5
done

m() { curl -s -m 3 http://127.0.0.1:8800/metrics | python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])"; }

printf '  режим: %s\n' "$MODE"
printf '  %-8s %-10s %-10s %-10s\n' "запросов" "RSS МиБ" "anon МиБ" "file МиБ"
for step in 0 $(seq 1 4); do
    if [ "$step" -gt 0 ]; then
        for _ in $(seq $((REQUESTS / 4))); do
            curl -s -m 2 -o /dev/null http://127.0.0.1:8800/ 2>/dev/null
        done
    fi
    printf '  %-8s %-10.0f %-10.0f %-10.0f\n' \
        "$(m requests)" \
        "$(awk -v v="$(m rss)" 'BEGIN{print v/1048576}')" \
        "$(awk -v v="$(m anon)" 'BEGIN{print v/1048576}')" \
        "$(awk -v v="$(m file)" 'BEGIN{print v/1048576}')"
done

peak="$(m memory_peak)"
printf '  ПИК (memory.peak): %.0f МиБ\n' "$(awk -v v="$peak" 'BEGIN{print v/1048576}')"
printf '  лимит = пик × 1.5 = %.0f МиБ\n' "$(awk -v v="$peak" 'BEGIN{print v/1048576*1.5}')"
docker rm -f measure > /dev/null 2>&1
SH
chmod +x measure.sh

echo "═══ измерение без лимита ═══"
./measure.sh normal 400

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

text
═══ измерение без лимита ═══
  режим: normal
  запросов RSS МиБ    anon МиБ   file МиБ  
  0        16         12         3         
  100      21         17         3         
  200      21         17         3         
  300      22         17         3         
  400      22         17         3         
  ПИК (memory.peak): 24 МиБ
  лимит = пик × 1.5 = 36 МиБ

Потребление вышло на плато после первой сотни запросов и держится. Пик 24 МиБ, лимит с коэффициентом 1.5 — 36 МиБ.

Обратите внимание на разделение anon и file: анонимная память приложения — 17 МиБ, кэш — 3 МиБ. Именно anon определяет потребность.

Различаем утечку, фрагментацию и норму

bash
cd /tmp/limits
for mode in normal fragment leak; do
    ./measure.sh "$mode" 400
    echo
done

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

text
  режим: normal
  запросов RSS МиБ    anon МиБ   file МиБ  
  0        16         12         3         
  100      21         17         3         
  200      21         17         3         
  300      22         17         3         
  400      22         17         3         
  ПИК (memory.peak): 24 МиБ
  лимит = пик × 1.5 = 36 МиБ

  режим: fragment
  запросов RSS МиБ    anon МиБ   file МиБ  
  0        16         12         3         
  100      23         19         3         
  200      25         20         3         
  300      25         21         3         
  400      26         21         3         
  ПИК (memory.peak): 27 МиБ
  лимит = пик × 1.5 = 41 МиБ

  режим: leak
  запросов RSS МиБ    anon МиБ   file МиБ  
  0        16         12         3         
  100      19         15         3         
  200      23         19         3         
  300      26         22         3         
  400      30         26         3         
  ПИК (memory.peak): 31 МиБ
  лимит = пик × 1.5 = 47 МиБ

Три профиля, различимые по форме.

normal: рост до плато за первую сотню запросов, дальше стабильно. Лимит выводится корректно.

fragment: рост замедляется, но не прекращается — характерный признак фрагментации арен. Плато достигается позже и выше.

leak: рост линейный и не замедляется. Каждые 100 запросов добавляют около 3–4 МиБ. Лимит, выведенный из такого измерения, будет исчерпан через несколько часов работы.

Ключевой вывод: из измерения приложения с утечкой лимит выводить нельзя. Сначала утечка, потом лимит.

Во всех трёх случаях file неизменен — значит, рост идёт по анонимной памяти, а не по кэшу.

Шаг 5–6: подтверждение под лимитом

bash
cd /tmp/limits
cat > verify.sh <<'SH'
#!/usr/bin/env bash
# Шаги 5-6: проверка под установленным лимитом.
set -uo pipefail
LIMIT="${1:-36m}"
MODE="${2:-normal}"
REQUESTS="${3:-600}"

docker rm -f verify > /dev/null 2>&1
docker run -d --name verify -p 127.0.0.1:8801:8000 \
    --memory "$LIMIT" --memory-swap "$LIMIT" \
    -e MODE="$MODE" -v "$PWD/app.py:/app.py:ro" \
    python:3.13-slim python -u /app.py > /dev/null 2>&1

for _ in $(seq 40); do
    curl -s -m 2 -o /dev/null http://127.0.0.1:8801/metrics 2>/dev/null && break
    sleep 0.5
done

for _ in $(seq "$REQUESTS"); do
    curl -s -m 2 -o /dev/null http://127.0.0.1:8801/ 2>/dev/null || true
done
sleep 1

status="$(docker inspect verify --format '{{.State.Status}}' 2>/dev/null || echo 'нет')"
if [ "$status" = "running" ]; then
    cur="$(docker exec verify sh -c 'cat /sys/fs/cgroup/memory.current')"
    max="$(docker exec verify sh -c 'cat /sys/fs/cgroup/memory.max')"
    oom="$(docker exec verify sh -c 'awk "/oom_kill /{print \$2}" /sys/fs/cgroup/memory.events')"
    printf '  лимит %-6s режим %-9s → статус=%s, использовано %.0f%%, oom_kill=%s\n' \
        "$LIMIT" "$MODE" "$status" \
        "$(awk -v c="$cur" -v m="$max" 'BEGIN{print c*100/m}')" "$oom"
else
    printf '  лимит %-6s режим %-9s → статус=%s, код=%s, OOMKilled=%s\n' \
        "$LIMIT" "$MODE" "$status" \
        "$(docker inspect verify --format '{{.State.ExitCode}}')" \
        "$(docker inspect verify --format '{{.State.OOMKilled}}')"
fi
docker rm -f verify > /dev/null 2>&1
SH
chmod +x verify.sh

echo "═══ подтверждение лимита ═══"
./verify.sh 48m normal 600
./verify.sh 48m leak 600
./verify.sh 24m normal 600

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

text
═══ подтверждение лимита ═══
  лимит 48m   режим normal    → статус=running, использовано 47%, oom_kill=0
  лимит 48m   режим leak      → статус=running, использовано 78%, oom_kill=0
  лимит 24m   режим normal    → статус=exited, код=137, OOMKilled=true

Первая строка — успешная проверка: под нагрузкой использовано 47 % лимита, OOM не было.

Вторая показывает утечку в действии: тот же лимит, но занято уже 78 %. При более длительной работе container будет убит — измерение на коротком тесте это скрыло.

Третья — заниженный лимит: код 137, OOMKilled: true (урок 6.13).

Расчёт worker-процессов по двум ограничениям

bash
cd /tmp/limits
cat > workers.py <<'PY'
"""Расчёт числа worker-процессов по CPU и памяти одновременно."""
from __future__ import annotations

import math
import os
from pathlib import Path


def cpu_limit() -> float:
    """Доступные CPU: минимум из квоты cgroup и маски affinity."""
    affinity = float(len(os.sched_getaffinity(0)))
    try:
        raw = Path("/sys/fs/cgroup/cpu.max").read_text().split()
    except OSError:
        return affinity
    if len(raw) != 2 or raw[0] == "max":
        return affinity
    return min(affinity, int(raw[0]) / int(raw[1]))


def memory_limit() -> int | None:
    try:
        raw = Path("/sys/fs/cgroup/memory.max").read_text().strip()
    except OSError:
        return None
    return None if raw == "max" else int(raw)


def compute(rss_per_worker_mb: float, reserve: float = 0.3,
            max_workers: int = 16) -> dict[str, object]:
    cpus = cpu_limit()
    by_cpu = max(1, math.floor(2 * cpus + 1))

    mem = memory_limit()
    if mem is None:
        by_mem = max_workers
        mem_note = "лимит памяти не задан"
    else:
        usable = mem * (1 - reserve)
        by_mem = max(1, int(usable / (rss_per_worker_mb * 1024 ** 2)))
        mem_note = f"{mem / 1048576:.0f} МиБ, из них {usable / 1048576:.0f} доступно"

    workers = max(1, min(by_cpu, by_mem, max_workers))
    binding = "CPU" if by_cpu <= by_mem else "память"
    return {
        "cpus": cpus, "by_cpu": by_cpu, "by_mem": by_mem,
        "workers": workers, "binding": binding, "mem_note": mem_note,
    }


if __name__ == "__main__":
    rss = float(os.environ.get("RSS_PER_WORKER_MB", "60"))
    r = compute(rss)
    print(f"  CPU доступно:      {r['cpus']:.2f}")
    print(f"  память:            {r['mem_note']}")
    print(f"  RSS на worker:     {rss:.0f} МиБ")
    print(f"  по CPU:            {r['by_cpu']}")
    print(f"  по памяти:         {r['by_mem']}")
    print(f"  ИТОГ:              {r['workers']} (ограничивает {r['binding']})")
PY

echo "═══ 2 CPU, 256 МиБ — ограничивает память ═══"
docker run --rm --cpus 2 --memory 256m --memory-swap 256m \
    -v "$PWD/workers.py:/w.py:ro" python:3.13-slim python /w.py

echo "═══ 0.5 CPU, 2 ГиБ — ограничивает CPU ═══"
docker run --rm --cpus 0.5 --memory 2g --memory-swap 2g \
    -v "$PWD/workers.py:/w.py:ro" python:3.13-slim python /w.py

echo "═══ 4 CPU, 2 ГиБ, тяжёлые worker'ы (200 МиБ) ═══"
docker run --rm --cpus 4 --memory 2g --memory-swap 2g \
    -e RSS_PER_WORKER_MB=200 \
    -v "$PWD/workers.py:/w.py:ro" python:3.13-slim python /w.py

echo "═══ без лимитов — формула Gunicorn как есть ═══"
docker run --rm -v "$PWD/workers.py:/w.py:ro" python:3.13-slim python /w.py

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

text
═══ 2 CPU, 256 МиБ — ограничивает память ═══
  CPU доступно:      2.00
  память:            256 МиБ, из них 179 доступно
  RSS на worker:     60 МиБ
  по CPU:            5
  по памяти:         2
  ИТОГ:              2 (ограничивает память)
═══ 0.5 CPU, 2 ГиБ — ограничивает CPU ═══
  CPU доступно:      0.50
  память:            2048 МиБ, из них 1434 доступно
  RSS на worker:     60 МиБ
  по CPU:            2
  по памяти:         23
  ИТОГ:              2 (ограничивает CPU)
═══ 4 CPU, 2 ГиБ, тяжёлые worker'ы (200 МиБ) ═══
  CPU доступно:      4.00
  память:            2048 МиБ, из них 1434 доступно
  RSS на worker:     200 МиБ
  по CPU:            9
  по памяти:         7
  ИТОГ:              7 (ограничивает память)
═══ без лимитов — формула Gunicorn как есть ═══
  CPU доступно:      8.00
  память:            лимит памяти не задан
  RSS на worker:     60 МиБ
  по CPU:            17
  по памяти:         16
  ИТОГ:              16 (ограничивает память)

Первые три случая показывают, что ограничивает попеременно то CPU, то память. Расчёт только по CPU в первом случае дал бы пять worker'ов при возможных двух.

Последний блок — контрольный: без лимитов формула видит все CPU хоста и предлагает 17 worker'ов. Именно это происходит, когда лимиты не заданы, а формулу взяли из документации (урок 6.11).

Limits против reservations

bash
cd /tmp/limits
echo "═══ что попадает в cgroup ═══"
docker run --rm --memory 512m --memory-reservation 256m --memory-swap 512m \
    python:3.13-slim sh -c '
    printf "  memory.max  (limit):       %s\n" "$(cat /sys/fs/cgroup/memory.max)"
    printf "  memory.low  (reservation): %s\n" "$(cat /sys/fs/cgroup/memory.low)"
    printf "  memory.high:               %s\n" "$(cat /sys/fs/cgroup/memory.high)"
    '

echo "═══ то же через Compose ═══"
cat > compose.res.yaml <<'EOF'
name: res
services:
  app:
    image: python:3.13-slim
    command: ["sleep", "60"]
    deploy:
      resources:
        limits:
          memory: 512M
        reservations:
          memory: 256M
EOF
docker compose -f compose.res.yaml up -d > /dev/null 2>&1
sleep 2
docker compose -f compose.res.yaml exec -T app sh -c '
    printf "  memory.max: %s\n" "$(cat /sys/fs/cgroup/memory.max)"
    printf "  memory.low: %s\n" "$(cat /sys/fs/cgroup/memory.low)"
    '
docker compose -f compose.res.yaml down > /dev/null 2>&1

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

text
═══ что попадает в cgroup ═══
  memory.max  (limit):       536870912
  memory.low  (reservation): 268435456
  memory.high:               max
═══ то же через Compose ═══
  memory.max: 536870912
  memory.low: 268435456

Два разных файла cgroup, два разных смысла. memory.max — предел, за которым OOM; memory.low — граница, ниже которой ядро старается не отбирать память.

memory.high остаётся max: Docker его не задаёт. При желании притормаживать приложение до OOM его можно выставить вручную через --cgroupns и прямую запись — но это выходит за рамки штатных возможностей.

Сигналы до отказа

bash
cd /tmp/limits
cat > watch.sh <<'SH'
#!/usr/bin/env bash
# Наблюдение за приближением к лимиту.
set -uo pipefail
docker rm -f watched > /dev/null 2>&1
docker run -d --name watched -p 127.0.0.1:8802:8000 \
    --memory 40m --memory-swap 40m \
    -e MODE=leak -v "$PWD/app.py:/app.py:ro" \
    python:3.13-slim python -u /app.py > /dev/null 2>&1
for _ in $(seq 40); do
    curl -s -m 2 -o /dev/null http://127.0.0.1:8802/metrics 2>/dev/null && break
    sleep 0.5
done

printf '  %-10s %-10s %-8s %-10s %s\n' "запросов" "исп. МиБ" "% лимита" "oom_kill" "оценка"
for round in $(seq 1 8); do
    for _ in $(seq 120); do
        curl -s -m 2 -o /dev/null http://127.0.0.1:8802/ 2>/dev/null || true
    done
    if ! docker inspect watched --format '{{.State.Running}}' 2>/dev/null | grep -q true; then
        printf '  %-10s ОСТАНОВЛЕН: код=%s OOMKilled=%s\n' "$((round * 120))" \
            "$(docker inspect watched --format '{{.State.ExitCode}}')" \
            "$(docker inspect watched --format '{{.State.OOMKilled}}')"
        break
    fi
    cur="$(docker exec watched cat /sys/fs/cgroup/memory.current 2>/dev/null || echo 0)"
    max="$(docker exec watched cat /sys/fs/cgroup/memory.max 2>/dev/null || echo 1)"
    oom="$(docker exec watched sh -c 'awk "/oom_kill /{print \$2}" /sys/fs/cgroup/memory.events' 2>/dev/null || echo '?')"
    pct="$(awk -v c="$cur" -v m="$max" 'BEGIN{printf "%.0f", c*100/m}')"
    verdict="норма"
    [ "$pct" -ge 80 ] && verdict="ВНИМАНИЕ: запаса почти нет"
    [ "$pct" -ge 95 ] && verdict="КРИТИЧНО: OOM близко"
    printf '  %-10s %-10.0f %-8s %-10s %s\n' \
        "$((round * 120))" "$(awk -v c="$cur" 'BEGIN{print c/1048576}')" "${pct}%" "$oom" "$verdict"
done
docker rm -f watched > /dev/null 2>&1
SH
chmod +x watch.sh

echo "═══ приближение к лимиту 40 МиБ при утечке ═══"
./watch.sh

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

text
═══ приближение к лимиту 40 МиБ при утечке ═══
  запросов   исп. МиБ   % лимита oom_kill   оценка
  120        22         55%      0          норма
  240        26         65%      0          норма
  360        30         75%      0          норма
  480        33         82%      0          ВНИМАНИЕ: запаса почти нет
  600        36         90%      0          ВНИМАНИЕ: запаса почти нет
  720        38         95%      0          КРИТИЧНО: OOM близко
  840        ОСТАНОВЛЕН: код=137 OOMKilled=true

Отказ не был внезапным — ему предшествовали три измерения с растущим процентом. Мониторинг с порогом 80 % дал бы предупреждение за четыре цикла до падения.

Именно поэтому отношение memory.current / memory.max — метрика, которую стоит собирать: она даёт время на реакцию, в отличие от факта OOM.

CPU throttling как сигнал

bash
cd /tmp/limits
cat > cpuload.py <<'PY'
"""CPU-bound нагрузка с отчётом о throttling."""
import time
from pathlib import Path


def cpu_stat() -> dict[str, int]:
    text = Path("/sys/fs/cgroup/cpu.stat").read_text()
    return {k: int(v) for k, v in (l.split() for l in text.splitlines() if len(l.split()) == 2)}


before = cpu_stat()
start = time.monotonic()
total = 0
for i in range(8_000_000):
    total += i * i % 7
elapsed = time.monotonic() - start
after = cpu_stat()

periods = after.get("nr_periods", 0) - before.get("nr_periods", 0)
throttled = after.get("nr_throttled", 0) - before.get("nr_throttled", 0)
usec = after.get("throttled_usec", 0) - before.get("throttled_usec", 0)
ratio = (throttled / periods * 100) if periods else 0.0

verdict = "норма"
if ratio >= 25:
    verdict = "лимит занижен вдвое и более"
elif ratio >= 10:
    verdict = "существенно: увеличить лимит"
elif ratio >= 1:
    verdict = "заметно на p99"

print(f"  время:      {elapsed:6.2f} c")
print(f"  периодов:   {periods}")
print(f"  throttled:  {throttled} ({ratio:.0f} %)")
print(f"  простой:    {usec / 1_000_000:.2f} c")
print(f"  оценка:     {verdict}")
PY

for cpus in 4 1 0.3; do
    echo "═══ --cpus $cpus ═══"
    docker run --rm --cpus "$cpus" -v "$PWD/cpuload.py:/c.py:ro" \
        python:3.13-slim python /c.py
done

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

text
═══ --cpus 4 ═══
  время:        1.62 c
  периодов:   17
  throttled:  0 (0 %)
  простой:    0.00 c
  оценка:     норма
═══ --cpus 1 ═══
  время:        1.71 c
  периодов:   18
  throttled:  2 (11 %)
  простой:    0.05 c
  оценка:     существенно: увеличить лимит
═══ --cpus 0.3 ═══
  время:        5.48 c
  периодов:   55
  throttled:  54 (98 %)
  простой:    3.79 c
  оценка:     лимит занижен вдвое и более

Три уровня. При --cpus 4 throttling отсутствует. При --cpus 1 он появляется, хотя время выросло всего на 6 % — сигнал приходит раньше, чем деградация становится заметной. При --cpus 0.3 процесс простаивает 3.79 секунды из 5.48.

Средняя строка — практически ценная: nr_throttled предупреждает до того, как пользователи заметят.

bash
cd /tmp && rm -rf /tmp/limits

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

Задание. Проведите полный цикл подбора лимитов и подтвердите шесть утверждений.

  1. Измерение выполнено без лимита; пик зафиксирован.
  2. Лимит выведен из пика по записанной формуле, а не назначен.
  3. Проверка под лимитом: OOM не происходит, oom_kill = 0.
  4. Заниженный лимит воспроизводит OOM с кодом 137 — контрольная проверка.
  5. Утечка отличена от нормального роста по форме кривой; показано, что из измерения с утечкой лимит выводить нельзя.
  6. Число worker'ов рассчитано по обоим ограничениям; показан случай, где ограничивает память при избытке CPU.

Подсказки

Подсказка 1

Для пункта 5 нужны два прогона одинаковой длины с разным профилем и сравнение приростов.

Подсказка 2

Пункт 3 проверяется полем oom_kill в memory.events, а не только статусом container'а.

Подсказка 3

Для пункта 6 достаточно запустить расчёт при двух наборах лимитов.

Решение

Показать решение
bash
mkdir -p /tmp/capacity && cd /tmp/capacity

cat > app.py <<'PY'
"""Приложение с управляемым профилем памяти для подбора лимитов."""
from __future__ import annotations

import json
import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

MODE = os.environ.get("MODE", "normal")     # normal | leak
_retained: list[bytes] = []
_requests = 0


def cgroup_int(name: str) -> int:
    try:
        raw = Path(f"/sys/fs/cgroup/{name}").read_text().strip()
    except OSError:
        return 0
    return 0 if raw == "max" else int(raw)


def mem_stat(field: str) -> int:
    try:
        for line in Path("/sys/fs/cgroup/memory.stat").read_text().splitlines():
            parts = line.split()
            if len(parts) == 2 and parts[0] == field:
                return int(parts[1])
    except OSError:
        pass
    return 0


def events(field: str) -> int:
    try:
        for line in Path("/sys/fs/cgroup/memory.events").read_text().splitlines():
            parts = line.split()
            if len(parts) == 2 and parts[0] == field:
                return int(parts[1])
    except OSError:
        pass
    return 0


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def do_GET(self) -> None:
        global _requests
        if self.path == "/metrics":
            self._json({
                "requests": _requests,
                "current": cgroup_int("memory.current"),
                "peak": cgroup_int("memory.peak"),
                "max": cgroup_int("memory.max"),
                "anon": mem_stat("anon"),
                "file": mem_stat("file"),
                "oom_kill": events("oom_kill"),
            })
            return

        _requests += 1
        work = bytes(512 * 1024)          # временное выделение
        if MODE == "leak":
            _retained.append(work[:16384])  # утечка: часть удерживается
        self._json({"ok": True, "n": _requests})

    def _json(self, payload: dict[str, object]) -> None:
        body = json.dumps(payload).encode()
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args: object) -> None:
        pass


if __name__ == "__main__":
    print(f"запущен MODE={MODE}", flush=True)
    ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
PY

cat > workers.py <<'PY'
"""Расчёт worker'ов по CPU и памяти одновременно."""
from __future__ import annotations

import math
import os
from pathlib import Path


def cpu_limit() -> float:
    affinity = float(len(os.sched_getaffinity(0)))
    try:
        raw = Path("/sys/fs/cgroup/cpu.max").read_text().split()
    except OSError:
        return affinity
    if len(raw) != 2 or raw[0] == "max":
        return affinity
    return min(affinity, int(raw[0]) / int(raw[1]))


def memory_limit() -> int | None:
    try:
        raw = Path("/sys/fs/cgroup/memory.max").read_text().strip()
    except OSError:
        return None
    return None if raw == "max" else int(raw)


if __name__ == "__main__":
    rss_mb = float(os.environ.get("RSS_PER_WORKER_MB", "60"))
    cpus = cpu_limit()
    by_cpu = max(1, math.floor(2 * cpus + 1))
    mem = memory_limit()
    by_mem = 16 if mem is None else max(1, int(mem * 0.7 / (rss_mb * 1024 ** 2)))
    workers = max(1, min(by_cpu, by_mem, 16))
    print(f"{cpus:.2f}|{by_cpu}|{by_mem}|{workers}|"
          f"{'CPU' if by_cpu <= by_mem else 'память'}")
PY

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

run_and_load() {   # run_and_load <имя> <режим> <лимит|нет> <запросов> <порт>
    local name="$1" mode="$2" limit="$3" reqs="$4" port="$5"
    docker rm -f "$name" > /dev/null 2>&1
    local args=(-d --name "$name" -p "127.0.0.1:$port:8000" -e MODE="$mode"
                -v "$PWD/app.py:/app.py:ro")
    [ "$limit" != "нет" ] && args+=(--memory "$limit" --memory-swap "$limit")
    docker run "${args[@]}" python:3.13-slim python -u /app.py > /dev/null 2>&1
    for _ in $(seq 40); do
        curl -s -m 2 -o /dev/null "http://127.0.0.1:$port/metrics" 2>/dev/null && break
        sleep 0.5
    done
    for _ in $(seq "$reqs"); do
        curl -s -m 2 -o /dev/null "http://127.0.0.1:$port/" 2>/dev/null || true
    done
}

metric() { curl -s -m 3 "http://127.0.0.1:$1/metrics" 2>/dev/null \
           | python3 -c "import json,sys; print(json.load(sys.stdin)['$2'])" 2>/dev/null; }
mib() { awk -v v="$1" 'BEGIN{printf "%.0f", v/1048576}'; }

printf '\n═══ Пункт 1: измерение БЕЗ лимита ═══\n'
run_and_load measure normal нет 500 8810
peak="$(metric 8810 peak)"
anon="$(metric 8810 anon)"
maxv="$(metric 8810 max)"
printf '    memory.max в container: %s (0 = лимит не задан)\n' "$maxv"
printf '    пик: %s МиБ, anon: %s МиБ\n' "$(mib "$peak")" "$(mib "$anon")"
[ "$maxv" = "0" ] && [ "$peak" -gt 0 ] && ok "измерение проведено без лимита" \
    || bad "лимит был задан (max=$maxv)"
docker rm -f measure > /dev/null 2>&1

printf '\n═══ Пункт 2: вывод лимита по формуле ═══\n'
COEFF=1.5
limit_mb="$(awk -v p="$peak" -v c="$COEFF" 'BEGIN{printf "%.0f", p/1048576*c}')"
cat > LIMITS.md <<TXT
# Расчёт лимита памяти

| Величина | Значение | Источник |
|---|---:|---|
| Пик под нагрузкой (500 запросов) | $(mib "$peak") МиБ | memory.peak без лимита |
| Анонимная память | $(mib "$anon") МиБ | memory.stat anon |
| Коэффициент запаса | $COEFF | стабильный профиль, короткие запросы |
| **Лимит памяти** | **$limit_mb МиБ** | пик × коэффициент |

Условие пересмотра: изменение профиля нагрузки, добавление обработки
файлов произвольного размера, переход на несколько worker-процессов.
TXT
sed -n '3,9p' LIMITS.md | sed 's/^/    /'
[ "$limit_mb" -gt 0 ] && ok "лимит выведен: $limit_mb МиБ" || bad "расчёт не получился"

printf '\n═══ Пункт 3: проверка под рассчитанным лимитом ═══\n'
run_and_load verify normal "${limit_mb}m" 800 8811
status="$(docker inspect verify --format '{{.State.Status}}' 2>/dev/null || echo нет)"
if [ "$status" = "running" ]; then
    cur="$(metric 8811 current)"; oom="$(metric 8811 oom_kill)"
    lim="$(metric 8811 max)"
    printf '    статус=%s, использовано %s/%s МиБ (%.0f%%), oom_kill=%s\n' \
        "$status" "$(mib "$cur")" "$(mib "$lim")" \
        "$(awk -v c="$cur" -v m="$lim" 'BEGIN{print c*100/m}')" "$oom"
    [ "$oom" = "0" ] && ok "OOM не произошло при 800 запросах" || bad "oom_kill=$oom"
else
    bad "container остановился: $(docker inspect verify --format '{{.State.ExitCode}}')"
fi
docker rm -f verify > /dev/null 2>&1

printf '\n═══ Пункт 4: контроль — заниженный лимит ═══\n'
low_mb="$(awk -v p="$peak" 'BEGIN{printf "%.0f", p/1048576*0.7}')"
run_and_load toolow normal "${low_mb}m" 800 8812
st="$(docker inspect toolow --format '{{.State.Status}}' 2>/dev/null || echo нет)"
code="$(docker inspect toolow --format '{{.State.ExitCode}}' 2>/dev/null || echo '?')"
oomk="$(docker inspect toolow --format '{{.State.OOMKilled}}' 2>/dev/null || echo '?')"
printf '    лимит %s МиБ (70%% пика): статус=%s код=%s OOMKilled=%s\n' \
    "$low_mb" "$st" "$code" "$oomk"
[ "$code" = "137" ] && [ "$oomk" = "true" ] \
    && ok "заниженный лимит даёт OOM — проверка информативна" \
    || bad "OOM не воспроизвёлся (код=$code)"
docker rm -f toolow > /dev/null 2>&1

printf '\n═══ Пункт 5: утечка против нормы ═══\n'
printf '    %-10s %-12s %-12s %-12s\n' "запросов" "normal МиБ" "leak МиБ" "разница"
docker rm -f n1 l1 > /dev/null 2>&1
docker run -d --name n1 -p 127.0.0.1:8813:8000 -e MODE=normal \
    -v "$PWD/app.py:/app.py:ro" python:3.13-slim python -u /app.py > /dev/null 2>&1
docker run -d --name l1 -p 127.0.0.1:8814:8000 -e MODE=leak \
    -v "$PWD/app.py:/app.py:ro" python:3.13-slim python -u /app.py > /dev/null 2>&1
for _ in $(seq 40); do
    curl -s -m 2 -o /dev/null http://127.0.0.1:8814/metrics 2>/dev/null && break
    sleep 0.5
done

norm_first=0; leak_first=0; norm_last=0; leak_last=0
for round in 1 2 3 4; do
    for _ in $(seq 200); do
        curl -s -m 2 -o /dev/null http://127.0.0.1:8813/ 2>/dev/null || true
        curl -s -m 2 -o /dev/null http://127.0.0.1:8814/ 2>/dev/null || true
    done
    n="$(metric 8813 anon)"; l="$(metric 8814 anon)"
    [ "$round" = "1" ] && { norm_first="$n"; leak_first="$l"; }
    norm_last="$n"; leak_last="$l"
    printf '    %-10s %-12s %-12s %s\n' "$((round * 200))" "$(mib "$n")" "$(mib "$l")" \
        "$(awk -v a="$l" -v b="$n" 'BEGIN{printf "+%.0f МиБ", (a-b)/1048576}')"
done

norm_growth="$(awk -v a="$norm_last" -v b="$norm_first" 'BEGIN{printf "%.1f", (a-b)/1048576}')"
leak_growth="$(awk -v a="$leak_last" -v b="$leak_first" 'BEGIN{printf "%.1f", (a-b)/1048576}')"
printf '    прирост за 600 запросов: normal=%s МиБ, leak=%s МиБ\n' "$norm_growth" "$leak_growth"
awk -v n="$norm_growth" -v l="$leak_growth" 'BEGIN{exit !(l > n * 3)}' \
    && ok "утечка отличима: рост не выходит на плато" \
    || bad "разница неубедительна: $norm_growth против $leak_growth"

leak_peak="$(metric 8814 peak)"
leak_limit="$(awk -v p="$leak_peak" 'BEGIN{printf "%.0f", p/1048576*1.5}')"
printf '    лимит, выведенный из ИЗМЕРЕНИЯ С УТЕЧКОЙ: %s МиБ\n' "$leak_limit"
printf '    он будет исчерпан — рост продолжается линейно\n'
ok "из измерения с утечкой лимит выводить нельзя"
docker rm -f n1 l1 > /dev/null 2>&1

printf '\n═══ Пункт 6: расчёт worker-процессов ═══\n'
calc() {  # calc <cpus> <memory> <rss>
    docker run --rm --cpus "$1" --memory "$2" --memory-swap "$2" \
        -e RSS_PER_WORKER_MB="$3" -v "$PWD/workers.py:/w.py:ro" \
        python:3.13-slim python /w.py 2>/dev/null
}
printf '    %-18s %-8s %-8s %-8s %-8s %s\n' "конфигурация" "CPU" "по CPU" "по пам." "итог" "ограничивает"
for cfg in "4 256m 60" "1 2g 60" "4 2g 200"; do
    set -- $cfg
    IFS='|' read -r cpus by_cpu by_mem workers binding <<< "$(calc "$1" "$2" "$3")"
    printf '    %-18s %-8s %-8s %-8s %-8s %s\n' "$1 CPU, $2, ${3}М" \
        "$cpus" "$by_cpu" "$by_mem" "$workers" "$binding"
done

IFS='|' read -r _ b_cpu b_mem w bind <<< "$(calc 4 256m 60)"
[ "$bind" = "память" ] && [ "$b_cpu" -gt "$b_mem" ] \
    && ok "при избытке CPU ограничивает память ($b_cpu по CPU → $w по памяти)" \
    || bad "ограничивает $bind"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все шесть утверждений подтверждены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker rm -f measure verify toolow n1 l1 > /dev/null 2>&1
cd /tmp && rm -rf /tmp/capacity
exit "$fail"

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

text
═══ Пункт 1: измерение БЕЗ лимита ═══
    memory.max в container: 0 (0 = лимит не задан)
    пик: 28 МиБ, anon: 19 МиБ
  ✓ измерение проведено без лимита

═══ Пункт 2: вывод лимита по формуле ═══
    | Величина | Значение | Источник |
    |---|---:|---|
    | Пик под нагрузкой (500 запросов) | 28 МиБ | memory.peak без лимита |
    | Анонимная память | 19 МиБ | memory.stat anon |
    | Коэффициент запаса | 1.5 | стабильный профиль, короткие запросы |
    | **Лимит памяти** | **42 МиБ** | пик × коэффициент |
  ✓ лимит выведен: 42 МиБ

═══ Пункт 3: проверка под рассчитанным лимитом ═══
    статус=running, использовано 25/42 МиБ (60%), oom_kill=0
  ✓ OOM не произошло при 800 запросах

═══ Пункт 4: контроль — заниженный лимит ═══
    лимит 20 МиБ (70% пика): статус=exited код=137 OOMKilled=true
  ✓ заниженный лимит даёт OOM — проверка информативна

═══ Пункт 5: утечка против нормы ═══
    запросов   normal МиБ   leak МиБ     разница
    200        19           23           +4 МиБ
    400        19           27           +8 МиБ
    600        20           31           +11 МиБ
    800        20           35           +15 МиБ
    прирост за 600 запросов: normal=1.0 МиБ, leak=12.0 МиБ
  ✓ утечка отличима: рост не выходит на плато
    лимит, выведенный из ИЗМЕРЕНИЯ С УТЕЧКОЙ: 57 МиБ
    он будет исчерпан — рост продолжается линейно
  ✓ из измерения с утечкой лимит выводить нельзя

═══ Пункт 6: расчёт worker-процессов ═══
    конфигурация       CPU      по CPU   по пам.  итог     ограничивает
    4 CPU, 256m, 60М   4.00     9        2        2        память
    1 CPU, 2g, 60М     1.00     3        23       3        CPU
    4 CPU, 2g, 200М    4.00     9        7        7        память
  ✓ при избытке CPU ограничивает память (9 по CPU → 2 по памяти)

═══ ИТОГ ═══
  все шесть утверждений подтверждены

Все шесть утверждений подтверждены.

Обратите внимание на пункт 5: колонка «разница» растёт линейно — 4, 8, 11, 15 МиБ. Это и есть подпись утечки. У нормального профиля прирост за те же 600 запросов составил 1 МиБ и остановился.

Три решения, определяющие качество.

Пункт 1 проверяет, что лимит действительно не был задан. Строка memory.max в container: 0 — не украшение вывода: если бы измерение шло под лимитом, ядро усилило бы reclaim, и пик оказался бы заниженным. Проверка предпосылки важнее самого измерения.

Пункт 4 — контрольный, и его провал означал бы недоверие к пункту 3. «OOM не произошло» при лимите 42 МиБ ничего не доказывает, если механизм проверки сломан. Заниженный лимит должен давать 137 — и даёт. Только после этого зелёный результат пункта 3 что-то значит.

Пункт 5 сравнивает прирост между замерами, а не абсолютные значения. Абсолютное потребление у профиля с утечкой выше с самого начала, и это ничего не говорит: приложение могло просто требовать больше. Отсутствие плато — вот признак утечки, и он виден только в динамике.

Чего решение не делает. Не различается утечка и фрагментация: обе дают рост anon, и отличить их без анализа объектов в куче нельзя. Практический признак — фрагментация выходит на плато, утечка нет, но на коротком тесте плато может не наступить. Для реальной диагностики нужны tracemalloc или objgraph внутри приложения. Не рассматривается и планирование ёмкости узла: сумма лимитов всех сервисов и стратегия переподписки — решение уровня платформы, а не отдельного сервиса.

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

bash
docker run --rm --cpus 0.5 --memory 128m --memory-swap 128m python:3.13-slim sh -c '
    echo "memory.max: $(cat /sys/fs/cgroup/memory.max)"
    echo "memory.low: $(cat /sys/fs/cgroup/memory.low)"
    echo "cpu.max:    $(cat /sys/fs/cgroup/cpu.max)"
    awk "/^anon |^file /" /sys/fs/cgroup/memory.stat
'

Ожидается 134217728, 0, 50000 100000 и строки anon/file.

Типичные ошибки

ОшибкаПричинаИсправление
Лимит назначен «на глаз»Не измерялиПровести цикл измерения
Измерение под лимитомКажется естественнымЯдро усиливает reclaim; значение занижено
Лимит выведен из приложения с утечкойНе заметили ростСначала утечка, потом лимит
Расчёт worker'ов только по CPUФормула из документацииУчитывать и память; берётся меньшее
memory.current считают памятью процессаЛогично предположитьВключает page cache; смотреть anon
Мониторят только факт OOMОн однозначенОтношение current/max даёт время на реакцию
oom_kill не проверяют при живом container'еdocker inspect показывает falseУбит потомок, а не PID 1
Считают CPU-лимит защитой от отказаПо аналогии с памятьюCPU тормозит, а не убивает
Reservation считают резервированиемНазвание вводит в заблуждениеЭто мягкая граница, а не гарантия
Игнорируют throttling ниже 10 %«Ещё не критично»Виден на p99 задолго до жалоб

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

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

  1. Почему измерять потребление нужно без установленного лимита?
  2. Как по форме кривой отличить утечку от фрагментации и от кэша?
  3. Почему расчёт worker'ов только по CPU ошибочен?
  4. Чем memory.low отличается от memory.max?
  5. Какие сигналы предупреждают об OOM заранее?

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

  1. Опишите процедуру подбора лимита памяти по шагам.
  2. Как выбрать коэффициент запаса?
  3. Как интерпретировать долю nr_throttled / nr_periods?

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

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

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

  1. Лимит выводят из измерения: запуск без лимита → нагрузка → пик → коэффициент → проверка.
  2. Измерение под лимитом занижено: ядро усиливает reclaim при приближении к границе.
  3. Коэффициент запаса зависит от профиля: 1.3 для стабильного, 2.0+ для переменного.
  4. memory.current включает page cache; потребность определяет anon из memory.stat.
  5. Утечка растёт линейно, фрагментация выходит на плато, кэш зависит от нагрузки.
  6. Из измерения приложения с утечкой лимит выводить нельзя.
  7. Число worker'ов ограничивают и CPU, и память; берётся меньшее из двух.
  8. Расчёт только по CPU при малой памяти гарантирует OOM.
  9. memory.max — жёсткий предел, memory.low — мягкая граница при нехватке на узле.
  10. Отношение memory.current / memory.max выше 0.8 — сигнал за несколько циклов до отказа.
  11. oom_kill > 0 при живом container'е означает гибель потомка, невидимую в docker inspect.
  12. CPU-лимит не убивает, а приостанавливает; сигнал — доля периодов с throttling.

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

ИсточникСсылкаЧто подтверждает
Docker: resource constraintshttps://docs.docker.com/engine/containers/resource_constraints/--memory, --memory-reservation, --cpus
Docker: runtime metricshttps://docs.docker.com/engine/containers/runmetrics/Чтение cgroup, состав memory.stat
cgroup v2https://docs.kernel.org/admin-guide/cgroup-v2.htmlmemory.max, memory.low, memory.peak, memory.events
Kernel: CFS bandwidth controlhttps://docs.kernel.org/scheduler/sched-bwc.htmlМеханизм throttling, cpu.stat
Gunicorn: designhttps://docs.gunicorn.org/en/stable/design.htmlФормула числа worker'ов
Python: memory managementhttps://docs.python.org/3/c-api/memory.htmlАллокатор, арены
Kubernetes: resource managementhttps://kubernetes.io/docs/concepts/configuration/manage-resources-containers/Requests, limits, QoS-классы

Навигация

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

Markdown на GitHub ↗