11.4. Resource limits и память Python
Цели
После этого материала вы сможете:
- провести измерение и вывести из него значения лимитов, а не назначить их наугад;
- отличить «лимит занижен» от «утечка памяти» от «фрагментация» по форме графика;
- рассчитать число worker-процессов с учётом обоих ограничений — CPU и памяти;
- объяснить, зачем нужен
--memory-reservationпри плотном размещении на узле; - спланировать ёмкость узла и понять, что происходит при переподписке;
- назвать сигналы, по которым видно приближение к лимиту до отказа.
Предварительные знания
- 6.13. Resource limits и память Python — механика cgroup;
- 6.11. Worker processes — расчёт числа процессов;
- 11.3. Healthchecks и readiness.
Механика разобрана в уроке 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_requests | 2.0 | Накопление фрагментации |
Что измерять
| Метрика | Где | Что показывает |
|---|---|---|
memory.current | /sys/fs/cgroup/ | Текущее потребление cgroup, включая page cache |
memory.peak | То же, ядро 5.19+ | Максимум за время жизни |
memory.stat → anon | То же | Анонимная память — ближе всего к «памяти процесса» |
memory.stat → inactive_file | То же | Реклеймируемый кэш; вычитается в docker stats |
memory.events → oom_kill | То же | Число убийств OOM killer'ом |
memory.events → high | То же | Сколько раз упирались в memory.high |
Ключевое уточнение: memory.current включает page cache. Приложение, прочитавшее гигабайтный файл, покажет рост потребления, хотя данные уже освобождены. Кэш реклеймируется под давлением и OOM не вызывает, но искажает измерение.
Для подбора лимита правильнее смотреть на anon из memory.stat плюс запас на кэш.
Три причины роста RSS и как их различить
Симптом один — память растёт. Причины разные, и лечатся они по-разному.
Утечка Фрагментация Кэш / рост нагрузки
────── ──────────── ───────────────────
│ ╱ │ ╱‾‾‾‾ │ ╱‾╲ ╱‾╲
│ ╱ │ ╱ │ ╱ ╲_╱ ╲
│ ╱ │╱ │ ╱
└──────── └──────── └────────
рост линейный, рост до плато, рост коррелирует
не останавливается затем стабильно с нагрузкой, падает
| Признак | Утечка | Фрагментация | Кэш |
|---|---|---|---|
| Выходит на плато | Нет | Да | Да |
| Зависит от нагрузки | Слабо | Слабо | Сильно |
| Падает при простое | Нет | Нет | Да |
Помогает max_requests | Нет | Да | Не нужно |
anon растёт | Да | Да | Нет |
file растёт | Нет | Нет | Да |
Практический тест: снять memory.stat дважды с интервалом и посмотреть, что именно выросло — anon или file.
Расчёт числа worker-процессов
Формула из документации Gunicorn учитывает только CPU. В container ограничений два, и действует более строгое:
по 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/worker | 3 | 5 | 3 |
| 2 CPU, 256 MiB, 60 MiB/worker | 5 | 2 | 2 |
| 0.5 CPU, 1 GiB, 60 MiB/worker | 2 | 11 | 2 |
| 4 CPU, 2 GiB, 200 MiB/worker | 9 | 7 | 7 |
Вторая строка — типичная ошибка: CPU много, памяти мало. Расчёт только по CPU дал бы пять worker'ов и гарантированный OOM (урок 6.11).
Limits и reservations
--memory (limits) | --memory-reservation (reservations) | |
|---|---|---|
| Файл cgroup | memory.max | memory.low |
| Тип | Жёсткий предел | Мягкая гарантия |
| Превышение | OOM kill | Ничего |
| Смысл | Больше не дадим | Столько отбирать в последнюю очередь |
| Влияет на планирование | Нет | В оркестраторе — да |
Reservation полезен при плотном размещении: при нехватке памяти на узле ядро сначала отбирает у тех, кто превысил свой memory.low.
Практика: reservation = рабочий объём, limit = рабочий объём × коэффициент.
Ёмкость узла и переподписка
узел: 16 GiB
сервисы: 10 × (limit 2 GiB) = 20 GiB лимитов
Это допустимо: лимит — потолок, а не резервирование. Приложения обычно потребляют меньше.
Но переподписка означает, что при одновременном пике узел уйдёт в своп или OOM на уровне host, а не cgroup. Симптом другой: убитым окажется не тот, кто превысил лимит, а тот, у кого oom_score выше.
| Стратегия | Сумма лимитов | Когда |
|---|---|---|
| Без переподписки | ≤ ёмкости узла | Критичные сервисы |
| Умеренная | ≤ 1.5× ёмкости | Типичный случай |
| Агрессивная | > 2× ёмкости | Пакетные задачи, тестовые среды |
При переподписке reservation перестаёт быть формальностью: он определяет, кого ядро тронет последним.
Сигналы приближения к лимиту
Отказ по памяти происходит внезапно. До него есть сигналы:
| Сигнал | Где смотреть | Что означает |
|---|---|---|
memory.current / memory.max > 0.8 | cgroup | Запаса почти нет |
memory.events → high растёт | cgroup | Упираемся в мягкий лимит |
pgmajfault растёт | memory.stat | Свопинг: страницы читаются с диска |
| Растёт время отклика | Метрики приложения | Аллокатор тратит время на reclaim |
oom_kill > 0 при живом container'е | memory.events | Убит потомок (урок 6.13) |
Последняя строка — самая коварная: docker inspect покажет OOMKilled: false, а часть worker'ов будет тихо исчезать.
CPU: лимит не убивает, а тормозит
Превышение CPU-квоты не приводит к отказу — процесс приостанавливается (урок 6.13).
Сигнал — доля периодов с throttling:
throttled_ratio = nr_throttled / nr_periods
| Доля | Оценка | Действие |
|---|---|---|
| < 1 % | Норма | Ничего |
| 1–10 % | Заметно на p99 | Рассмотреть увеличение |
| 10–25 % | Существенно | Увеличить лимит или уменьшить worker'ов |
| > 25 % | Лимит занижен вдвое и более | Увеличить |
Важная деталь: throttling возможен и при средней загрузке ниже лимита — из-за всплесков внутри 100-миллисекундного окна.
Внутренний механизм
Почему измерять надо без лимита
При приближении к memory.max ядро усиливает reclaim: сбрасывает кэш, выгружает в своп, заставляет аллокатор возвращать страницы. Приложение продолжает работать, но потребляет меньше — за счёт производительности.
Измерение в этом режиме даст заниженное значение, а лимит, выведенный из него, зафиксирует приложение в состоянии постоянного давления.
Как считается memory.current
Сумма нескольких категорий из memory.stat:
| Категория | Что это |
|---|---|
anon | Анонимная память процессов — куча, стек |
file | Page cache прочитанных файлов |
kernel_stack | Стеки ядра для потоков |
slab | Структуры ядра |
sock | Буферы сокетов |
docker stats показывает memory.current минус inactive_file — то есть за вычетом легко реклеймируемого кэша. Поэтому его число ближе к «настоящему» потреблению, чем сырое значение.
Команды и примеры
Шаг 1–3: измерение без лимита
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
Ожидаемый вывод:
═══ измерение без лимита ═══
режим: 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 определяет потребность.
Различаем утечку, фрагментацию и норму
cd /tmp/limits
for mode in normal fragment leak; do
./measure.sh "$mode" 400
echo
done
Ожидаемый вывод:
режим: 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: подтверждение под лимитом
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
Ожидаемый вывод:
═══ подтверждение лимита ═══
лимит 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-процессов по двум ограничениям
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
Ожидаемый вывод:
═══ 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
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
Ожидаемый вывод:
═══ что попадает в 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 и прямую запись — но это выходит за рамки штатных возможностей.
Сигналы до отказа
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
Ожидаемый вывод:
═══ приближение к лимиту 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 как сигнал
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
Ожидаемый вывод:
═══ --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 предупреждает до того, как пользователи заметят.
cd /tmp && rm -rf /tmp/limits
Практическое упражнение
Задание. Проведите полный цикл подбора лимитов и подтвердите шесть утверждений.
- Измерение выполнено без лимита; пик зафиксирован.
- Лимит выведен из пика по записанной формуле, а не назначен.
- Проверка под лимитом: OOM не происходит,
oom_kill = 0. - Заниженный лимит воспроизводит OOM с кодом
137— контрольная проверка. - Утечка отличена от нормального роста по форме кривой; показано, что из измерения с утечкой лимит выводить нельзя.
- Число worker'ов рассчитано по обоим ограничениям; показан случай, где ограничивает память при избытке CPU.
Подсказки
Подсказка 1
Для пункта 5 нужны два прогона одинаковой длины с разным профилем и сравнение приростов.
Подсказка 2
Пункт 3 проверяется полем oom_kill в memory.events, а не только статусом container'а.
Подсказка 3
Для пункта 6 достаточно запустить расчёт при двух наборах лимитов.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Пункт 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 внутри приложения. Не рассматривается и планирование ёмкости узла: сумма лимитов всех сервисов и стратегия переподписки — решение уровня платформы, а не отдельного сервиса.
Проверка результата
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 задолго до жалоб |
Контрольные вопросы
На понимание:
- Почему измерять потребление нужно без установленного лимита?
- Как по форме кривой отличить утечку от фрагментации и от кэша?
- Почему расчёт worker'ов только по CPU ошибочен?
- Чем
memory.lowотличается отmemory.max? - Какие сигналы предупреждают об OOM заранее?
На применение:
- Опишите процедуру подбора лимита памяти по шагам.
- Как выбрать коэффициент запаса?
- Как интерпретировать долю
nr_throttled / nr_periods?
На диагностику:
- Container живёт неделю, потом падает с кодом
137. Первая версия? - Время отклика выросло, память в норме, ошибок нет. Что проверить?
Краткое резюме
- Лимит выводят из измерения: запуск без лимита → нагрузка → пик → коэффициент → проверка.
- Измерение под лимитом занижено: ядро усиливает reclaim при приближении к границе.
- Коэффициент запаса зависит от профиля: 1.3 для стабильного, 2.0+ для переменного.
memory.currentвключает page cache; потребность определяетanonизmemory.stat.- Утечка растёт линейно, фрагментация выходит на плато, кэш зависит от нагрузки.
- Из измерения приложения с утечкой лимит выводить нельзя.
- Число worker'ов ограничивают и CPU, и память; берётся меньшее из двух.
- Расчёт только по CPU при малой памяти гарантирует OOM.
memory.max— жёсткий предел,memory.low— мягкая граница при нехватке на узле.- Отношение
memory.current / memory.maxвыше 0.8 — сигнал за несколько циклов до отказа. oom_kill > 0при живом container'е означает гибель потомка, невидимую вdocker inspect.- CPU-лимит не убивает, а приостанавливает; сигнал — доля периодов с throttling.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: resource constraints | https://docs.docker.com/engine/containers/resource_constraints/ | --memory, --memory-reservation, --cpus |
| Docker: runtime metrics | https://docs.docker.com/engine/containers/runmetrics/ | Чтение cgroup, состав memory.stat |
| cgroup v2 | https://docs.kernel.org/admin-guide/cgroup-v2.html | memory.max, memory.low, memory.peak, memory.events |
| Kernel: CFS bandwidth control | https://docs.kernel.org/scheduler/sched-bwc.html | Механизм throttling, cpu.stat |
| Gunicorn: design | https://docs.gunicorn.org/en/stable/design.html | Формула числа worker'ов |
| Python: memory management | https://docs.python.org/3/c-api/memory.html | Аллокатор, арены |
| Kubernetes: resource management | https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ | Requests, limits, QoS-классы |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Конфигурация и secrets
Главное оглавление