Как systemd превратил обычный скрипт перезапуска в бесконечный дедлок — почему мониторинг не заметил собственную смерть

от автора

Конструкция, о которой пойдёт речь, есть почти в каждой инфраструктуре: скрипт генерирует конфиг сервиса и перезапускает сервис, если конфиг изменился. Четыре строки логики, юнит на десять строк, работает годами.

У меня эта конструкция выключила сбор данных на всех четырёх хостах, где она установлена. На двух — вечным дедлоком между двумя юнитами, который невозможно переждать, потому что таймаут там отключён по умолчанию. На третьем — командой, которая молча ничего не делает именно тогда, когда нужна. И ни на одном из хостов никто не заметил этого две-три недели, потому что мониторинг следил за инфраструктурой, но не за собой.

Дальше — разбор механики со ссылками на документацию, схема «до/после», набор команд, которыми такую поломку можно найти у себя, и выводы, часть из которых оказалась неожиданной для меня самого.

Расстановка сил

Есть служба-экспортёр, читающая конфиг из файла. Есть скрипт синка, который этот файл генерирует из внешнего источника и перезапускает экспортёр при изменениях. Скрипт запускается таймером раз в минуту.

ini

# monitoring-snmp-sync.service[Unit]Description=Sync SNMP targets and auths[Service]Type=oneshotExecStart=/usr/local/bin/snmp-sync.sh

ini

# monitoring-snmp-exporter.service[Unit]Description=SNMP exporterAfter=monitoring-snmp-sync.service[Service]ExecStart=/usr/local/bin/snmp_exporter --config.file=/etc/snmp/snmp.ymlRestart=on-failure

bash

# snmp-sync.sh, фрагментif ! cmp -s "$new_auths" "$cur_auths"; then    install -m 0640 "$new_auths" "$cur_auths"    systemctl try-restart monitoring-snmp-exporter   # ← вот онаfi

After= здесь обоснован: экспортёру нужен свежий конфиг. try-restart выбран сознательно — «чтобы не поднимать службу, если её намеренно остановили». Скрипт отработал сотни раз без нареканий.

Симптом

При «служба не поднимается, но и не падает» первым делом стоит смотреть не статус юнита, а очередь задач менеджера:

# systemctl list-jobsJOB    UNIT                              TYPE  STATE787297 monitoring-snmp-sync.service      start running787372 monitoring-snmp-exporter.service  start waiting

Две задачи: синк «выполняется», экспортёр «ждёт». На одном хосте это состояние висело несколько часов, на другом — две недели. Ошибок в журнале нет, падений нет. Статус юнита при этом выглядит безобидно: activating, ни слова про проблему.

Механика: точнее, чем «дедлок как у потоков»

Аналогия с потоками и мьютексами тут скорее мешает, потому что участники другие: не процессы ждут блокировку, а задачи менеджера ждут друг друга согласно объявленному порядку. Разберём по шагам, в терминах systemd.

Начнём с того, что делает After=. Дословно из man systemd.unit(5):

If a unit foo.service contains a setting After=bar.service and both units are being started, foo.service‘s start-up is delayed until bar.service has finished starting up.

Ключевое — выделенное условие. After= не «запретить запуск, пока сервис активен», а «если обе единицы запускаются одновременно, отложить запуск второй до завершения запуска первой». Если синк уже успешно отработал и неактивен, экспортёр стартует немедленно — никакой задержки нет. Проблема возникает ровно в момент, когда у синка есть активная задача запуска.

Теперь цепочка:

  1. Таймер ставит задачу start monitoring-snmp-sync.service. Она переходит в состояние running — процесс ExecStart выполняется.

  2. Скрипт внутри этого процесса вызывает systemctl try-restart monitoring-snmp-exporter. Вызов синхронный: systemctl по умолчанию ждёт завершения поставленной задачи.

  3. Менеджер создаёт задачу для экспортёра. Но синк в этот момент находится в состоянии запуска, а экспортёр объявил After= на него — то есть выполняется ровно условие «both units are being started». Задача экспортёра переходит в waiting.

  4. Задача экспортёра ждёт завершения запуска синка. Запуск синка завершится, когда вернётся ExecStart. ExecStart ждёт возврата systemctl. systemctl ждёт выполнения задачи экспортёра.

ДО:  timer ──► sync.service  [start: running]                 │                 │  ExecStart=/usr/local/bin/snmp-sync.sh                 │     └── systemctl try-restart exporter   (синхронно!)                 │                     │                 │                     ▼                 │         exporter.service  [start: waiting]                 │                     │                 │                     │  After=sync.service                 └─────────────────────┘                        ожидание по кругу → DEADLOCK

Обратите внимание: ни After=, ни try-restart сами по себе не ошибочны. Ошибочна их комбинация с синхронным вызовом из юнита, входящего в ту же цепочку упорядочивания.

Почему тупик именно вечный

Обычное возражение: «повиснет и отвалится по таймауту, systemd же страхует». Для Type=oneshot это не так. man systemd.service(5), раздел TimeoutStartSec=:

Defaults to DefaultTimeoutStartSec= set in the manager, except when Type=oneshot is used, in which case the timeout is disabled by default.

То есть привычные 90 секунд DefaultTimeoutStartSec на oneshot не распространяются. Логика разработчиков понятна: oneshot часто используется для длительных разовых операций — миграций, бэкапов, развёртывания, — и убивать их по таймеру опаснее, чем дать выполняться. Побочный эффект: oneshot-юнит, попавший в дедлок, висит бесконечно. В нашем случае TimeoutStartSec= задан не был — значит у менеджера не оказалось естественной точки, в которой он мог бы прервать запуск синка и тем самым разорвать цикл.

Проверяется одной командой:

$ systemctl show -p Type -p TimeoutStartUSec monitoring-snmp-sync.serviceType=oneshotTimeoutStartUSec=infinity

Дальше включается механизм, уже безобидный: таймер каждую минуту пытается запустить синк, видит существующую задачу для этого юнита и присоединяется к ней вместо создания новой. Новых попыток не возникает, в журнале тишина.

Почему хуже, чем «сервис не перезапустился»

try-restart — это не «перезапусти, если получится». man systemctl(1):

try-restart — Restart one or more units specified on the command line if the units are running. Do nothing if units are not running.

В нашем инциденте это выглядело так: try-restart застал экспортёр работающим, остановил его — а последующий запуск оказался заблокирован зависимостью After= и остался в очереди навсегда. Поэтому корректная формулировка не «экспортёр не перезапустился», а «экспортёр был выключен и не включён обратно». Сбор данных не подвис — его аккуратно погасили и оставили в этом состоянии.

Побочный эффект, который сбивал с толку при диагностике: в метриках висела серия up{snmp_device="..."} = 0 по устройству, которое из системы удалили неделей раньше. Файл со списком целей обновляет ровно тот скрипт, который завис, — агент продолжал опрашивать замороженный список. Ложная метрика держалась не из-за ошибки в логике целей, а потому что процесс, который должен был её убрать, стоял в очереди менеджера.

Вторая причина: no-op в самый неподходящий момент

На четвёртом хосте дедлока не было, а экспортёр всё равно лежал — с 25 июля, за три недели до разбора:

snmp_exporter[2841]: FATAL: error parsing config file:  yaml: line 12: mapping values are not allowed in this contextsystemd[1]: monitoring-snmp-exporter.service: Main process exited,  code=exited, status=1/FAILUREsystemd[1]: monitoring-snmp-exporter.service: Start request repeated  too quickly.systemd[1]: monitoring-snmp-exporter.service: Failed with result  'exit-code'.

Что произошло: синк однажды записал невалидный YAML (гонка между записью файла и чтением его экспортёром), служба упала, Restart=on-failure перезапускал её, пока не сработал лимит StartLimitBurst — по умолчанию 5 попыток за StartLimitIntervalSec=10s — и юнит остался в failed.

Через минуту конфиг исправился сам: синк перезаписывает файл каждый проход. Но служба не поднялась, потому что единственной командой, способной её поднять, был try-restart — а он, как мы выяснили выше, для неработающей службы «does nothing».

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

Когда службу попросили руками, она поднялась с первой попытки — конфиг давно был валиден:

# systemctl reset-failed monitoring-snmp-exporter# systemctl start monitoring-snmp-exporter# systemctl is-active monitoring-snmp-exporteractive

Почему здесь нужен reset-failed, а не просто start? Потому что юнит, исчерпавший лимит запусков, отказывается стартовать и по прямой команде. man systemctl(1) про reset-failed:

In addition to resetting the «failed» state of a unit it also resets various other per-unit properties: the start rate limit counter of all unit types is reset to zero, as is the restart counter of service units.

Что чинили

1. Неблокирующий вызов — закрывает дедлок в корне.

bash

systemctl --no-block restart monitoring-snmp-exporter

--no-block ставит задачу в очередь и сразу возвращает управление. Процесс ExecStart завершается, запуск синка считается завершённым, условие After= выполняется, экспортёр стартует.

ПОСЛЕ:  timer ──► sync.service  [start: running → done]                 │                 │  ExecStart=/usr/local/bin/snmp-sync.sh                 │     └── systemctl --no-block restart exporter                 │                    (задача в очередь, возврат сразу)                 ▼            sync завершён                 │                 ▼         exporter.service  [start: running]                 │  After=sync.service — условие выполнено                 ▼              active

Правило, которое я вынес — и оно узкое, а не «всегда используйте --no-block»: если юнит запускает или перезапускает другой юнит, связанный с ним через After=/Before=, синхронный вызов systemctl требует отдельной проверки на цикл. Синхронный вызов сам по себе полезен — он даёт код возврата и позволяет узнать, удалось ли действие; в скриптах развёртывания и в интерактивной работе это именно то, что нужно. Опасна только комбинация «синхронный вызов + цепочка упорядочивания, в которую входит вызывающий».

2. restart + reset-failed вместо try-restart.

bash

systemctl reset-failed monitoring-snmp-exporter 2>/dev/null || truesystemctl --no-block restart monitoring-snmp-exporter

restart поднимает юнит независимо от текущего состояния, reset-failed снимает failed и обнуляет счётчик попыток.

3. Перезапуск по состоянию, а не только по событию.

bash

changed=0if ! cmp -s "$new_auths" "$cur_auths"; then    install -m 0640 "$new_auths" "$cur_auths"; changed=1fiif ! cmp -s "$new_targets" "$cur_targets"; then    install -m 0644 "$new_targets" "$cur_targets"; changed=1fiif [ "$changed" = 1 ] \   || ! systemctl is-active --quiet monitoring-snmp-exporter; then    systemctl reset-failed monitoring-snmp-exporter 2>/dev/null || true    systemctl --no-block restart monitoring-snmp-exporterfi

Было: «конфиг изменился → перезапусти». Стало: «конфиг изменился или служба не активна → перезапусти». Первое лечит только предвиденный сценарий, второе — любой, включая временно невалидный конфиг от самого синка.

4. Про StartLimitIntervalSec=0 — и почему это не универсальный совет.

На двух хостах я снял лимит запусков:

ini

[Unit]StartLimitIntervalSec=0

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

Чего это стоит: если служба падает не из-за временной причины, Restart=on-failure будет перезапускать её в бесконечном цикле. С настройками по умолчанию (RestartSec=100ms) это десятки перезапусков в секунду — заметная нагрузка и залитый журнал. Поэтому отключение лимита осмысленно только вместе с двумя оговорками: заданным RestartSec= и внешним контролем состояния службы.

ini

[Unit]StartLimitIntervalSec=0[Service]Restart=on-failureRestartSec=5s

Более консервативная альтернатива, которая мне нравится больше и которую я оставил на третьем хосте: не отключать защиту, а расширить окно так, чтобы самолечение успевало сработать.

ini

[Unit]StartLimitIntervalSec=5minStartLimitBurst=20

Смысл: за пять минут синк точно пройдёт несколько раз, значит валидный конфиг успеет появиться, а двадцать попыток — потолок, который защитит от бесконечного цикла при настоящей поломке. Общее правило для таких случаев: не «выключить rate limit, потому что служба должна работать всегда», а «подобрать окно под период самолечения».

5. Тест, который ломает конфиг намеренно.

На этом этапе мы проверяем именно отказоустойчивость: битый конфиг должен привести к падению экспортёра, но не должен оставить его в failed после следующего прохода синка.

bash

#!/usr/bin/env bashset -euo pipefail# 1. Ломаем конфиг руками — ровно так, как это когда-то сделал синкprintf 'auths:\n  broken: : :\n' > /etc/snmp/snmp_auths.ymlsystemctl --no-block restart monitoring-snmp-exporter || truesleep 3if systemctl is-active --quiet monitoring-snmp-exporter; then    echo "ожидали падение на битом конфиге"; exit 1fi# 2. Ждём один проход синка (таймер раз в минуту)sleep 70# 3. Служба должна подняться сама и отдавать метрикиsystemctl is-active --quiet monitoring-snmp-exportercurl -sf "http://127.0.0.1:9116/snmp?target=192.168.1.1" >/dev/null# 4. И никаких зависших задач в очереди менеджераtest -z "$(systemctl list-jobs --no-legend | grep snmp || true)"echo OK

Как найти такое у себя

Это, пожалуй, полезнее самой истории. Порядок команд, которым я теперь проверяю любую связку «скрипт + сервис».

Зависшие задачи. Первое и самое быстрое:

bash

systemctl list-jobs# всё, что висит в waiting дольше нескольких секунд, — повод разобраться

Oneshot-юниты без таймаута, которые дёргают systemctl. Два шага: найти все oneshot, затем проверить, кто из них вызывает systemctl.

bash

# oneshot-юниты с отключённым таймаутом стартаsystemctl show '*.service' \  -p Id -p Type -p TimeoutStartUSec --value 2>/dev/null \  | paste - - - | awk '$2=="oneshot" && $3=="infinity" {print $1}'# кандидаты: скрипты, которые зовут systemctl без --no-blockgrep -rn "systemctl" /usr/local/bin /etc/systemd/system \  | grep -v -- "--no-block"

Второй однострочник — не статический анализ, а грубый поиск кандидатов для ручной проверки. Он выдаст и комментарии, и systemctl status внутри условий, и совершенно корректные вызовы, не имеющие отношения к зависимостям. Задача — получить короткий список мест, которые стоит посмотреть глазами, а смотреть нужно на одно: входит ли вызывающий юнит в цепочку After=/Before= с тем, кого он дёргает.

Цепочки упорядочивания. Смотреть, кто кого ждёт:

bash

systemctl list-dependencies --after monitoring-snmp-exportersystemctl list-dependencies --before monitoring-snmp-sync

Что реально применилось к юниту (с учётом drop-in’ов, о которых легко забыть):

bash

systemctl cat monitoring-snmp-exporter     # все файлы, включая .d/*.confsystemctl show monitoring-snmp-exporter \  -p After -p Before -p Restart -p RestartUSec \  -p StartLimitIntervalUSec -p StartLimitBurst -p NRestarts

NRestarts тут — недооценённое свойство: показывает, сколько раз служба уже перезапускалась, то есть признак хронической проблемы, о которой никто не знает.

Граф зависимостей целиком, если хочется картинку:

bash

# только ordering-зависимости — ровно то, что нас интересуетsystemd-analyze dot --order 'monitoring-*' > units.dotdot -Tsvg units.dot > units.svg

Без --order граф покажет и требования, и порядок одновременно; цветовую легенду команда печатает сама при запуске, так что угадывать не нужно.

Проверка гипотезы на живой системе. Дедлок воспроизводится за полминуты на любом тестовом хосте — два юнита в десять строк каждый. Рекомендую попробовать: понимание механики после этого совсем другое.

ini

# /etc/systemd/system/deadlock-a.service[Unit]Description=A (oneshot, зовёт B синхронно)[Service]Type=oneshotExecStart=/bin/sh -c 'systemctl restart deadlock-b.service'

ini

# /etc/systemd/system/deadlock-b.service[Unit]Description=B (ждёт A)After=deadlock-a.service[Service]ExecStart=/bin/sleep infinity

bash

systemctl daemon-reloadsystemctl start deadlock-a --no-blocksleep 2 && systemctl list-jobs   # A running, B waiting — навсегда

Убирать за собой лучше по конкретным идентификаторам задач из вывода list-jobs:

bash

systemctl cancel 12345 12346     # номера задач A и B из list-jobssystemctl stop deadlock-a deadlock-b

systemctl cancel без аргументов тоже работает, но, согласно man systemctl(1), «if no job ID is specified, cancel all pending jobs» — то есть снимет вообще все отложенные задачи менеджера. На тестовой машине это безобидно, на рабочей — не то, чего вы хотели.

Выводы

After= работает только когда обе единицы запускаются. Это не «блокировка на активный юнит», а упорядочивание задач в общей транзакции. Отсюда и коварство: конструкция годами работает, а ломается ровно в тот момент, когда вызов приходит изнутри юнита, входящего в ту же цепочку.

Type=oneshot — это отказ от страховки таймаутом. Удобно для миграций, опасно для скриптов, общающихся с менеджером. Если oneshot-юнит вызывает systemctl, задавайте TimeoutStartSec= явно: упасть через минуту с понятной ошибкой лучше, чем висеть две недели в тишине.

try-restart опасно использовать как единственный механизм самолечения. Он по определению работает только с живой службой и «does nothing» с упавшей. Для служб с генерируемым конфигом нужна пара reset-failed + restart.

Самолечение проверяет состояние, а не событие. «Конфиг изменился → перезапусти» лечит один сценарий; «изменился или служба лежит → перезапусти» лечит все.

Rate limit настраивают, а не отключают. Окно подбирается под период, за который система успевает вылечиться сама.

systemctl list-jobs — первая команда при странных зависаниях. Статус юнита в дедлоке выглядит нормально, очередь задач показывает правду сразу.

И главный вывод, который к systemd уже не относится. Самое дорогое в этой истории — не дедлок, а то, что четыре погашенных экспортёра никто не заметил: где-то две недели, где-то три. Метрика «агент присылает данные» была, а метрики «служба, которая должна присылать данные, запущена» — не было. Система наблюдения, не наблюдающая за собственными компонентами, узнаёт о своих поломках от пользователей.

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


Если у вас в парке есть oneshot-юниты, дёргающие другие службы, — systemctl list-jobs и однострочник из раздела «Как найти такое у себя» могут оказаться познавательными. Любопытно, насколько это распространено: напишите в комментариях, если найдёте висяки.

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