5 шаблонов выражений cron, применяемых в реальном production
Выражения cron выглядят просто — пять полей, несколько операторов — но production-команды раз за разом совершают одни и те же ошибки: задачи, которые молча пропускаются в определённые месяцы, перекрывающиеся запуски, нарушающие общее состояние, и сотни задач, просыпающихся в одну секунду и перегружающих базу данных. Проблема редко в самом выражении; проблема в том, что не продумываются граничные случаи.
В этом руководстве рассматриваются пять шаблонов cron, которые инженерные команды реально используют в production, с пояснением логики и ловушек, которые нужно избегать. Используйте Toova Cron Parser, чтобы проверить любое выражение и увидеть следующие десять моментов выполнения перед деплоем.
Анатомия выражения cron
Стандартное выражение cron состоит из пяти разделённых пробелами полей:
*/15 * * * *
| | | | |
| | | | +---- день недели (0-6, 0=воскресенье)
| | | +------- месяц (1-12)
| | +---------- день месяца (1-31)
| +------------- час (0-23)
+----------------- минута: каждые 15 (0, 15, 30, 45)
Каждое поле принимает: конкретное значение (5), подстановочный знак (*), диапазон (1-5), список (1,3,5) или шаговое значение (*/15). Некоторые реализации добавляют шестое поле для секунд; другие (например, GitHub Actions) ожидают ровно пять. Всегда проверяйте, какой диалект использует ваш планировщик, прежде чем писать сложные выражения.
Шаблон 1: каждые 15 минут — */15 * * * *
*/15 * * * *
| | | | |
| | | | +---- день недели (0-6, 0=воскресенье)
| | | +------- месяц (1-12)
| | +---------- день месяца (1-31)
| +------------- час (0-23)
+----------------- минута: каждые 15 (0, 15, 30, 45) Универсальное выражение для обработчиков очередей, проверок состояния и задач опроса, которые должны запускаться часто, но не непрерывно. Срабатывает в :00, :15, :30 и :45 каждого часа, ежедневно.
В записи crontab:
# запись в crontab — запуск обработчика очереди каждые 15 минут
*/15 * * * * /opt/app/bin/process-queue.sh >> /var/log/queue.log 2>&1 В Node.js:
// Node.js с node-cron
import cron from 'node-cron';
cron.schedule('*/15 * * * *', async () => {
const batch = await db.query(
'SELECT * FROM jobs WHERE status = $1 LIMIT 100',
['pending']
);
await processBatch(batch);
}); Проблема эффекта стада. Когда сотни воркеров используют одинаковое выражение */15, они все запускаются одновременно в :00, :15, :30 и :45. Если каждый воркер обращается к общей базе данных, вы получаете четыре пика нагрузки в час вместо равномерной. Решение — добавить случайный джиттер перед каждым запуском:
// Добавляем джиттер, чтобы избежать эффекта стада на распределённых системах
cron.schedule('*/15 * * * *', async () => {
const jitterMs = Math.floor(Math.random() * 30_000); // до 30 секунд
await sleep(jitterMs);
await processBatch();
}); До 30 секунд джиттера распределяют пик по 30-секундному окну, сохраняя задачу в рамках ожидаемого 15-минутного цикла. Для более сложного распределения смотрите Шаблон 5 (синтаксис Jenkins H).
Используйте Timestamp Converter, чтобы перевести следующее запланированное время из Unix epoch в читаемый формат при отладке расписания.
Шаблон 2: рабочие часы в будни — 0 9 * * 1-5
0 9 * * 1-5
| | | | |
| | | | +-- день недели: пн–пт (1-5)
| | | +------ месяц: каждый
| | +---------- день месяца: каждый
| +-------------- час: 9
+------------------ минута: 0 (ровно в начале часа) Это выражение срабатывает ровно один раз, в 9:00, с понедельника по пятницу. Стандартный шаблон для: отправки ежедневных дайджестов, запуска бизнес-отчётов в рабочее время, триггерных рабочих процессов, требующих проверки человеком в рабочий день, и деплоя на staging в начале рабочего дня команды.
В Kubernetes CronJob:
# Kubernetes CronJob — дайджест по рабочим дням в 9:00 UTC
apiVersion: batch/v1
kind: CronJob
metadata:
name: digest-email
spec:
schedule: "0 9 * * 1-5"
jobTemplate:
spec:
template:
spec:
containers:
- name: mailer
image: myapp/mailer:latest
command: ["/app/send-digest"]
restartPolicy: OnFailure Ловушка с часовым поясом. Расписания Kubernetes CronJob работают в часовом поясе кластера — по умолчанию UTC. «9:00 UTC» — это 5:00 по Восточному времени зимой и 4:00 по Тихоокеанскому. Пользователи будут получать «утренний дайджест» посреди ночи. Используйте библиотеку cron, принимающую строки часовых поясов IANA:
// Проблема временной зоны — "9:00 UTC" редко совпадает с ожиданиями пользователей
// Используйте библиотеку, понимающую часовые пояса IANA
import { CronJob } from 'cron';
const job = new CronJob(
'0 9 * * 1-5',
() => sendDailyDigest(),
null,
true,
'America/New_York' // запускается в 9:00 по Нью-Йорку, не в 9:00 UTC
); Timezone Converter поможет найти UTC-эквивалент для любого местного времени в любом часовом поясе IANA.
Нумерация дней недели. В стандарте cron используется 0 или 7 для воскресенья. Некоторые реализации (Quartz, cron4j) используют 1 для понедельника вместо 0. Проверяйте документацию вашего планировщика и тестируйте с Cron Parser перед деплоем.
Шаблон 3: ночной батч — 0 22 * * *
0 22 * * *
| | | | |
| | | | +-- день недели: любой
| | | +------ месяц: любой
| | +---------- день месяца: любой
| +-------------- час: 22 (22:00)
+------------------- минута: 0 Запускается раз в день в 22:00. Ночной батч — один из самых распространённых шаблонов в data engineering: агрегирование событий за день, перестройка поисковых индексов, генерация отчётов, архивирование логов, отправка уведомлений конца дня.
Почему 22:00, а не полночь? Две причины. Во-первых, 22:00 даёт буфер перед сменой дня, снижая вероятность захвата событий, поступающих с задержкой. Во-вторых, тяжёлая задача в полночь конкурирует с ежемесячным биллингом (Шаблон 4) в первые числа месяца.
Три наиболее распространённых способа написать ежедневную задачу:
# Три способа написать "ежедневно в 22:00":
0 22 * * * # стандартный cron
@daily # полночь — НЕ то же самое, что 22:00
0 22 * * * # всегда предпочитайте явное время вместо @-макросов для не полночного времени Предотвращение перекрытия. Ночная задача, обычно занимающая 45 минут, иногда может занять 2 часа — и если она ещё выполняется в момент запуска следующей ночи, обе задачи будут изменять одни и те же данные. Используйте распределённую блокировку:
// Проверка идемпотентности — предотвращение двойной обработки при перекрытии задач
async function runNightlyBatch() {
const lock = await redis.set(
'nightly-batch-lock',
process.pid,
'EX', 3600, // TTL: 1 час
'NX' // установить только если ключа нет
);
if (!lock) {
console.log('Batch уже запущен, пропускаем.');
return;
}
try {
await processNightlyData();
} finally {
await redis.del('nightly-batch-lock');
}
}
Флаг Redis NX гарантирует, что только один процесс может удерживать блокировку одновременно. TTL предотвращает удержание блокировки навечно упавшей задачей.
Шаблон 4: ежемесячный биллинг — 0 0 1 * *
0 0 1 * *
| | | | |
| | | | +-- день недели: любой
| | | +------ месяц: любой
| | +---------- день месяца: 1 (первое число)
| +-------------- час: 0 (полночь)
+------------------ минута: 0 Срабатывает в полночь первого числа каждого месяца. Классический шаблон для: генерации ежемесячных счетов, сброса квот использования, ежемесячной сверки и архивирования данных предыдущего месяца.
Ловушки cron для ежемесячных задач:
# Первое число месяца в полночь UTC
0 0 1 * *
# ---- ЛОВУШКИ ----
# Это НЕ работает надёжно как "каждый месяц":
0 0 29-31 * * # в месяцах с меньшим числом дней задача молча пропускается
# Ежеквартальный биллинг (янв, апр, июл, окт):
0 0 1 1,4,7,10 *
# Последнее число месяца сложно — стандартный cron не имеет "последнего дня"
# Используйте @reboot + логику в коде или cron, который запускается ежедневно и
# проверяет в коде, является ли сегодня последним днём месяца. Биллинг в конце месяца. Если нужно выставлять счета в последний день каждого месяца (а не первого), чистого выражения cron для этого не существует. Обходной путь:
// Проверка "последнего дня месяца" на уровне приложения
cron.schedule('0 23 28-31 * *', async () => {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
// Если завтра 1-е, значит сегодня последний день месяца
if (tomorrow.getDate() === 1) {
await runEndOfMonthBilling();
}
}); Задача запускается 28–31 числа каждого месяца и проверяет в коде, является ли сегодня действительно последним днём. Это приводит к нескольким лишним запускам (в месяцах с менее чем 31 днём), но проверка дешёвая, а логика понятная.
Для ежеквартального биллинга используйте оператор списка в поле месяца: 0 0 1 1,4,7,10 * (январь, апрель, июль, октябрь).
Шаблон 5: хешированное расписание (синтаксис Jenkins H) — H/30 * * * *
H/30 * * * *
|
+--- H означает, что Jenkins выбирает случайную минуту для каждой задачи.
H/30 = раз в 30 минут, начиная с H.
Разные задачи получают разные значения H, распределяя нагрузку.
Токен H — расширение Jenkins для синтаксиса cron. Он заменяется стабильным псевдослучайным числом, полученным из имени задачи. H/30 в поле минут означает «раз в 30 минут, начиная с минуты, определяемой хешем имени задачи».
В Jenkinsfile:
// Jenkinsfile — пайплайн запускается каждые 30 минут со смещением
pipeline {
triggers {
cron('H/30 * * * *')
}
stages {
stage('Deploy') {
steps {
sh './deploy.sh'
}
}
}
} Почему это важно. В инсталляциях Jenkins часто сотни пайплайнов. Если каждый использует */30 * * * *, все они запускаются в :00 и :30, создавая огромные пики нагрузки на пул агентов и любые нижестоящие системы. С H/30 каждая задача получает разное смещение — одна в :03/:33, другая в :17/:47 — равномерно распределяя нагрузку по часу.
GitHub Actions не поддерживает H. Для распределения нескольких рабочих процессов Actions выбирайте разные фиксированные смещения минут вручную:
// GitHub Actions — H не поддерживается; смещения задаются вручную
// Для двух задач: выбираем разные фиксированные минуты
name: Job A
on:
schedule:
- cron: '5 * * * *' # запускается в :05 каждого часа
---
name: Job B
on:
schedule:
- cron: '35 * * * *' # запускается в :35 — через 30 минут после Job A Это менее элегантно, чем Jenkins H, но достигает того же эффекта для небольшого числа рабочих процессов. Для большого числа рабочих процессов рассмотрите внешний оркестратор с нативной поддержкой распределённого планирования.
Сквозные вопросы
Идемпотентность — обязательное требование
Каждая задача cron должна быть безопасна для повторного запуска. Инфраструктура ломается, планировщики перезапускаются, а распределённые блокировки иногда истекают. Проектируйте задачи так, чтобы их повторный запуск с теми же входными данными в то же время давал тот же результат. Используйте upsert вместо insert, паттерн «проверка перед действием» и ключи идемпотентности для вызовов внешних API.
Оповещения об ошибках
Задача cron, которая молча падает, хуже той, что вообще не запускается. Отправляйте оповещение в очередь недоставленных сообщений или систему мониторинга, когда задача завершается с ошибкой или не укладывается в ожидаемое временное окно. Такие инструменты, как Sentry Crons, Healthchecks.io и Cronitor, добавляют проверку пульса: задача обращается к URL при старте и при завершении. Если сигнал завершения не приходит в срок, срабатывает оповещение.
Логируйте расписание
Выдавайте структурированную строку лога в начале и в конце каждого запуска cron, включая выражение, метку времени запуска (а не текущее время, которое может немного отличаться из-за джиттера) и продолжительность. Это упрощает корреляцию инцидента («база данных замедлилась в 22:00») с конкретным запуском батча.
Краткая справка
Разбирайте и проверяйте любые выражения с помощью Cron Parser. Конвертируйте запланированное время между часовыми поясами с помощью Timezone Converter. Если задача логирует Unix-временные метки, Timestamp Converter мгновенно переведёт их в читаемые даты.
Для исчерпывающего справочника синтаксиса cron и общих примеров crontab.guru — канонический быстрый справочник. Синтаксис Jenkins H и его полная спецификация описаны в документации Jenkins Pipeline cron syntax.