Почему задания @Cron в NestJS выполняются по одному разу на каждую реплику и как это исправить
Узнайте, почему количество внутренних запланировщиков @Cron увеличивается при масштабировании сервиса NestJS, и как внешний триггер, идемпотентные записи и защищённый конечный пункт решают эту проблему.
Еженощная задача, которая должна создавать ровно одну запись для каждого существа, начинает генерировать дубликаты: идентичные строки для одного и того же существа и даты начала, созданные с разницей в несколько секунд, причем ни одна из записей не повреждена. В коде задачи ничего не менялось. Изменилось лишь то, что теперь сервис работает на нескольких репликах, причем планировщик находится в каждой из них. В этой статье объясняется, почему декоратор @Cron в NestJS ведет себя именно так, сравниваются три способа его устранения, а также рассматривается выбранный подход, включая детали безопасности и часовых поясов.
Как возникают дубликаты
Рассмотрим задачу, которая перемещает каждое активное существо в следующее ограниченное временное окно, создавая по одной новой записи для каждого существа ежедневно. Типичная реализация в NestJS использует декоратор @nestjs/schedule:
@Injectable()
export class WindowGenerationService {
@Cron('0 8 * * *') // every day at 08:00
async generateNextWindows() {
const entities = await this.repo.findActiveEndingSoon();
for (const entity of entities) {
await this.repo.createNextWindow(entity);
}
}
}
Это именно то, что рекомендуется в документации, и всё работает безупречно, пока сервис запускается в виде одного экземпляра.
Проблемы начинаются после горизонтального масштабирования. При наличии трех экземпляров существует по три процесса, каждый из которых загружает модуль, и @Cron регистрирует свой таймер в каждом из них. Ничто не координирует их действия: в 08:00 все три запускаются одновременно. Поскольку задача не выполняет проверки идемпотентности и не использует блокировку, при каждом запуске ищется существующее окно времени; его не находя, создается собственная копия.
Существуют две основные проблемы. Очевидной является наличие дублирующихся данных. Менее очевидной — расточительство ресурсов: каждая копия выполняет одну и ту же работу в один и тот же момент, после чего требуется дополнительные усилия для устранения последствий. Планировщик в процессе обработки в масштабируемом сервисе не только является причиной ошибок корректности, но и по своей конструкции потребляет вычислительные ресурсы пропорционально количеству копий. В коде нет никаких намеков на это, поэтому проблема обычно проявляется в тестовых данных, а не при проверке.
Три способа её решения
Вариант 1: распределённый замок
Сохраните @Cron, но заставьте экземпляры соревноваться за блокировку, например, с помощью консультативной блокировки базы данных или ключа в кэше, и позвольте выполняться только победителю. Этот метод работает, но заменяет видимую ошибку невидимой. Дублирующиеся строки хотя бы можно найти и удалить. Однако пропущенная попытка выполнения не дает такой возможности: если бэкенд блокировки недоступен в 08:00 или экземпляр падает, удерживая блокировку до истечения срока её действия, задача просто не выполняется, и никто этого не замечает до тех пор, пока пропущенный период времени не вызовет проблемы спустя несколько дней. Кроме того, это добавляет новую зависимость и новый способ бесшумной ошибки для компенсации таймера, размещённого не на том уровне.
Вариант 2: только идемпотентность
Сделайте так, чтобы задачу можно было выполнять несколько раз без последствий — дополнительные попытки ничего не изменят. Это просто и корректно, но три контейнера всё равно будут запускаться каждую ночь для выполнения избыточной работы.
Вариант 3: вынести планирование из приложения
Приложение не должно решать, когда выполняться работа. Пусть внешний планировщик управляет временем и отправляет один HTTP-запрос на обычный конечный пункт; приложение определяет лишь что происходит, когда получает этот запрос. Один триггер приводит к одному запуску, и добавление реплик больше ничего не умножает. Распространенными вариантами таких триггеров являются CronJob в Kubernetes, сервис планирования от поставщика облачных услуг или расписание CI-пайплайна.
Выбранный дизайн сочетает вариант 3 с идемпотентностью из варианта 2 в качестве резервного механизма.
Новый дизайн
Декоратор @Cron удаляется, а логика задачи находится за конечным пунктом:
@Post('jobs/run')
async runJob(@Body() body: RunJobDto) {
this.assertValidSecret(body.secret);
return this.jobs.run(body.jobKey);
}
Внешний планировщик вызывает этот метод один раз в назначенное время. Балансировщик нагрузки направляет запрос к одному экземпляру, который выполняет задачу; остальные копии никогда не участвуют в этом процессе. Передача параметра jobKey позволяет одному конечному пункту обслуживания отправлять несколько задач.
Практическое усовершенствование: задача, выполняющаяся несколько минут, может превысить время ожидания HTTP-запроса со стороны планировщика. Для длительных задач рекомендуется быстро подтверждать получение запроса и выполнять работу в фоновом режиме, при этом продолжая защищаться от перекрытия задач.
Использование идемпотентности как меры предосторожности
Проверка идемпотентности остается необходимой, поскольку обещание «выполнить ровно один раз» — это принцип, который инфраструктура обычно соблюдает, но иногда нарушает: планировщик повторяет попытку после истечения времени ожидания, кто-то вручную запускает задачу или экземпляр перезагружается в процессе её выполнения.
async createNextWindow(entity: Entity) {
const existing = await this.repo.findByEntityIdAndStartDate(
entity.id,
entity.nextStartDate,
);
if (existing) return; // already done, no-op
await this.repo.create(/* ... */);
}
Перед созданием окна метод ищет окно с тем же идентификатором сущности и датой начала, и если оно существует, он сразу возвращает результат. Следует помнить, что подход «сначала проверка, затем вставка» сам по себе является неконсистентным, если два запуска полностью совпадают по времени. Надежным решением является наличие уникального ограничения в базе данных для идентификатора сущности и даты начала, что предотвращает создание дубликатов при одновременных операциях. Чтобы узнать больше о проектировании операций записи, устойчивых к повторным попыткам, ознакомьтесь с нашим руководством по ключам идемпотентности в Node.js POST-эндпоинтах.
Какова стоимость изменений
Эндпоинт задачи — это публичный эндпоинт
Преобразование частного ночного задания в HTTP-маршрут создает кнопку, которую может нажимать кто угодно, кто ее найдет, многократно. Поэтому такой маршрут требует общего секрета, и способ сравнения этого секрета имеет значение:
private assertValidSecret(provided: string) {
const expected = this.config.cronSecret;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new UnauthorizedException();
}
}
Сравнение с помощью provided === expected может привести к утечке информации из-за особенностей времени выполнения: сравнение строк может прекратиться при первой несовпадающей символе, поэтому время, затраченное на обнаружение несоответствия, указывает на степень точности предположений, что позволяет злоумышленнику постепенно восстановить секретную информацию. Функция timingSafeEqual из модуля crypto в Node выполняет сравнение за константное время. Для её работы требуются буферы одинаковой длины, поэтому сначала проверяется их длина; хеширование обоих значений перед сравнением предотвращает раскрытие даже информации о длине. Ревизоры редко указывают на это, а полагание на то, что такой способ останется незамеченным, не является эффективной стратегией.
Стоит рассмотреть ещё два шага по усилению безопасности. Отправка секретного значения в заголовке запроса вместо в теле предотвращает его попадание в механизмы логирования тела запроса. А проверка того, что переменная secret в классе RunJobDto представляет собой непустую строку, не позволяет функции Buffer.from выдавать ошибку при отсутствии входных данных.
Выбор времени — это решение, принимаемое разработчиками продукта
Вторая стоимость легко недооценивается: выбор времени выполнения. Задача должна запускаться после начала дня для каждого пользователя, причем пользователи находятся в разных часовых поясах США, так что час днем на восточном побережье всё ещё считается ранним утром на западном побережье. Поэтому расписание фиксируется на определённый час по UTC, выбранный исходя из самого западного часового пояса, в котором работает компания, поскольку именно этот час удобен везде. Cron-файл указывает время по UTC, тогда как требования касаются местного времени, и именно при переводе между ними принимается окончательное решение. Запишите этот обоснование рядом с расписанием, так как из одного лишь выражения cron этого не понять.
Основные выводы
@Cronвыполняется в каждом процессе, загружающем модуль, поэтому количество его запусков умножается на количество копий без каких-либо предупреждений в коде.
timingSafeEqual, проверка входных данных и разумная логгирование.