Содержание урока · 8 разделов
История, которую переживает почти каждый: разработчик пишет задачу 0 9 на ноутбуке в Москве, проверяет — отчёт приходит в девять, всё отлично. Сервис уезжает в контейнер, а контейнер живёт в UTC. Девять утра по-прежнему наступают, но по-гринвичски — в полдень по Москве. Пользователи получают утренний отчёт в обед, напоминания уходят ночью, и в логах нет ни одной ошибки: планировщик работает ровно так, как сказал. Часовой пояс cron-планировщика не настраивается сам и не берётся из данных задачи — он берётся из процесса. Поэтому зону стоит решать явно, на этапе создания планировщика или в выражении конкретной задачи, а не полагаться на окружение сервера. Формулируя задачу, полезно отвечать на вопрос «в чьём времени это сработает?» так же, как отвечаете на вопрос «что это делает?»: без ответа на первый выражение вида 0 9 — это не расписание, а загадка, разгадывать которую будут по логам после первого инцидента. Разберём три уровня решения: зона всего планировщика, зона отдельной задачи и зоны отдельных пользователей.
Cron timezone: Location планировщика и time.Local#
По умолчанию robfig/cron интерпретирует все выражения в time.Local — зоне процесса Go. Откуда она берётся: из переменной окружения TZ, а если та не задана, из системного файла /etc/localtime. На ноутбуке это ваша географическая зона, в контейнере почти наверняка UTC, на старом сервере — что угодно. Из этого следует, что cron timezone без явного указания — это зона сервера, а не зона бизнеса. Явное решение — конструктор: cron.New(cron.WithLocation(loc)), где loc — значение *time.Location, полученное через time.LoadLocation по имени из базы IANA. Одна строка, и весь планировщик говорит на одном языке: выражения парсятся в этой зоне, тики вычисляются в ней же. Если сервис весь живёт в одном часовом поясе — например, внутренние отчёты компании в Берлине — этого достаточно, и дальше можно не читать. Остальным — про задачи в чужих зонах.
Планировщик в московской зоне
GOfunc main() {
loc, err := time.LoadLocation("Europe/Moscow")
if err != nil {
log.Fatalf("зона: %v", err)
}
c := cron.New(cron.WithLocation(loc))
_, err = c.AddFunc("0 9 * * *", sendMorningDigest)
if err != nil {
log.Fatal(err)
}
c.Start()
select {}
}time.LoadLocation возвращает time.Location по имени из базы IANA; ошибка обрабатывается на старте — без зоны планирование бессмысленно. cron.WithLocation делает эту зону зоной всего планировщика: задача 0 9 сработает в девять по Москве независимо от того, в какой зоне живёт процесс. Напоминание из второго урока: модуль и зависимости подключаются через go get, запускается всё локальным go run.
Проверка: зона по умолчанию
Что использует cron.New() без опций при интерпретации выражения 0 9 * * *?
CRON_TZ: зона отдельной задачи#
Когда в одном сервисе живут задачи в разных зонах — отчёт по берлинскому времени, синхронизация по нью-йоркскому рынку, напоминания по местному времени пользователей — переносить весь планировщик в одну зону недостаточно. Для этого в v3 есть префикс CRON_TZ прямо в выражении: строка вида CRON_TZ=Asia/Tokyo 30 4 * означает «в 04:30 токийского времени», и работает независимо от зоны самого планировщика. Префикс понимает штатный парсер библиотеки — это не строковая обработка на вашей стороне, и ошибка в имени зоны вернётся из AddFunc сразу на старте, а не в первый тик. Правило приоритета простое: зона из CRON_TZ конкретной задачи сильнее зоны планировщика, а зона планировщика сильнее time.Local. Есть и старый префикс TZ= — он поддерживается для совместимости, но документация рекомендует CRON_TZ, и новый код лучше писать по ней.
Две задачи в разных зонах одного планировщика
GOc := cron.New() // зона процесса, пусть даже UTC
// Дайджест в 08:30 по Нью-Йорку
_, _ = c.AddFunc("CRON_TZ=America/New_York 30 8 * * *", sendUSDigest)
// Очистка кэша каждый час по зоне планировщика
_, _ = c.AddFunc("0 * * * *", pruneCache)
c.Start()Планировщик создан без WithLocation и живёт в зоне процесса. Первая задача жёстко привязана к нью-йоркскому времени через CRON_TZ и не зависит от переездов сервера; вторая продолжает считаться в зоне планировщика. Такой микс встречается часто: внешние события привязаны к чужим зонам, внутренняя уборка — к зоне сервиса. Ошибку парсинга префикса AddFunc вернёт сразу — проверяйте её на старте.
Проверка: задача в чужой зоне
Выражение CRON_TZ=Asia/Yekaterinburg 15 10 * * * при планировщике, живущем в UTC. Когда задача сработает по UTC?
Когда пойдёт печать по UTC-серверу?
Прочитайте код и выберите, что он выведет:
loc, err := time.LoadLocation("Europe/Moscow") // UTC+3
if err != nil {
log.Fatal(err)
}
c := cron.New(cron.WithLocation(loc))
_, _ = c.AddFunc("0 9 * * *", func() {
fmt.Println("девять утра")
})
c.Start()
// процесс живёт на сервере с UTCDST: час, которого не было, и час, который настал дважды#
Зоны со сезонным переводом часов — Европа, Америка, Австралия — вносят в расписание два особенных момента в году. Весной час выкидывается: в ночь перехода время с двух до трёх не существует, и задача, стоящая на 02:30, в эту ночь попадает в несуществующий момент. Как это разрешится — вопрос к пакету time: документация time.Date честно пишет, что для несуществующего времени выбор зоны не гарантирован, то есть тик случится, но в соседний момент. Осенью наоборот: час наступает дважды, и момент 02:30 неоднозначен. Отсюда практические правила: ночные задачи не ставят на окно перехода (примерно 02:00–03:00 в зонах с DST), а задачи, которым важна точность до минуты, тестируют на даты переходов. Российским зонам сейчас проще: сезонные переводы в них отменены с 2014 года, и Europe/Moscow круглый год UTC+3. Но если бизнес в Европе или США — переходы обязаны быть частью плана, а в логах вокруг дат переходов полезно держать повышенное внимание хотя бы в первые недели после релиза.
time.LoadLocation читает базу IANA с диска: в Debian-образах она есть, в scratch и многих минимальных Alpine — нет. Итог: ошибка tzdata не найдена на старте или, что хуже, зона в отдельных окружениях не резолвится вовсе. Лечение — встроить базу в бинарник: пустой импорт _ "time/tzdata" в пакете main (Go 1.15+), ценой примерно четырёхсот килобайт. После этого LoadLocation работает в любом окружении, от scratch до CI-раннера. Проверка занимает минуту: локально соберите бинарник, запустите в контейнере без базы зон и убедитесь, что зона загрузилась — на CI это часто всплывает первым.
Часовые пояса пользователей: храните UTC, показывайте местное#
Когда зоны меняются от пользователя к пользователю — напоминания в боте, дайджесты по местному утру, — правило одно: хранить и считать в UTC, конвертировать на границе. В базах данных время сохраняется в UTC без зоны; в задаче вычисляется целевой момент — user.Zone.Load или LoadLocation по строке зоны профиля; момент интерпретируется в этой зоне. Префикс CRON_TZ здесь не помощник: он предполагает фиксированную зону, известную на старте, а у вас их столько же, сколько пользователей. Поэтому пользовательские напоминания обычно устроены иначе: одна задача тикает почасово или поминутно, выбирает из базы тех, у кого местное время подошло, и конвертирует время сравнения в их зоны. Расход — один тик вместо тысячи расписаний; зато зона меняется в базе данных, а не в коде, и новый пользователь не требует перерегистрации задач. Такую схему мы подробно собирали в уроке про напоминания в Telegram-боте; механика самого пакета time — зоны, In, ParseInLocation — разобрана в уроке про time и таймеры.
Четыре способа задать зону#
| Способ | Где пишется | Когда выбирать |
|---|---|---|
| time.Local по умолчанию | ничего не пишется | сервис и все задачи живут в зоне сервера осознанно |
| cron.WithLocation | конструктор планировщика | весь сервис работает по одной зоне бизнеса |
| CRON_TZ в выражении | префикс перед полями расписания | одна или несколько задач в фиксированных чужих зонах |
| конвертация в коде | внутри задачи: LoadLocation и time.In | зоны известны только в рантайме, по пользователю |
Соберите планировщик в новосибирской зоне
Впишите загрузку зоны, опцию конструктора и префикс зоны отдельной задачи.
Соберите планировщик в берлинской зоне
Расставьте строки в порядке выполнения: загрузка зоны, планировщик, регистрация, проверки и старт.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: часовые пояса
Работа с зонами — это пакет time, и базовые конвертации time.In между зонами прогоняются прямо в песочнице сайта: time там есть. Загрузку именованных зон через LoadLocation и сам robfig/cron песочница не умеет — сторонних пакетов и дисковой базы зон у неё нет, поэтому урок выполняется локально: модуль из второго урока, go run и минута на проверку тиков по чужой зоне.
Зона — это не деталь окружения, а часть контракта задачи: планировщик, задача и данные должны говорить на одном языке времени. Явная зона планировщика через WithLocation, точечная через CRON_TZ, пользовательские зоны через конвертацию на границе — три уровня, которые закрывают практически все сценарии, а встроенная база tzdata убирает зависимость от окружения контейнера. Остаётся дисциплина: зона задачи фиксируется в коде или конфиге рядом с выражением, а не вытаскивается из окружения молча. Следующий урок — секунды и cron.NewWithSeconds: шестипольный формат для задач, которым мало минут. Дальше стоит читать Start и Stop — жизненный цикл планировщика, который мы заводили в каждом примере этого урока, и установку robfig/cron, если модуль ещё не поднят. Полный маршрут — на странице курса. В домашнем задании — переезд планировщика в чужую зону с проверкой тиков по логам.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- cron.New() без опций живёт в time.Local процесса: на ноутбуке и в контейнере это разные зоны.
- cron.WithLocation задаёт зону планировщика явно: выражения парсятся и исполняются в ней.
- CRON_TZ в выражении — зона отдельной задачи; её приоритет выше зоны планировщика.
- DST-переходы делают окно 02:00–03:00 ненадёжным: несуществующее время Go разрешает без гарантий.
- В контейнеры без базы зон нужен пустой импорт _ "time/tzdata" — иначе LoadLocation падает.
- Пользовательские зоны решаются конвертацией на границе: хранение в UTC, задача сверяет местное время.
Домашнее задание
Поднимите планировщик из урока и добавьте три задачи: одну в зоне планировщика, одну через CRON_TZ в чужой зоне и одну, которая сверяет текущее время с зоной из строки-конфига. Поставьте тики на ближайшую минуту, запустите с TZ=UTC и без него и сверьте время по логам. Затем соберите бинарник с пустым импортом time/tzdata, запустите его в минимальном контейнере и убедитесь, что LoadLocation больше не падает.
Личные заметкиПусто
Вопросы и ответы по уроку
Что надёжнее: WithLocation или CRON_TZ в каждой задаче?
Если весь сервис живёт в одной зоне — WithLocation один раз и спокойствие для всех задач. Если задачи в разных зонах — зона планировщика становится значением по умолчанию, а отдельные задачи помечаются CRON_TZ. Смешивать оба уровня нормально: правило приоритета предсказуемо — префикс задачи сильнее.
Задача не сработала в ночь перехода на летнее время. Это баг?
Скорее всего, она стояла на несуществующий момент внутри выкинутого часа. Тик случится в соседнее разрешённое время, но точный момент пакет time не гарантирует. Для задач, чувствительных к минуте, ставьте их вне окна перехода или добавляйте компенсирующую проверку после окна.
Можно ли менять зону планировщика на лету?
Готового публичного способа в v3 нет: зона фиксирована на момент создания Cron. Практическое решение — пересоздать планировщик с новой зоной и перерегистрировать задачи, что и так делают сервисы с динамическим расписанием; между Stop старого и Start нового запуски не теряются, если соблюсти порядок из урока про жизненный цикл.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Секунды: cron.NewWithSeconds
- Start, Stop и жизненный цикл планировщика
- Контекст в задачах: отмена и дедлайны
- Долгие задачи: перекрытие запусков
- AddJob: задачи-структуры и интерфейс Job
- AddFunc: задачи на функциях
- Спецсимволы: звёздочка, слэш, диапазоны и @daily
- Синтаксис cron-выражений: пять полей
- Все курсы по Go
Следующая ступень учебного пути
Логирование в Go: структурные логи на slog
Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.
- Логировать структурированно: уровни, ключи-атрибуты, группы
- Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
- Мигрировать со старого пакета log без переписывания всей кодовой базы
- Держать производительность: Enabled, предвычисление, горячий путь