Deep Engineering

MEASUREMENT

bench/cgroups/practice.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/sre/cgroups-oom
How to run it
python3 bench/cgroups/oom.py      > bench/cgroups/runs/oom.txt
python3 bench/cgroups/practice.py > bench/cgroups/runs/practice.txt

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «cgroups и OOM-killer»

Файл Что делает
oom.py пять наблюдений: убийство при лимите 64 МиБ (код выхода, пустые потоки, пик, failcnt); строка ядра с constraint=CONSTRAINT_MEMCG; выбор жертвы между большим и маленьким процессом; та же программа с тем же запросом под лимитом, который её вмещает; и файловый кеш — учитывается группе, но возвращается
practice.py ответы к задачам урока — прогоном, а не рассуждением: три строки первой задачи и пик потребления группы

Запуск из корня репозитория:

python3 bench/cgroups/oom.py      > bench/cgroups/runs/oom.txt
python3 bench/cgroups/practice.py > bench/cgroups/runs/practice.txt

Что в этих числах воспроизводимо, а что нет

Воспроизводимо на любой машине с cgroup v1 и правом создавать группы: пик потребления, равный лимиту; код выхода −9 (снаружи 137); пустые stdout и stderr убитого процесса; значение CONSTRAINT_MEMCG в строке ядра; исход выбора жертвы между большим и маленьким; нулевой failcnt под лимитом, который вмещает запрос; ненулевой код выхода не появляется при чтении файла вчетверо больше лимита.

Не воспроизводимо и не должно совпадать: сам failcnt (он зависит от того, сколько раз ядру удалось освободить память), номера процессов, имена групп и точный пик в блоке про кеш.

Требования к среде

  • Смонтированный /sys/fs/cgroup/memory (cgroup v1) и право создавать в нём подкаталоги. Во второй версии те же величины называются memory.max, memory.events и memory.peak; скрипты под неё не переписаны.
  • Читаемый dmesg — иначе второй блок честно скажет not readable here и не станет печатать выдуманное.
  • Право писать в /proc/sys/vm/drop_caches. Без него пятый блок напечатает no (not permitted), и его числа перестанут что-либо значить: кеш, заполненный до прогона, заряжен другой группе.

Числа сняты на CPython 3.11.15, Linux 6.18.44 (x86-64, два ядра, 7 ГиБ). Версия интерпретатора роли не играет: всё, что печатают скрипты, — свойства ядра.

Script

115 lines
"""Практика к уроку про cgroups и OOM-killer: три ответа и один пик.

ЗАЧЕМ ОТДЕЛЬНЫЙ ФАЙЛ. Задачи урока проверяются сборкой
(scripts/validate-practice.mjs): показанный читателю код обязан построчно быть
в скрипте, верный вариант — дословно встречаться в записи прогона, а
заявленное число — печататься самой программой.

ЧТО ЗДЕСЬ ПРОВЕРЯЕТСЯ. Три вещи, которые в разговоре путают чаще всего: какой
код выхода видно снаружи; что программа успела сказать о своей смерти (ничего);
и кого ядро выбирает жертвой, когда в группе двое. Плюс пик потребления — то
самое место, где интуиция «процесс вырос за лимит и лопнул» расходится с
устройством: за лимит он не выходит ни на байт.

ЗАПУСК: python3 bench/cgroups/practice.py
Вывод: runs/practice.txt
"""

import os
import subprocess
import sys
import time

CG_ROOT = "/sys/fs/cgroup/memory"
MIB = 1024 * 1024

EATER = """
import os, sys, time
group, want_mib = sys.argv[1], int(sys.argv[2])
hold = float(sys.argv[3]) if len(sys.argv) > 3 else 0.0
with open(group + "/cgroup.procs", "w") as fh:
    fh.write(str(os.getpid()))
blocks = []
for _ in range(want_mib // 4):
    block = bytearray(4 * 1024 * 1024)
    for offset in range(0, len(block), 4096):
        block[offset] = 1
    blocks.append(block)
print("allocated", len(blocks) * 4, "MiB", flush=True)
time.sleep(hold)
"""


def make_group(name: str, limit_mib: int) -> str:
    group = f"{CG_ROOT}/{name}"
    os.makedirs(group, exist_ok=True)
    with open(f"{group}/memory.limit_in_bytes", "w", encoding="utf-8") as fh:
        fh.write(str(limit_mib * MIB))
    return group


def read(group: str, name: str) -> str:
    with open(f"{group}/{name}", encoding="utf-8") as fh:
        return fh.read().strip()


def victim_of(group: str) -> str:
    """Кого ядро убьёт в группе, где двое: державшего мало или просящего много."""
    small = subprocess.Popen(
        [sys.executable, "-c", EATER, group, "40", "30"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    time.sleep(1.5)
    big = subprocess.Popen(
        [sys.executable, "-c", EATER, group, "600"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    big_code = big.wait()
    time.sleep(0.3)
    small_alive = small.poll() is None
    small.kill()
    small.wait()
    if big_code == -9 and small_alive:
        return "big"
    if not small_alive and big_code != -9:
        return "small"
    return "both"


def main() -> None:
    print(f"Python {sys.version.split()[0]} · Linux {os.uname().release} · cgroup v1")
    print()

    # --- Часть 1: три ответа о поведении --------------------------------
    group = make_group("de_practice_kill", 64)
    done = subprocess.run(
        [sys.executable, "-c", EATER, group, "1600"], capture_output=True, text=True
    )
    print(128 - done.returncode)
    print(done.stderr.strip() or "none")
    print(victim_of(make_group("de_practice_victim", 160)))

    # --- Часть 2: до какого пика дорастёт группа с лимитом ---------------
    #
    # ПОЧЕМУ ИМЕННО ПИК. Ошибка, которую задача и ловит: «раз процесс убит за
    # превышение, значит он лимит превысил». Не превысил — учёт идёт по
    # заселённым страницам, и когда следующая страница не помещается, ядро не
    # даёт её занять, а убивает. Максимум за всю жизнь группы поэтому равен
    # лимиту, а не больше него.
    print()
    print(f"limit, MiB                           {int(read(group, 'memory.limit_in_bytes')) // MIB}")
    print(f"peak usage before the kill, MiB      {int(read(group, 'memory.max_usage_in_bytes')) / MIB:.1f}")
    print(f"times the limit was hit (failcnt)    {read(group, 'memory.failcnt')}")

    for name in ("de_practice_kill", "de_practice_victim"):
        try:
            os.rmdir(f"{CG_ROOT}/{name}")
        except OSError:
            pass


if __name__ == "__main__":
    main()