Перейти к содержимому
Toova
Все инструменты

5 шаблонов выражений cron, применяемых в реальном production

Toova

Выражения 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.