Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 7 из 25 ~16 мин +20 XP

Часовые пояса: Timezone и локальное время

Часовой пояс cron-задачи — первое, что отличает локальный прототип от продакшена: планировщик интерпретирует выражения в зоне процесса, и задача на девять утра молча становится задачей на полдень, как только сервис переезжает в контейнер с UTC. В этом уроке разберём, откуда cron берёт зону, как назначить её планировщику целиком и отдельной задаче через CRON_TZ, чем опасны переходы на летнее время и почему базу зон в контейнер приходится класть руками.

Содержание урока · 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. Одна строка, и весь планировщик говорит на одном языке: выражения парсятся в этой зоне, тики вычисляются в ней же. Если сервис весь живёт в одном часовом поясе — например, внутренние отчёты компании в Берлине — этого достаточно, и дальше можно не читать. Остальным — про задачи в чужих зонах.

Планировщик в московской зоне

GO
func 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, и новый код лучше писать по ней.

Две задачи в разных зонах одного планировщика

GO
c := 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()

// процесс живёт на сервере с UTC

DST: час, которого не было, и час, который настал дважды#

Зоны со сезонным переводом часов — Европа, Америка, Австралия — вносят в расписание два особенных момента в году. Весной час выкидывается: в ночь перехода время с двух до трёх не существует, и задача, стоящая на 02:30, в эту ночь попадает в несуществующий момент. Как это разрешится — вопрос к пакету time: документация time.Date честно пишет, что для несуществующего времени выбор зоны не гарантирован, то есть тик случится, но в соседний момент. Осенью наоборот: час наступает дважды, и момент 02:30 неоднозначен. Отсюда практические правила: ночные задачи не ставят на окно перехода (примерно 02:00–03:00 в зонах с DST), а задачи, которым важна точность до минуты, тестируют на даты переходов. Российским зонам сейчас проще: сезонные переводы в них отменены с 2014 года, и Europe/Moscow круглый год UTC+3. Но если бизнес в Европе или США — переходы обязаны быть частью плана, а в логах вокруг дат переходов полезно держать повышенное внимание хотя бы в первые недели после релиза.

Часовые пояса пользователей: храните UTC, показывайте местное#

Когда зоны меняются от пользователя к пользователю — напоминания в боте, дайджесты по местному утру, — правило одно: хранить и считать в UTC, конвертировать на границе. В базах данных время сохраняется в UTC без зоны; в задаче вычисляется целевой момент — user.Zone.Load или LoadLocation по строке зоны профиля; момент интерпретируется в этой зоне. Префикс CRON_TZ здесь не помощник: он предполагает фиксированную зону, известную на старте, а у вас их столько же, сколько пользователей. Поэтому пользовательские напоминания обычно устроены иначе: одна задача тикает почасово или поминутно, выбирает из базы тех, у кого местное время подошло, и конвертирует время сравнения в их зоны. Расход — один тик вместо тысячи расписаний; зато зона меняется в базе данных, а не в коде, и новый пользователь не требует перерегистрации задач. Такую схему мы подробно собирали в уроке про напоминания в Telegram-боте; механика самого пакета time — зоны, In, ParseInLocation — разобрана в уроке про time и таймеры.

Четыре способа задать зону#

СпособГде пишетсяКогда выбирать
time.Local по умолчаниюничего не пишетсясервис и все задачи живут в зоне сервера осознанно
cron.WithLocationконструктор планировщикавесь сервис работает по одной зоне бизнеса
CRON_TZ в выражениипрефикс перед полями расписанияодна или несколько задач в фиксированных чужих зонах
конвертация в кодевнутри задачи: LoadLocation и time.Inзоны известны только в рантайме, по пользователю

Соберите планировщик в новосибирской зоне

Впишите загрузку зоны, опцию конструктора и префикс зоны отдельной задачи.

loc, err := time.("Asia/Novosibirsk") if err != nil { log.Fatal(err) } c := cron.New(cron.(loc)) // задача по местному времени Нью-Йорка _, _ = c.AddFunc("=America/New_York 30 8 * * *", morningReport)

Соберите планировщик в берлинской зоне

Расставьте строки в порядке выполнения: загрузка зоны, планировщик, регистрация, проверки и старт.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: часовые пояса

Термин

Зона по умолчанию

нажмите, чтобы перевернуть
Определение

cron.New() интерпретирует выражения в time.Local — зоне процесса: TZ, затем /etc/localtime. Ноутбук и контейнер живут в разных зонах, поэтому зона должна быть явной.

1 / 6

Зона — это не деталь окружения, а часть контракта задачи: планировщик, задача и данные должны говорить на одном языке времени. Явная зона планировщика через 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 больше не падает.

Личные заметкиПусто
Хранятся только в вашем браузере (Cron · Часовые пояса: Timezone и локальное время)

Вопросы и ответы по уроку

Что надёжнее: WithLocation или CRON_TZ в каждой задаче?

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

Задача не сработала в ночь перехода на летнее время. Это баг?

Скорее всего, она стояла на несуществующий момент внутри выкинутого часа. Тик случится в соседнее разрешённое время, но точный момент пакет time не гарантирует. Для задач, чувствительных к минуте, ставьте их вне окна перехода или добавляйте компенсирующую проверку после окна.

Можно ли менять зону планировщика на лету?

Готового публичного способа в v3 нет: зона фиксирована на момент создания Cron. Практическое решение — пересоздать планировщик с новой зоной и перерегистрировать задачи, что и так делают сервисы с динамическим расписанием; между Stop старого и Start нового запуски не теряются, если соблюсти порядок из урока про жизненный цикл.

Термины урока

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

Следующая ступень учебного пути

Slog25 уроков~9 чпрактика25 уроков уже доступны

Логирование в Go: структурные логи на slog

Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.

  • Логировать структурированно: уровни, ключи-атрибуты, группы
  • Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
  • Мигрировать со старого пакета log без переписывания всей кодовой базы
  • Держать производительность: Enabled, предвычисление, горячий путь