Systemd-таймер жил по UTC, и отчёты уезжали на три часа
Ежедневный отчёт о продажах вдруг стал приходить не в девять утра, как обычно, а в шесть. Никто не трогал расписание, никто не менял код генератора — а время отправки просто съехало на три часа и держалось там стабильно, день за днём. Разбираем, как переезд сервиса на новую машину в UK-локации превратил безобидный systemd-таймер в источник путаницы для всей команды продаж, и почему дело было не в коде, а в одной строке конфигурации, которую никто не удосужился перепроверить.
Содержание
- Что сломалось
- Первые следы: что показали journalctl и systemctl list-timers
- Гипотеза 1: разъехались часы из-за проблем с NTP
- Гипотеза 2: ошибка в синтаксисе OnCalendar
- Реальная причина: пояс хоста и пояс, в котором думали авторы юнита, разошлись
- Как чинили: явная таймзона на хосте и в юните
- Что изменили после инцидента
Что сломалось
Сервис reportgen генерирует и рассылает ежедневный отчёт о продажах для менеджеров. Раньше он крутился на старой VPS, настроенной под часовой пояс Europe/Moscow, и запускался по systemd-таймеру каждое утро в 06:00 — то есть отчёт улетал в 09:00 по Москве, когда первые сотрудники уже садились за рабочие места.
После того как инфраструктуру частично перенесли на новый сервер в UK-локации (расширяли географию и заодно разгружали старую машину), отчёт стал приходить в 06:00 по Москве вместо 09:00. Для получателей это выглядело как сбой: часть данных за предыдущий день ещё не успевала долиться из смежной системы биллинга, которая финализирует цифры к 07:00 МСК, — и отчёт улетал с неполными цифрами. Дальше начались вопросы в духе «а можно доверять этим отчётам вообще», что для DevOps даже неприятнее, чем сам факт сдвига по времени.
Важная деталь для дальнейшего разбора: сдвиг был ровно на три часа — не на час, не на произвольное число минут из-за нагрузки, а именно на три, что для человека, работающего с московским временем, сразу выглядит подозрительно похоже на разницу между UTC и MSK (UTC+3, круглый год, потому что в России отменили переходы на летнее время ещё в 2014 году — сезонных сдвигов можно не опасаться).
Первые следы: что показали journalctl и systemctl list-timers
Первым делом проверили, вообще ли таймер сработал по расписанию или произошла какая-то задержка запуска:
systemctl list-timers --all | grep reportgen
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-08-25 06:00:00 UTC 14h left Mon 2026-08-24 06:00:00 UTC 9h ago reportgen.timer reportgen.service
Уже здесь видна первая зацепка — колонка времени явно подписана UTC, хотя на старом сервере в этом месте всегда стояло MSK. Дальше подняли журнал самого сервиса:
journalctl -u reportgen.service --since "2026-08-20" --until "2026-08-25" -o short-iso
Логи показали, что сервис действительно стартовал ровно в 06:00 каждый день — без опозданий, без ретраев, без ошибок выполнения. Само приложение отработало штатно: собрало данные, сформировало PDF, отправило письмо. Значит, дело не в самом скрипте генерации отчёта и не в очереди отправки почты — таймер запускал сервис вовремя относительно того времени, которое он считал текущим. Вопрос был в том, что он считал текущим временем.
Проверили состояние времени на хосте:
timedatectl status
Local time: Mon 2026-08-24 09:14:22 UTC
Universal time: Mon 2026-08-24 09:14:22 UTC
RTC time: Mon 2026-08-24 09:14:20
Time zone: Etc/UTC (UTC, +0000)
System clock synchronized: yes
NTP service: active
RTC in local TZ: no
Строка Time zone: Etc/UTC — это и есть корень проблемы, но на этом этапе разбора мы её пока не подтвердили как причину, а зафиксировали как факт и продолжили проверять остальные версии, чтобы не хвататься за первое правдоподобное объяснение.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотеза 1: разъехались часы из-за проблем с NTP
Первая рабочая версия — дрейф системных часов. Если хост не синхронизируется с NTP, время может «уехать» на произвольную величину, и три часа — не самое безумное отклонение для сервера, который долго простоял без синка.
Проверили состояние синхронизации:
timedatectl timesync-status
chronyc tracking
Оба вывода показали исправную синхронизацию: System clock synchronized: yes, смещение (Last offset) — доли миллисекунды, Leap status: Normal. NTP работал штатно, время на сервере было точным — просто это было точное время в UTC, а не в MSK. Гипотезу отбросили: часы не «плыли», они были абсолютно корректны, только считались в другом поясе, чем ожидала команда.
Гипотеза 2: ошибка в синтаксисе OnCalendar
Вторая версия — где-то в юните таймера опечатка, из-за которой OnCalendar резолвится не в то время, что задумано. Проверили сам юнит:
cat /etc/systemd/system/reportgen.timer
[Unit]
Description=Daily sales report timer
[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target
Синтаксически всё верно — 06:00:00 каждый день, Persistent=true на случай, если сервер был выключен в момент срабатывания. Проверили, как systemd фактически интерпретирует это выражение:
systemd-analyze calendar "*-*-* 06:00:00"
Original form: *-*-* 06:00:00
Normalized form: *-*-* 06:00:00
Next elapse: Tue 2026-08-25 06:00:00 UTC
(in UTC): Tue 2026-08-25 06:00:00 UTC
(in MSK): Tue 2026-08-25 09:00:00 MSK
И вот тут стало окончательно ясно: строка OnCalendar=*-*-* 06:00:00 без явного указания часового пояса не абстрактна — systemd интерпретирует такое время в локальной таймзоне хоста, а не в UTC и не в той зоне, в которой писался юнит. На старом сервере локальной зоной была Europe/Moscow, поэтому 06:00:00 там и означало 06:00 по Москве. Юнит-файл при переносе скопировали один в один — и он остался синтаксически корректным, просто на новом хосте те же цифры стали означать другое время. Ошибки в синтаксисе не было; была неявная зависимость от окружения, которую никто не зафиксировал явно.
Реальная причина: пояс хоста и пояс, в котором думали авторы юнита, разошлись
Итоговая причина — комбинация двух фактов:
- Новый VPS в UK-локации был поднят с таймзоной по умолчанию
Etc/UTC. Большинство образов облачных дистрибутивов (включая тот, что использовали при разворачивании) по умолчанию ставят системный пояс в UTC — это осознанное решение производителей образов: единообразие между регионами, предсказуемость логов, отсутствие путаницы с переходами на летнее время в тех странах, где они ещё есть. Никто не выполнилtimedatectl set-timezone Europe/Moscowпри вводе сервера в эксплуатацию — это просто выпало из чек-листа переезда, потому что раньше сервер поднимали с уже готового образа, где пояс был настроен заранее. - Юнит
reportgen.timerбыл написан без явного указания зоны вOnCalendar. Это стандартная и в целом нормальная практика — большинство таймеров пишут без явного пояса, полагаясь на то, что системная зона хоста верна. Но это делает таймер хрупким к переносу между машинами: время в юните «значит» то, что значит текущая зона хоста, а не то, что подразумевал автор при написании файла полгода назад.
По отдельности ни один из этих двух фактов не был багом. Образ с UTC по умолчанию — это нормально и предсказуемо. Таймер без явной зоны — тоже валидная и распространённая конструкция. Но вместе, при переезде сервиса на новый хост без ревизии таймзоны, они дали ровно тот сдвиг, который наблюдали: 3 часа, потому что разница между UTC и MSK — это ровно 3 часа, без сезонных колебаний.
Отдельно стоит отметить, почему сдвиг был именно «тихим»: сервис не падал, не писал ошибок, не ретраился — он честно выполнял свою работу по расписанию, которое сам считал верным. Мониторинг доступности сервиса (uptime, коды ответа, факт отправки письма) в такой ситуации ничего не покажет — отчёт действительно отправляется, просто не в то время. Это тот случай, когда алерт должен ловить не «сервис не сработал», а «сервис сработал не тогда, когда должен был» — а такую проверку почти никто не настраивает по умолчанию.
Как чинили: явная таймзона на хосте и в юните
Исправление сделали в две части, обе — намеренно избыточные, чтобы не зависеть от одной точки отказа:
Первое — выставили корректный часовой пояс на хосте:
timedatectl set-timezone Europe/Moscow
timedatectl status
Это меняет /etc/localtime на симлинк, указывающий на нужную зону, и все системные вызовы, читающие локальное время (включая systemd-таймеры без явной зоны), сразу начинают резолвиться корректно.
Второе — переписали юнит с явной таймзоной внутри OnCalendar, чтобы расписание не зависело от того, как настроен конкретный хост:
[Unit]
Description=Daily sales report timer
[Timer]
OnCalendar=*-*-* 06:00:00 Europe/Moscow
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target
Синтаксис OnCalendar=... <TZ> поддерживается systemd начиная с версии 239 — можно явно указать зону последним токеном выражения, и таймер будет резолвить время в этой зоне независимо от системной настройки хоста. Проверили новое выражение той же командой, что и раньше:
systemd-analyze calendar "*-*-* 06:00:00 Europe/Moscow"
Original form: *-*-* 06:00:00 Europe/Moscow
Normalized form: *-*-* 06:00:00 Europe/Moscow
Next elapse: Tue 2026-08-25 06:00:00 MSK
(in UTC): Tue 2026-08-25 03:00:00 UTC
После применения обеих правок и systemctl daemon-reload с перезапуском таймера, отчёт снова стал уходить в 09:00 по Москве — и теперь это гарантировано не зависит от того, в каком поясе настроен конкретный хост, на который сервис переедет в следующий раз. Если у вас есть похожие задачи и хочется быстро настроить cron-задачи на VPS или разобраться с часовыми поясами на сервере с нуля, эти материалы закрывают базовую настройку — но, как видно из этого разбора, база не спасает, если про неё забывают при переезде.
Что изменили после инцидента
Технический фикс закрыл конкретный случай, но не защищал от повторения на других сервисах — а таймеров и cron-задач в инфраструктуре хватало. Поэтому сделали три вещи на уровне процесса:
- Ревизия всех таймеров и cron-задач на предмет неявной таймзоны. Прошлись по всем
*.timer-юнитам и записям вcrontabна всех серверах, выписали те, где расписание критично для бизнес-логики (отчёты, биллинг, бэкапы с ожиданием окна обслуживания). У таймеров с явным требованием к местному времени добавили явную зону вOnCalendar. - Чек-лист переезда/провижининга сервера. В процедуру ввода нового VPS в эксплуатацию добавили обязательный шаг: сверить
timedatectl statusс ожидаемым поясом до того, как на сервер переезжает хоть один сервис с расписанием. Формально это была и раньше «очевидная» вещь, но без пункта в чек-листе она стабильно выпадала — как выпала в этот раз при работе с новым UK-хостом. - Мониторинг факта отправки, а не только факта запуска. Добавили простую проверку через healthchecks-подобный сервис:
reportgenпри успешной отправке пингует контрольный URL, и если пинг приходит не в ожидаемое окно (±15 минут от 09:00 МСК), приходит алерт — независимо от того, упал сервис или просто сработал не в то время. Такая проверка ловит именно класс проблем «сработало, но не тогда», который обычный uptime-мониторинг не видит в принципе. Если тема мониторинга cron/таймеров вам близка, у нас есть отдельный разбор похожего случая — cron не отработал ночью, там другая причина, но тот же класс симптомов «молчаливого» сбоя расписания.
Отдельно обсуждали, не стоит ли просто держать все серверы в UTC и переводить в локальное время только на уровне приложения — это более «правильный» с инженерной точки зрения подход, но решили не делать масштабную миграцию ради одного инцидента: дешевле и безопаснее сделать таймзону хоста и таймзону в расписании согласованными и явными, чем переписывать логику отображения времени во всех сервисах, которые уже привыкли работать в MSK.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему systemd-таймер вообще ориентируется на локальное время, а не на UTC?
Потому что для большинства задач (бэкапы ночью, отчёты к началу рабочего дня, ротация логов в тихие часы) важно именно локальное время конкретного региона, а не абстрактный UTC. Разработчики systemd сделали локальную зону поведением по умолчанию, а UTC — опцией, которую нужно указать явно через суффикс в OnCalendar или отдельно через OnCalendar=... UTC.
Как узнать, в каком поясе сработает конкретный таймер, не дожидаясь его запуска?
Командой systemd-analyze calendar "<выражение>" — она покажет ближайшее время срабатывания и в локальной зоне, и в UTC, что удобно именно для таких проверок при переезде между серверами.
А cron подвержён той же проблеме?
Да, ровно той же — классический cron тоже читает расписание в системной локальной таймзоне и не умеет указывать зону прямо в строке расписания (в отличие от systemd-таймеров). Если переносите crontab-задачи между серверами с разными поясами, сдвиг будет точно таким же по механике, просто без возможности зашить зону в саму задачу — придётся синхронизировать timedatectl на хосте или явно оборачивать команду в TZ=Europe/Moscow date ... внутри скрипта.
Почему в логах journalctl не было вообще никаких признаков проблемы?
Потому что с точки зрения сервиса и systemd ничего не сломалось: таймер запустился штатно, сервис штатно отработал, ошибок выполнения не было. Проблема была не в выполнении, а в интерпретации расписания — такие вещи логи выполнения принципиально не покажут, их видно только сравнением ожидаемого и фактического времени срабатывания.
Стоит ли на всякий случай указывать зону во всех таймерах, даже если сейчас всё работает?
Да, это дешёвая страховка. Явная зона в OnCalendar=... Europe/Moscow (или любой другой нужной) ничего не стоит с точки зрения производительности, зато делает таймер переносимым между хостами с разными системными поясами — а серверы, как показывает этот случай, переезжают чаще, чем кажется на этапе первоначальной настройки.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →