Чому завдання NestJS @Cron виконуються по одному на кожну репліку та як це виправити
Дізнайтеся, чому кількість внутрішніх запланувань @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, перевірка вхідних даних та належне логування.