Таймаут вернул 124, а следующий запуск пропущен: какие фоновые утилиты держат flock

—

от автора

Задача из cron превысила лимит timeout 600, в логе код 124. Через минуту cron запускает её снова, и обёртка flock -n молча выходит: lock занят, «предыдущий запуск ещё идёт». Но timeout уже вернул 124: главный процесс задачи завершился, а её фоновые потомки могли остаться.

Я проверил в контейнере семь обычных способов, которыми задача оставляет что-то работать в фоне: setsid -f, nohup … &, start-stop-daemon --background, двойной fork на Python с закрытием дескрипторов и без, ssh-agent, gpg-agent. Каждый запускался из задачи, которая держит flock, и обрывался через timeout 2 и subprocess.run(timeout=2). Демона не убила ни одна обёртка. Будет ли пропущен следующий запуск, решает сама фоновая утилита: закрывает ли она унаследованные дескрипторы. gpg-agent и наивный двойной fork держат lock, ssh-agent и start-stop-daemon отпускают. Зато у тех, что отпускают, процессы копятся: пять запусков подряд дали пять живых ssh-agent. А оборванный запуск в обоих случаях заканчивается одинаково: 124 от timeout, TimeoutExpired от subprocess.run.

Стенд

Задача берёт lock на дескрипторе 9, уходит в фон одним из семи способов и ждёт 30 секунд. Сама она ждёт с закрытым дескриптором, так что после её смерти lock может держать только фоновый потомок:

exec 9>"$D/lock"; flock -n 9 || { echo "job: lock busy"; exit 1; }case $M in  setsid_f)  setsid -f sleep 30 </dev/null >/dev/null 2>&1 ;;  nohup_bg)  nohup sleep 30 </dev/null >/dev/null 2>&1 & ;;  ssd_bg)    start-stop-daemon --start --background --make-pidfile \                 --pidfile "$D/ssd.pid" --exec /usr/bin/sleep -- 30 ;;  py_dfork)  python3 dfork.py keep ;;     # двойной fork + setsid  py_dfork_close) python3 dfork.py close ;;  # то же + os.closerange(3, 1024)  ssh_agent) eval "$(ssh-agent -s)" >/dev/null ;;  gpg_agent) export GNUPGHOME="$D/gh.$$"; mkdir -m 700 "$GNUPGHOME"             gpg-agent --daemon >/dev/null 2>&1 ;;esacsleep 30 9>&-

Через две секунды после возврата обёртки скрипт находит в /proc новые процессы, смотрит, у кого среди открытых файлов есть lock, и пробует flock -n тем же файлом, как следующий запуск. У ssh-agent и gpg-agent каталог /proc/PID/fd закрыт для чтения: оба помечают себя как non-dumpable. Держателя я находил иначе: убивал выживших по одному и смотрел, после кого lock освободился. Каждая ячейка прогнана три раза, повторы совпали. Образ python:3.15 (Debian 13), coreutils 9.7, Python 3.15.1.

Почему вообще важен дескриптор. По man flock(2) блокировка принадлежит открытому описанию файла, копии после fork её разделяют, и снимается она, когда закроется последняя копия. Любой потомок, который унаследовал дескриптор 9 и не закрыл его, держит lock, даже если про lock ничего не знает.

Семь способов уйти в фон

способ

жив после таймаута

lock: timeout 2

lock: run(timeout=2)

кто держит

setsid -f sleep

да

занят 3/3

занят 3/3

sleep

nohup sleep &

только после run

свободен 3/3

занят 3/3

sleep

start-stop-daemon --background

да

свободен 3/3

свободен 3/3

никто

двойной fork без закрытия fd

да

занят 3/3

занят 3/3

python3

двойной fork + closerange

да

свободен 3/3

свободен 3/3

никто

ssh-agent

да

свободен 3/3

свободен 3/3

никто

gpg-agent --daemon

да

занят 3/3

занят 3/3

gpg-agent

Обёртки отработали одинаково во всех 42 прогонах: вернулись через 2,00 секунды с кодом 124 или TimeoutExpired. Ни одна не убила процесс, который ушёл в свою сессию.

Единственная строка, где обёртка что-то решает, — nohup. Он не создаёт новую сессию и даже не меняет группу, он только игнорирует SIGHUP. timeout без --foreground шлёт TERM всей группе процессов, и sleep под nohup умирает. subprocess.run(timeout=) шлёт SIGKILL одному PID, своему прямому ребёнку, поэтому тот же sleep выживает и держит lock. Это видно и по исходникам: в src/timeout.c сигнал уходит группе, в subprocess.run вызывается process.kill().

Во всех остальных строках результат определяет утилита. setsid -f, самодельный двойной fork и gpg-agent унаследованный дескриптор не трогают. start-stop-daemon --background, ssh-agent и двойной fork по образцу PEP 3143 (на стенде — закрыть дескрипторы 3–1023) дескриптор закрывают. Из документации timeout и subprocess этого не узнать, это свойство конкретной программы и её версии.

Пять запусков подряд

Свободный lock выглядит как хороший исход. Я запустил каждую задачу пять раз подряд под timeout 2, как cron с коротким интервалом, с общим lock-файлом:

способ

прошли

пропущены

живых фоновых в конце

lock после

setsid -f

1

4

1

занят

двойной fork без закрытия

1

4

1

занят

gpg-agent

1

4

1

занят

start-stop-daemon --background

5

0

1

свободен

двойной fork + closerange

5

0

5

свободен

ssh-agent

5

0

5

свободен

У одной причины два исхода. Потомок, который держит дескриптор, съедает следующие запуски, и cron видит это как «уже идёт». Потомок, который его закрыл, запускам не мешает, но после каждого таймаута остаётся новый процесс. Только start-stop-daemon не размножился, потому что не стартует второй экземпляр при живом PID из pidfile. Таймаут здесь ни при чём. Оборванный запуск во всех шести строках вернул 124, пропущенные вернули 1 от flock -n. По коду первого запуска не угадать, что будет дальше: пропуски или утечка процессов.

Что освободило lock

Флаги timeout тут не помогают. На синтетическом дереве из трёх потомков с тем же lock (обычный фоновый, после setsid, с trap '' TERM) я прогнал timeout 2, --foreground и -k 1 2: setsid-потомка не достал ни один вариант, lock оставался занят во всех 15 прогонах.

subprocess.run(timeout=2) убил только прямого ребёнка, Popen с os.killpg — всех, кроме setsid-потомка.

Без cgroup всё дерево закрыл один вариант. Обёртка объявляет себя subreaper через prctl(PR_SET_CHILD_SUBREAPER), и осиротевшие потомки переподвешиваются к ней вместо PID 1. После killpg она обходит /proc, собирает всех, кто числится её потомками, и шлёт им KILL:

PR_SET_CHILD_SUBREAPER = 36ctypes.CDLL(None, use_errno=True).prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0)p = subprocess.Popen(cmd, start_new_session=True)try:    p.wait(timeout=2)except subprocess.TimeoutExpired:    os.killpg(p.pid, signal.SIGTERM)    ...                                   # ждём главный процесс до секунды, затем killpg KILL    while left := descendants(os.getpid()):   # обход /proc по ppid        for pid in left:            os.kill(pid, signal.SIGKILL)        ...                                   # пожать зомби через waitpid(-1, WNOHANG)

На синтетическом дереве это заняло 2,05 секунды вместо 2,00, setsid-потомок убит, lock свободен. На демонах из таблицы я subreaper не прогонял; механизм тот же (двойной fork отрывает их от родителя), но это только вывод из устройства, замера нет. Другой путь — cgroup, например systemd с KillMode=control-group: из группы процессов уходят через setsid, а cgroup процесс так не покидает. В моём контейнере /sys/fs/cgroup смонтирован только для чтения, дочернюю группу создать нельзя, поэтому cgroup.kill я не проверял.

Что из этого следует

Демон, оставленный задачей, переживает и timeout, и subprocess.run, и дальше всё зависит от того, закрыл ли он унаследованный дескриптор с lock. Закрыл — следующие запуски идут, но после каждого таймаута в системе на один процесс больше, если утилита сама не проверяет, что уже запущена, как start-stop-daemon с pidfile. Не закрыл — cron пропускает запуски, пока демон жив. Оборванный запуск в обоих случаях возвращает одно и то же: 124 или TimeoutExpired.

Что с этим делать. Проверьте, закрывает ли ваша фоновая утилита унаследованный дескриптор с lock: gpg-agent, setsid -f и самодельный двойной fork его сохраняют и держат lock, ssh-agent, start-stop-daemon и fork с закрытием дескрипторов — закрывают.

Закрыть дескриптор принудительно (9>&- для фонового потомка или flock -o) можно, только если задача переживёт одновременный второй запуск. На стенде с flock -o второй запуск стартовал, пока потомки первого ещё работали: после его старта они сделали 60 записей. Если фоновый процесс ушёл в свою сессию и должен умереть вместе с задачей, нужен subreaper или cgroup, обёртка с таймаутом этого не сделает.

Окружение: блоки с утилитами — образ python:3.15 (Debian 13), Python 3.15.1, GNU coreutils timeout 9.7, gpg-agent 2.4.7, start-stop-daemon 1.22.22, OpenSSH 10.0p1; синтетическое дерево — python:3.15-slim, Python 3.15.0, bash 5.2.37, util-linux flock 2.41.5. Мануал timeout на сайте GNU описывает 9.12, логику группы и -k я сверял по текущему src/timeout.c.

ссылка на оригинал статьи https://habr.com/ru/articles/1099830/