Допустим, в вашем продукте есть утренний отчёт. Каждый будний день к началу рабочего дня в почте клиента должно лежать письмо со вчерашними цифрами. Пользователь не знает слова «cron», не видит расписания и не интересуется, на какой машине оно исполняется. У него есть только ожидание: утром письмо уже пришло. Перенос задачи с Vercel на свой сервер нужно мерить именно этим ожиданием, а не тем, что на сервере появилась строка расписания.
Строка расписания, впрочем, переносится за пять минут. Всё остальное, что делало задачу надёжной или хотя бы предсказуемой, на облачной платформе было спрятано в окружении: в какой зоне считается время, как задача вызывается, что происходит при перекрытии запусков, где остаётся след. На VPS всё это придётся назвать и решить самому. Разберём один перенос по шагам, а на каждой развилке будем смотреть, что именно обещано пользователю.
Сначала паспорт задачи: что обещано и кому
Прежде чем открывать редактор unit-файлов, запишите задачу в нескольких строках. Это не бюрократия: каждая строка потом превратится в конкретную настройку или в сознательный отказ от настройки.
- Обещание во времени. «Письмо до 09:00 по Москве» и «запуск в 06:00 UTC» — разные формулировки, и вторая лишь частный способ выполнить первую.
- Допустимая задержка. Если отчёт придёт в 09:40, это неприятность или авария?
- Внешнее действие. Письмо, списание, push, запись в чужую систему. Именно оно страшно дублировать, а не сам запуск скрипта.
- Время работы. Сколько задача идёт обычно и сколько в худший известный день.
- Цена пропуска. Можно ли пропустить одно утро, нужно ли выполнить позже или обязательно повторить.
- Как задача вызывается сейчас. Это HTTP-точка в приложении и какая у неё авторизация.
Последний пункт заслуживает отдельного внимания. По документации Vercel, cron выполняет HTTP GET-запрос к адресу production-деплоя. Значит, ваша «задача» — это на самом деле обычный маршрут приложения, у которого есть собственная защита от посторонних вызовов. При переносе эта защита остаётся важной частью картины: в руководстве по управлению cron-заданиями прямо упоминается, что при миграции нужно учесть авторизацию прежней HTTP-точки, расписание, журналы и незавершённые работы. Это хороший список вопросов для паспорта.
Тут и возникает первая развилка. Можно оставить HTTP-точку и дёргать её локальным запросом с сервера. А можно вынести тело задачи в отдельную команду, которую запускает systemd, и вообще не использовать HTTP. Второй путь чище: вы не зависите от веб-сервера, его таймаутов и от того, что посторонний человек может узнать адрес маршрута. Но он требует, чтобы код задачи запускался вне контекста веб-запроса, а это иногда означает рефакторинг. Для первого переноса честнее взять путь с меньшим числом изменений и записать в паспорт, что вынос в отдельную команду — следующий шаг.
Если вы переносите не только cron, но и само приложение, смотрите заодно разбор переезда Next.js с Vercel на VPS: там про изображения, кеш и функции, то есть про окружение, в котором эта задача будет жить.
Три часа в одном расписании: UTC, пояс сервера и время приложения
Самая тихая ошибка при переносе — сдвиг на несколько часов, который заметен не сразу. Источник у неё простой: в системе одновременно существуют минимум три представления о времени.
- Время расписания. По документации Vercel, cron работает в UTC. Выражение «0 6 * * 1-5» означало 06:00 по Гринвичу, а не по вашему офису.
- Время сервера. У VPS есть свой часовой пояс, который зависит от образа и от того, что вы там настроили. Страна дата-центра сама по себе ничего не решает: пояс хранится в настройках системы.
- Время внутри приложения. Код может считать «вчера» по UTC, по локальному поясу сервера или по поясу клиента. От этого зависит, какой именно отчётный день попадёт в письмо.
В календарных выражениях systemd действует правило, о котором легко забыть: если пояс не указан явно, используется текущий пояс системы, а само выражение может явно задавать UTC или зону из базы IANA. Отсюда вывод для переноса: расписание, которое раньше неявно означало UTC, на новом сервере нужно записать так, чтобы оно не зависело от настроек машины. Либо вы сохраняете прежнюю семантику и пишете UTC явно, либо сознательно переходите на местный пояс и тогда фиксируете это решение в паспорте.
Какой вариант выбрать, зависит от обещания. Если письмо должно прийти «к девяти по Москве», а Москва не переводит часы, разница между вариантами исчезает, остаётся только понимать, что вы выбрали. Если же ваши клиенты живут в поясе с переходом на летнее время, явный UTC будет каждые полгода сдвигать реальное местное время запуска на час. Привязка к зоне IANA сохранит местное время, но тогда в день перехода придётся подумать, что делать с несуществующим или повторяющимся часом. Это не повод пугаться: достаточно решить заранее и записать.
Есть и третий слой, самый коварный. Допустим, задача запускается в 06:00 UTC и собирает данные «за вчера». Если код берёт вчерашнюю дату по часовому поясу сервера, а сервер стоит в другом поясе, чем прежнее окружение, границы отчётного дня сместятся. Письмо придёт вовремя, но в нём окажутся не те сутки. Никаких ошибок в журнале не будет. Поэтому при репетиции переноса полезно сравнить не только факт запуска, но и содержимое результата на одних и тех же входных данных.
Запуск, который дожил до следующего
Теперь представим утро, когда база ответила медленно. Задача, которая обычно идёт четыре минуты, идёт сорок. А расписание у вас, скажем, каждые десять минут, потому что это не отчёт, а, например, синхронизация заказов. Следующее срабатывание наступает, пока предыдущее ещё не закончилось. Что произойдёт?
В Vercel это явно описанная ситуация: задание может работать дольше интервала и пересечься с новым запуском, и защищаться от этого должен сам код. На VPS с systemd поведение иное, и стоит понимать, в чём именно. Таймер активирует связанный service. Согласно документации systemd.timer, если service уже активен, при новом срабатывании он не перезапускается и параллельная копия не создаётся. То есть на уровне одной связки timer и service перекрытие просто не случится: второй запуск не начнётся, пока идёт первый.
Это приятное свойство, но делать из него вывод «блокировки больше не нужны» рано. Во-первых, оно действует только для одной связки. Если кто-то запустит ту же команду вручную из терминала, systemd об этом не узнает и вручную запущенная копия спокойно пойдёт параллельно с плановой. Во-вторых, если приложение сохранило HTTP-вызов и задачу дёргают снаружи, например из другого планировщика, который вы забыли выключить при переезде, никакой service-защиты нет вовсе. В-третьих, в будущем кто-нибудь добавит второй экземпляр приложения, и защита, привязанная к одной машине, перестанет покрывать всё.
Поэтому разумно держать два уровня. Первый — то, что даёт systemd: один активный экземпляр на связку. Второй — блокировка внутри самой задачи, привязанная к смыслу работы, а не к процессу. Например, запись в базе или файл блокировки с понятным владельцем и временем захвата. Главное требование к такой блокировке — она должна освобождаться при аварийном завершении или иметь срок жизни, иначе один упавший запуск заблокирует все следующие, и вы вместо дубля получите тишину. А тишина, как мы увидим дальше, хуже дубля, потому что её труднее заметить.
Тип сервиса и таймаут
Для задачи, которая выполняет работу и завершается, в systemd подходит Type=oneshot: unit описывает команду, у которой есть начало и конец. Два нюанса стоит знать заранее. Для oneshot таймаут запуска TimeoutStartSec по умолчанию отключён, то есть зависшая команда будет висеть неограниченно долго, и именно этот параметр нужен, чтобы ограничить время. А RuntimeMaxSec на oneshot не действует, поэтому привычка «поставить максимальное время работы» здесь не сработает. Кроме того, для обычного периодического oneshot не подходит RemainAfterExit=yes: с ним service после успешного завершения считается активным, и логика «уже активный service не запускается повторно» обернётся тем, что задача перестанет стартовать вообще.
Ограничение времени тоже нужно выбирать осмысленно. Если поставить таймаут короче реальной длительности в худший день, задача будет убиваться посередине, а это опаснее, чем долгий запуск. Разумно взять значение с запасом над худшим известным временем и дополнять его оповещением о длительности, о чём ниже. Конкретные числа зависят от вашей задачи, и подставить их из головы я не могу. Готовые unit-файлы здесь тоже намеренно не приведены: прежде чем выкладывать их в рабочее окружение, их нужно прогнать на тестовой машине, потому что мелочи вроде пользователя, рабочего каталога и переменных окружения отличаются у каждого проекта.
Блокировка и идемпотентность — разные вещи
Здесь чаще всего возникает путаница, и стоит развести два понятия, которые обычно складывают в одно слово «защита от дублей».
Блокировка отвечает на вопрос: «не идут ли сейчас две копии работы одновременно?». Она защищает от одновременности. Идемпотентность отвечает на другой вопрос: «если одно и то же действие выполнится дважды, например потому, что первая попытка упала после отправки, но до записи результата, будет ли побочный эффект один?». Она защищает от повтора во времени.
Разница видна на примере утреннего письма. Задача берёт список получателей, формирует отчёт и отправляет письма. Блокировка гарантирует, что два процесса не отправляют одновременно. Но представьте: процесс отправил письмо, и в этот момент сервер перезагрузился, не успев отметить «отправлено». После старта следующий запуск, не найдя отметки, отправит письмо снова. Блокировка тут ничем не помогла: одновременности не было, был последовательный повтор.
Документация Vercel предупреждает об этой стороне вопроса: одно событие может быть доставлено более одного раза, и рекомендуются блокировки и идемпотентность. Заметьте, что рекомендованы обе меры вместе: они закрывают разные дыры. Перенос на VPS убирает один источник дублей, платформенную доставку, но не убирает остальные: ручной перезапуск, повтор после сбоя, повторный запуск после правки.
Ключ операции вместо случайного идентификатора
Как строится настоящая защита внешнего действия? Хороший пример контракта даёт API платежей Stripe: повтор запроса с тем же ключом возвращает сохранённые статус и тело первого ответа, причём у гарантии есть правила и срок хранения ключа. Я привожу его как образец устройства контракта, а не как обещание, что любой почтовый сервис или любая другая система поведёт себя так же: у вашего получателя действия может не быть такого механизма вообще, и это нужно выяснить по его документации.
Из этого примера важен один вывод, который легко пропустить. Ключ работает только тогда, когда он одинаков у повторных попыток одной и той же бизнес-операции. Если при каждом запуске генерировать новый случайный идентификатор, повтор после сбоя получит другой ключ, и получатель честно сочтёт его новой операцией. Новый идентификатор попытки не делает действие идемпотентным; он лишь помечает попытку.
Значит, ключ нужно выводить из смысла работы. Для утреннего отчёта это, например, сочетание «тип отчёта + отчётная дата + получатель». Любая повторная попытка за тот же день получит тот же ключ. Если же получатель не поддерживает ключи, защиту придётся строить на своей стороне: заводить таблицу «операция — статус» и перед отправкой проверять её. Но тут нужна честность в одном месте. Запись в вашей базе и вызов внешнего сервиса не образуют общей транзакции: между ними всегда есть момент, когда процесс может упасть. Поэтому порядок действий определяет, какой риск вы выбираете.
- Сначала отметка, потом действие. Если процесс упадёт между ними, действие не выполнится, а отметка уже стоит. Риск — пропуск.
- Сначала действие, потом отметка. Если процесс упадёт между ними, действие выполнено, а отметки нет. Риск — дубль.
- Промежуточный статус «начато» с временем. Повторный запуск видит незавершённую запись и решает, что с ней делать: проверить результат у получателя, если это возможно, или передать человеку.
Третий вариант самый трудоёмкий, но единственный, который не прячет проблему. Для неважных уведомлений достаточно первого или второго, главное, чтобы выбор был осознанным. Для денег и для необратимых действий нужен либо идемпотентный контракт у получателя, либо ручная сверка после сбоя.
Одна оговорка про systemd, чтобы не создавать ложной уверенности. Даже безупречно настроенная связка timer и service управляет процессом, но не бизнес-операцией: service описывает управляемый процесс и сам по себе не гарантирует, что внешнее действие совершено ровно один раз. Выходной код процесса говорит, что команда завершилась, но не говорит, что письмо дошло.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
След, по которому видно и присутствие, и отсутствие
Задача без журнала — как лампочка в закрытом шкафу: горит она или нет, выясняется только по последствиям. У журнала для периодической задачи есть особенность, которую не все учитывают. Он должен сообщать не только о том, что запуск был плохим, но и о том, что запуска не было вообще. Обычное логирование ошибок этого не умеет: пока ничего не запустилось, писать ошибку некому.
Поэтому для каждого запуска стоит фиксировать три момента: начало, итог и длительность. Минимальная запись выглядит примерно так (значения условные):
run=morning-report date=2026-11-02 start=06:00:03Z status=ok sent=412 skipped=3 duration_s=187Из такой строки видно главное: какая отчётная дата, чем закончилось, сколько работы сделано и сколько это заняло. Число «пропущено» полезно само по себе: если вчера оно было три, а сегодня триста, это сигнал даже при статусе ok. Длительность нужна для двух целей: заметить, что задача начала подползать к интервалу между запусками, и выбрать осмысленный таймаут.
Теперь про отсутствие. Самый надёжный способ заметить отсутствие запуска — проверять свежесть последнего успешного итога, а не наличие ошибок. Правило звучит так: «если последняя запись со статусом ok старше, чем допустимая задержка из паспорта, поднять тревогу». Такая проверка срабатывает в любом сценарии: сервер был выключен, таймер не включился после переустановки, задача падала молча, блокировка зависла. Причины разные, а симптом один: нет свежего успеха. Именно поэтому проверять нужно симптом.
Куда писать? Для начала достаточно журнала systemd и отдельной записи итога там, где её сможет прочитать проверяющий скрипт: файл, таблица в базе, запись в любой системе мониторинга, которая у вас уже есть. Выбор зависит от инфраструктуры, а не от задачи. Важно, чтобы у проверки был независимый путь: если оповещение живёт в том же процессе, который упал, оно упадёт вместе с ним.
Ещё одна деталь, которую упоминание документации Vercel подсказывает прямо: неудачный вызов автоматически не повторяется. Документация говорит об этом явно, и это полезно помнить при сравнении: поведение «после ошибки ждём следующего срабатывания» в прежнем окружении уже было. На VPS вы вольны сохранить его или добавить повторы, но тогда повторы становятся вашей ответственностью, и вместе с ними возвращается вопрос про ключ операции из предыдущего раздела. Повтор без идемпотентности превращает сбой в дубль.
Сервер остановился: пропустить, отложить или повторить
Самое важное, что нужно осознать до переключения: cron — это не очередь задач. Расписание говорит «в такое-то время запусти», но не хранит список невыполненного и не обязано доставить то, что не удалось. Очередь устроена иначе: у неё есть записи работ, статусы, повторы, возможно, приоритеты. Если вы привыкли, что платформа «как-то сама» разберётся, стоит прямо сказать себе: на VPS это «как-то» нужно спроектировать.
Рассмотрим конкретно: сервер был недоступен с 05:30 до 07:15, а запуск был назначен на 06:00. Что нужно пользователю? Здесь возможны три разумных решения, и они подходят разным задачам.
| Решение | Когда подходит | Что нужно для этого |
|---|---|---|
| Пропустить | Работа теряет смысл после своего времени: например, сводка «на сейчас», которую заменит следующая | Только проверка, что пропуск замечен и зафиксирован |
| Отложить: выполнить один раз после возвращения | Утренний отчёт, который полезен и в 07:30, но не нужен дважды | Механизм запуска после простоя и ключ операции по отчётной дате |
| Повторить каждое пропущенное | Редкие случаи, когда каждое срабатывание несёт отдельную работу, например закрытие периода | Явный список работ с состояниями, то есть уже очередь, а не расписание |
Для варианта «отложить» в systemd есть параметр Persistent, и важно понимать его границы. Persistent применим к календарному расписанию (OnCalendar) и по умолчанию выключен. Если включить, systemd запоминает время последнего запуска, и когда таймер снова активируется, он вызывает сервис, если за время неактивности был хотя бы один пропущенный запуск. Слова «хотя бы один» ключевые: механизм не воспроизводит все пропущенные события. Если сервер простоял трое суток, вы получите один догоняющий запуск, а не три. Для утреннего отчёта это, как правило, именно то, что нужно, но для задачи, где каждый день требует отдельной обработки, это обернётся тихой потерей двух дней.
Дальше нужно учесть, что в догоняющем запуске нет информации о том, какой именно запуск он замещает. Он просто стартует позже. Поэтому приложение должно само определять отчётную дату по данным, а не по времени старта. Если код берёт «вчера» от текущего момента, то при запуске в 07:15 и в 06:00 результат совпадёт, а вот при догоне после ночи простоя с переходом через полночь по поясу приложения он может сформировать другие сутки. Лучше хранить «последнюю успешно обработанную дату» и обрабатывать всё до нужной границы: тогда догоняющий запуск сам поймёт, чего не хватает.
Есть и побочный эффект, о котором стоит знать: при включённом Persistent сервис может стартовать сразу после загрузки системы, когда другие компоненты, например база или сетевые мосты, ещё поднимаются. Параметр RandomizedDelaySec добавляет задержку и тем самым смягчает эту проблему, а заодно разносит запуски во времени, но он же сдвигает момент старта, и это нужно сопоставить с допустимой задержкой из паспорта. Сочетание «догнать после простоя» и «письмо до 09:00» требует арифметики: если задержка плюс время работы выходит за срок, обещание нарушается, даже когда всё технически исправно.
Если же ваш случай — третья строка таблицы, ответ другой: расписание тут не поможет, нужен список работ. Например, таблица в базе, где для каждого отчётного периода есть запись со статусом, а плановый запуск просто берёт все необработанные записи по порядку. Тогда таймер превращается в обычный «будильник», который будит обработчик очереди, а источником истины становится таблица. Это и есть граница между расписанием и очередью: расписание отвечает на «когда», очередь на «что именно осталось».
Репетиция, переключение и возврат без двойной работы
Самая опасная минута переноса — когда задача одновременно живёт в двух местах. Если вы включили таймер на сервере, но не выключили cron в прежнем окружении, пользователь получит два письма. Если выключили старый до проверки нового, рискуете получить ни одного. Поэтому переключение лучше провести как последовательность коротких проверяемых шагов, а не как «в пятницу включим».
- Репетиция без внешнего эффекта. Запустите задачу на сервере вручную в режиме, который не отправляет письма: в тестовый ящик, в журнал или с отключённой отправкой. Сравните результат с реальным результатом старой задачи на тех же данных. Особое внимание — границам отчётных суток и числам вроде «отправлено» и «пропущено».
- Проверка расписания без ожидания. Прежде чем доверять календарному выражению, посмотрите, когда systemd вычисляет ближайшие срабатывания, и сверьте с паспортом: правильное ли время и правильный ли пояс. Это дешевле, чем узнать ошибку утром.
- Явная передача. Выключите старое расписание и включите новое в один заранее выбранный момент, лучше не рядом с самим сроком запуска, а в середине интервала между двумя запусками. Тогда у вас есть время убедиться, что старый больше не вызывает точку, а новый стоит в плане.
- Закрытие старой точки. Если прежний HTTP-маршрут остаётся доступным, убедитесь, что без правильной авторизации его нельзя вызвать, а лучше уберите его, когда он больше не нужен. Забытая точка — это приглашение к случайному повторному запуску.
- Первое боевое утро под наблюдением. Проверьте не сам факт запуска, а то, что получил пользователь, и запись итога в журнале.
Отдельно подумайте о незавершённых работах: если в момент переключения старая задача была запущена и не закончилась, её результат нужно дождаться или учесть. Иначе новая версия, не видя отметки о выполнении, начнёт ту же работу заново. Здесь снова выручает ключ операции по отчётной дате: он делает такое пересечение безопасным.
Возврат назад должен быть продуман не менее тщательно, чем переключение. Если после первого утра что-то идёт не так, вернуть старое расписание проще всего тогда, когда вы не удалили его, а лишь отключили. Но важно помнить: возврат тоже создаёт пересечение, а значит, тот же ключ операции и та же проверка «уже сделано» должны работать в обе стороны. Если защита от дубля есть только в новом окружении, возврат на старое породит именно то, чего вы избегали.
Что даёт для этого VPS
Для такой задачи на сервере не нужны большие ресурсы. Расписание само по себе почти ничего не потребляет, а нагрузку создаёт тело задачи: выборка данных, формирование отчёта, отправка. Поэтому начинать стоит с оценки именно этой работы, а не с выбора мощной конфигурации. У VPS MAATRIX есть линейка от Ba с 1 vCPU, 2 ГБ RAM и 40 ГБ NVMe до более крупных конфигураций, а полный доступ root позволяет самому настроить systemd, пояс сервера и журнал. Эти параметры описывают ресурсы, а не обещание ёмкости: подойдёт ли конкретный тариф для вашей задачи, покажет замер длительности и потребления памяти при репетиции. Если на том же сервере живёт и приложение, учитывайте, что утренний запуск конкурирует с ним за процессор и память, и сверяйте это с записанной длительностью.
Среди локаций в публичном предложении перечислены UK, US, RU и FR. Для запуска по расписанию выбор локации обычно меньше влияет на задачу, чем настройка пояса, но если ваша задача обращается к внешнему API, проверьте по документации самого API, как он ведёт себя с запросами из выбранной страны: доступность стороннего сервиса с конкретного адреса я бы не считал само собой разумеющейся. Автоматические резервные копии в описании тарифов не заявлены, поэтому если у задачи есть собственное состояние, например таблица статусов операций, позаботьтесь о его сохранности отдельно.
Утро, ради которого всё затевалось
Вернёмся к клиенту, которому нужен отчёт к девяти. Он не увидит ни таймера, ни блокировки, ни ключа операции. Он увидит письмо, в котором верные сутки и верные цифры, одно письмо вместо двух и вовремя. Если сервер ночью перезагружался, отчёт всё равно должен прийти, пусть чуть позже, но один раз. Если же что-то пошло не так, вы узнаете об этом до того, как напишет клиент, потому что свежего успешного итога нет в журнале.
Поэтому правильный критерий готовности переноса звучит не так, как привычно. Не «таймер включён и показывает следующий запуск», а «в понедельник утром, после выходных, в ящике лежит одно письмо за нужную дату, а в журнале есть строка, из которой видно, сколько работы сделано и за сколько». Строка расписания — лишь способ добиться этого письма.