MEASUREMENT
bench/signals/lifecycle.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/signals
- How to run it
python3 bench/signals/lifecycle.py > bench/signals/runs/lifecycle.txt python3 bench/signals/practice.py > bench/signals/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
Замеры для урока «Сигналы, зомби и PID 1»
| Файл | Что делает |
|---|---|
lifecycle.py |
четыре наблюдения без единого замера времени: потомок, чей статус не забрали; сирота и её новый родитель; SIGTERM первому процессу пространства имён — без обработчика и с ним; коды выхода при завершении сигналом |
practice.py |
ответы к задачам урока — прогоном, а не рассуждением: пять строк первой задачи и доля потомков, оставшихся зомби |
Запуск из корня репозитория:
python3 bench/signals/lifecycle.py > bench/signals/runs/lifecycle.txt
python3 bench/signals/practice.py > bench/signals/runs/practice.txt
Что в этих числах воспроизводимо, а что нет
Воспроизводимо у любого читателя на Linux: буква состояния Z, код
выхода, забранный wait; номер родителя сироты (1 или номер ближайшего
подписчика-жнеца, если он в системе есть); исход SIGTERM для PID 1 в обоих
случаях; коды 137 и 143.
Не воспроизводимо и не должно совпадать: номера процессов, время
остановки в миллисекундах и льготный срок. В practice.py льготный срок
сокращён до двух секунд, чтобы прогон был быстрым; у оркестраторов он обычно
десять или тридцать. Отсюда и число «2009 мс» в записи прогона — это не свойство системы, а
выбранный здесь срок плюс время, за которое доходит SIGKILL.
Требования к среде
unshare --fork --pid --mount-proc должен быть разрешён: без него первый
процесс пространства имён не создать, и третий блок lifecycle.py напечатает
«не нашёл потомка unshare». В контейнере, где сняты эти числа, он разрешён.
Числа сняты на CPython 3.11.15, Linux 6.18.44 (x86-64, два ядра). Версия интерпретатора здесь роли не играет: всё, что печатают оба скрипта, — свойства ядра, а не языка.
Script
217 lines"""Жизнь и смерть процесса: зомби, сирота и особый статус PID 1.
ЗАЧЕМ ЭТОТ ФАЙЛ. Три вещи из этого урока обычно пересказывают по памяти, и все
три проверяются на одной машине за секунды: что делает ядро с потомком, статус
которого никто не забрал; кому достаётся потомок, чей родитель умер; и почему
`SIGTERM`, посланный первому процессу контейнера, часто не делает ничего.
Последнее — не особенность Docker и не ошибка образа, а правило ядра, записанное
в `pid_namespaces(7)`: первому процессу пространства имён доставляются только те
сигналы, для которых он установил обработчик. Обработчик поставлен — сигнал
работает; не поставлен — сигнал не делает ничего. Здесь это показано обоими
случаями подряд.
ПОЧЕМУ ПОДПИСИ ПО-АНГЛИЙСКИ. Урок существует в двух языках, и запись прогона
цитируется в обоих дословно. Русские подписи означали бы либо два прогона с
расходящимися числами, либо кириллицу в английском материале, которой там быть
не должно. Английские подписи цитируются как есть обеими версиями — так же
устроены записи Go-линии.
ЧТО ЗДЕСЬ ИЗМЕРЯЕТСЯ, А ЧТО ПРОСТО ПЕЧАТАЕТСЯ. Времени тут нет вовсе: вопрос не
«сколько стоит», а «что происходит». Поэтому печатаются состояния из `/proc`,
коды выхода и идентификаторы родителей — то, что можно проверить, не веря на
слово ни автору, ни пересказу.
ЗАПУСК: python3 bench/signals/lifecycle.py
Вывод: runs/lifecycle.txt
"""
import os
import signal
import subprocess
import sys
import time
def show(title: str) -> None:
print()
print(title)
print("-" * len(title))
def row(label: str, value: object) -> None:
print(f" {label:<42} {value}")
def proc_state(pid: int) -> str:
"""Однобуквенное состояние процесса из /proc/<pid>/stat.
Читается третье поле, но имя команды в скобках может содержать пробелы,
поэтому строка режется по последней закрывающей скобке, а не по пробелам.
"""
try:
with open(f"/proc/{pid}/stat", encoding="utf-8") as fh:
data = fh.read()
except FileNotFoundError:
return "no such process"
tail = data[data.rfind(")") + 2 :]
return tail.split()[0]
STATE_WORDS = {
"R": "R (running)",
"S": "S (sleeping)",
"D": "D (uninterruptible sleep)",
"Z": "Z (zombie)",
"T": "T (stopped)",
}
def human_state(pid: int) -> str:
state = proc_state(pid)
return STATE_WORDS.get(state, state)
def target_child(pid: int) -> int | None:
"""PID потомка процесса — того, кто внутри пространства имён стал первым.
Список детей ядро отдаёт файлом; из него берётся первый, потому что
`unshare --fork` порождает ровно одного.
"""
try:
with open(f"/proc/{pid}/task/{pid}/children", encoding="utf-8") as fh:
kids = fh.read().split()
except FileNotFoundError:
return None
return int(kids[0]) if kids else None
# ------------------------------------------------------------------ 1
def block1() -> None:
show("1. ZOMBIE: the child is gone, the record is not")
pid = os.fork()
if pid == 0:
os._exit(7)
time.sleep(0.2)
row("child finished, status not collected", human_state(pid))
row("why the kernel keeps the record", "so the parent can read the exit code")
done, status = os.waitpid(pid, 0)
row("state after wait", human_state(pid))
row("exit code the record was held for", os.waitstatus_to_exitcode(status))
row("conclusion", "a zombie is an uncollected status, not leaked memory")
# ------------------------------------------------------------------ 2
def block2() -> None:
show("2. ORPHAN: the parent died first")
# Внук печатает своего родителя ДО и ПОСЛЕ смерти отца: только так видно
# смену родителя, а не её последствия.
code = (
"import os, time, sys\n"
"print(' grandchild: parent right after start ', os.getppid(), flush=True)\n"
"time.sleep(0.6)\n"
"print(' grandchild: parent after father exits ', os.getppid(), flush=True)\n"
)
parent = (
"import subprocess, sys, time\n"
f"subprocess.Popen([sys.executable, '-c', {code!r}])\n"
"time.sleep(0.2)\n"
)
subprocess.run([sys.executable, "-c", parent], check=True)
time.sleep(0.8)
row("who the new parent is", "nearest subreaper, or PID 1")
row("why it matters", "the orphan's status goes to it, not to the lost parent")
# ------------------------------------------------------------------ 3
def block3() -> None:
show("3. PID 1: one SIGTERM, two outcomes")
# Программа-подопытная: спит, а по флагу ставит обработчик SIGTERM.
prog = (
"import os, signal, sys, time\n"
"handle = sys.argv[1] == 'handler'\n"
"if handle:\n"
" signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))\n"
"print(os.getpid(), flush=True)\n"
"time.sleep(5)\n"
)
for mode, label in (("nohandler", "no handler"), ("handler", "with handler")):
proc = subprocess.Popen(
["unshare", "--fork", "--pid", "--mount-proc", sys.executable, "-c", prog, mode],
stdout=subprocess.PIPE,
text=True,
)
inner_pid = proc.stdout.readline().strip()
time.sleep(0.3)
# СИГНАЛ ШЛЁТСЯ НЕ unshare, А ЕГО ПОТОМКУ. Внутри пространства имён у
# него PID 1, снаружи — обычный номер, и именно снаружи мы его и шлём:
# так делает и оркестратор. Первый черновик слал сигнал самому
# `unshare` и получал «жив» в обоих случаях — то есть не проверял
# ничего, а показывал поведение постороннего процесса.
target = target_child(proc.pid)
row(f"PID inside the namespace ({label})", inner_pid)
if target is None:
row(f"SIGTERM, {label}", "no child of unshare found")
proc.kill()
proc.wait()
continue
os.kill(target, signal.SIGTERM)
deadline = time.time() + 1.5
outcome = "ALIVE: the signal did nothing"
while time.time() < deadline:
state = proc_state(target)
if state in ("no such process", "Z"):
outcome = "exited"
break
time.sleep(0.05)
row(f"SIGTERM from outside, {label} (PID {target})", outcome)
proc.kill()
proc.wait()
row("the rule", "no default signal action is carried out for PID 1")
row("what it means for a container", "an image with no handler ignores SIGTERM")
# ------------------------------------------------------------------ 4
def block4() -> None:
show("4. EXIT CODES: 128 + signal number")
cases = [
("exited on its own", [sys.executable, "-c", "raise SystemExit(3)"], None),
("SIGTERM, default disposition", [sys.executable, "-c", "import time; time.sleep(5)"], signal.SIGTERM),
("SIGKILL", [sys.executable, "-c", "import time; time.sleep(5)"], signal.SIGKILL),
]
for label, cmd, sig in cases:
proc = subprocess.Popen(cmd)
if sig is not None:
time.sleep(0.2)
proc.send_signal(sig)
code = proc.wait()
note = ""
if code < 0:
note = f"(the shell reports {128 - code})"
row(label, f"{code} {note}")
row("why 137 in OOM reports", "128 + 9: killed by SIGKILL")
row("why 143 during a rollout", "128 + 15: killed by SIGTERM")
def main() -> None:
print(f"Python {sys.version.split()[0]} · Linux {os.uname().release}")
block1()
block2()
block3()
block4()
if __name__ == "__main__":
main()