MAATRIX / Блог / @reboot в crontab поднял 30 копий сервиса после перезагрузки

@reboot в crontab поднял 30 копий сервиса после перезагрузки

MAATRIX

Сервис держали на связке cron и @reboot — просто и без лишней инфраструктуры. Полгода это работало без единой перезагрузки, пока плановое обновление ядра не вскрыло проблему одним махом: сервис поднялся не в одном экземпляре, а в тридцати. Ниже — как мы это увидели, какие версии отбросили и почему виноват был не cron сам по себе, а деплой-скрипт, который тихо копил дубли строк месяцами.

Что мы увидели после перезагрузки

Плановое окно на обновление ядра закрыли ночью, сервер ушёл в reboot по расписанию хостинг-провайдера. Через пару минут после подъёма начал звонить алертинг: сервис то отвечал, то отваливался, память росла рывками, а в очереди задач стали появляться дублирующиеся записи — одно и то же событие обрабатывалось два, три, а иногда и больше раз подряд.

Первая же команда всё расставила по масштабу:

ps aux | grep worker.py | grep -v grep | wc -l
30

Тридцать процессов одного и того же воркера с разными PID, все стартовали в пределах одной минуты после reboot:

ps -eo pid,ppid,lstart,cmd | grep worker.py
  1841     1 Fri Aug 28 03:14:02 2026 python3 /opt/app/worker.py
  1842     1 Fri Aug 28 03:14:02 2026 python3 /opt/app/worker.py
  1843     1 Fri Aug 28 03:14:02 2026 python3 /opt/app/worker.py
  ...

PPID у всех — 1, то есть процессы не были форкнуты друг от друга и не образовывали дерево: каждый запущен независимо, напрямую от init. Это сразу сузило круг подозреваемых — искать баг самовоспроизведения внутри приложения было бессмысленно.

Первые гипотезы — и почему они не подтвердились

Разбор инцидента строили как обычно: выписали все правдоподобные причины и последовательно закрывали их фактами, а не догадками.

  • Fork-бомба внутри приложения. В коде нет os.fork(), нет multiprocessing.Process без ограничения на число воркеров, нет рекурсивного вызова самого себя. Отбросили за 10 минут по grep по исходникам.
  • systemd с Restart=always в цикле рестартов. Первая мысль — раз процессов много, значит юнит перезапускается быстрее, чем падает. Проверили: systemctl status worker.service — юнита с таким именем не существует вовсе, сервис не был заведён под systemd. Мимо. Если вам интересно, как systemd вообще стартует сервисы при загрузке и в каком порядке — это разобрано в статье про порядок запуска systemd при загрузке.
  • Зомби-процессы, не пожранные родителем. Смотрели на STAT в ps aux — везде S или R, ни одного Z. Плюс PPID=1 у всех, зомби бы висели с ppid умершего родителя, а не с ppid init. Мимо.
  • PID 1 не пересылает сигналы, и старые процессы не гасятся при рестарте контейнера. Мы не в контейнере, это голый VPS без Docker, так что сама механика не подходила — но проверить стоило, потому что похожий сценарий с сигналами и PID 1 у нас уже был описан в разборе как PID 1 не пересылал сигналы и ронял деплой. Здесь не тот случай, но полезно было исключить его явно, а не на глаз.
  • Второй сервер параллельно поднял такие же процессы по сети (перепутали IP в балансировщике). Проверили hostname, ip a, время старта каждого процесса — все 30 запущены локально, на этой же машине, в одну секунду. Мимо.

К этому моменту стало ясно: дело не в рантайме и не в оркестрации, а в том, что именно запускало процессы при старте системы.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Что показали логи и crontab

journalctl -u cron быстро показал, что за первую минуту после загрузки cron-демон выполнил не одну задачу с меткой @reboot, а тридцать — с одинаковой командой:

journalctl -u cron --since "03:14:00" --until "03:15:00" | grep CMD | wc -l
30

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

crontab -l -u appuser | grep worker.py | wc -l
30

И действительно — тридцать одинаковых строк подряд:

@reboot /opt/app/start_worker.sh
@reboot /opt/app/start_worker.sh
@reboot /opt/app/start_worker.sh
... (ещё 27 раз)

Cron не умеет и не пытается дедуплицировать задачи — это просто список строк, который построчно выполняется планировщиком при наступлении события. Тридцать одинаковых строк — тридцать независимых запусков. При этом за все предыдущие месяцы сервер ни разу не перезагружали: сервис держался в памяти с момента первого деплоя, поэтому накопление дублей в crontab было абсолютно незаметно снаружи — всё работало штатно, в одном экземпляре, просто потому что @reboot не срабатывал.

Как @reboot дублируется: механика проблемы

Причина нашлась в деплой-скрипте, который гонялся по cron каждую ночь и разворачивал последнюю версию кода. В числе прочего он должен был гарантировать, что сервис поднимется автоматически после перезагрузки — для этого при каждом деплое он дописывал строку в crontab:

# фрагмент старого deploy.sh — проблемная часть
(crontab -l -u appuser 2>/dev/null; echo "@reboot /opt/app/start_worker.sh") | crontab -u appuser -

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

Отдельно стоит сказать, почему это не поймали раньше на code review: строка (crontab -l; echo ...) | crontab - — стандартный паттерн из десятков туториалов в интернете, выглядит безобидно и в единичном прогоне действительно безобидна. Проблема проявляется только при повторных запусках одного и того же скрипта, а линтеры и статический анализ такие вещи не ловят — это чисто эксплуатационная грабля, которая молчит, пока не случится reboot.

Похожая логика ошибок разбирается в статье про типичные ошибки в cron-задачах на сервере — там как раз про то, что cron прощает синтаксические ошибки, но не прощает неаккуратное управление самим списком задач.

Что сделали, чтобы почистить и не повторить

Сразу после диагностики — экстренная чистка. Погасили тридцать копий:

pkill -f /opt/app/worker.py

и убедились, что осталась ровно одна строка в crontab:

crontab -l -u appuser | grep -v worker.py > /tmp/clean_cron
echo "@reboot /opt/app/start_worker.sh" >> /tmp/clean_cron
crontab -u appuser /tmp/clean_cron

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

После разбора причины поменяли две вещи:

  1. Деплой-скрипт стал идемпотентным. Строка добавляется только если её ещё нет:
CRON_LINE="@reboot /opt/app/start_worker.sh"
crontab -l -u appuser 2>/dev/null | grep -qF "$CRON_LINE" || \
  (crontab -l -u appuser 2>/dev/null; echo "$CRON_LINE") | crontab -u appuser -
  1. Сам стартовый скрипт получил защиту от повторного запуска — на случай, если в crontab всё же случайно окажется дубль или кто-то запустит его руками поверх уже работающего процесса:
#!/bin/bash
exec 9>/var/run/worker.lock
flock -n 9 || { echo "уже запущен, выходим"; exit 1; }
exec python3 /opt/app/worker.py

flock -n берёт эксклюзивную блокировку на файл и мгновенно завершает второй запуск, если блокировка уже занята первым. Это дешёвая и надёжная страховка независимо от того, что именно инициировало повторный старт — cron, ручной запуск или ошибка в скрипте провижининга.

Заодно пересмотрели, нужен ли вообще @reboot в чистом виде для продакшен-сервиса, или лучше отдать автозапуск и супервизию systemd, у которого из коробки есть Restart=on-failure, единый лог через journald и явная гарантия «этот юнит существует в системе в одном описании», а не «столько раз, сколько его туда дописали».

Критерий@reboot в crontabsystemd unit
Дедупликация при повторной настройкеНет, ответственность на скриптеДа, systemctl enable идемпотентен
Автоперезапуск при паденииНет из коробкиRestart=on-failure
Единый логНужно настраивать отдельноjournalctl -u service из коробки
Защита от параллельного запускаНужен flock вручнуюШтатное поведение unit-файла
Порог входаНиже, одна строкаЧуть выше, нужен unit-файл

Как защититься на своём VPS: чеклист

Если сервисы на вашем сервере запускаются через cron, @reboot, rc.local или любые самописные скрипты провижининга — стоит проверить несколько вещей до того, как это выяснится на реальном перезапуске:

  • Проверьте crontab на дубли прямо сейчас: crontab -l | sort | uniq -c | sort -rn — если у какой-то строки счётчик больше единицы, у вас та же грабля, что и у нас, просто пока без триггера.
  • Любой скрипт, который дописывает что-либо в crontab, cron.d или systemd-юниты автоматически (деплой, Ansible, Terraform provisioner), должен сначала проверять текущее состояние, а не слепо добавлять строку.
  • На критичные сервисы поставьте flock в стартовый скрипт — это одна строка, а не отдельный проект, зато закрывает целый класс проблем с двойным запуском.
  • Если сервер редко перезагружается, это не повод не тестировать @reboot-путь: раз в квартал можно намеренно перезагрузить staging-копию и посмотреть, что реально поднимется.
  • Настройте алерт на количество процессов с заданным именем (pgrep -c в cron-проверке или в системе мониторинга) — это дешёвая защита, которая поймает и эту проблему, и зависшие процессы, о которых можно почитать в статье про поиск скрытых и лишних процессов на сервере.
  • Держите под рукой быстрый способ снести все копии одной командой (pkill -f имя_процесса) — во время инцидента не время придумывать regexp.

Если вы арендуете VPS именно под такие сервисы — воркеры, очереди, фоновые задачи — закладывайте с самого начала супервизию через systemd, а не только через cron: это экономит именно те несколько часов, которые мы потратили на разбор этого случая.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему cron не проверяет дубли строк сам?

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

Можно ли было поймать проблему раньше, не дожидаясь перезагрузки?

Да — регулярная проверка crontab -l | sort | uniq -c в рамках планового аудита конфигурации или в CI деплой-пайплайна поймала бы дубли на второй-третий день, а не через месяц.

Стоит ли вообще отказываться от @reboot в пользу systemd?

Не обязательно для маленьких вспомогательных скриптов, но для сервисов, от которых зависит продакшен-нагрузка, systemd даёт больше гарантий из коробки — рестарт при падении, единый лог, явное состояние enable/disable — и стоит того, чтобы завести unit-файл вместо одной строки в cron.

Почему flock в стартовом скрипте не мешает нормальному перезапуску сервиса?

Блокировка держится, пока жив процесс, который её взял. Как только сервис завершается (штатно или аварийно), файловый дескриптор закрывается, flock освобождается, и следующий запуск проходит без проблем — блокирует именно параллельный запуск, а не последовательный.

Что делать, если дубли уже успели наделать побочных эффектов (например, задвоили обработку задач)?

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

Обсудить статью, задать вопрос или начать новую тему

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

Перейти в сообщество →