Галоўная / Артыкулы / Чаму заданні NestJS @Cron выконваюцца адна раз пры кожнай рэпліке і як гэта паспрацаваць

Чаму заданні NestJS @Cron выконваюцца адна раз пры кожнай рэпліке і як гэта паспрацаваць

Дазвольце дазнаць, чаму калі сервіс NestJS расширваеся, колькасць планаванняя @Cron унутраніх процэсаў зростае, і як зовнішній трыгер, ідэмпотентныя запісы і захаваны канцэнтры выправляюць гэта.

1350 слоў

Ночны адміністратыўны задань, якое павінна ствараць роўна адну записку на кожную ентытэ, пачала ствараць дуплікаты: ідэнтычныя рядкі для той самай ентытэ і даты пачатку, запісаныя з разніцай у калькунутыя секунды, пры чым ніхто з іх не парадураваны. Нічога ў кодзе задання не змянілася. Змянілася тое, што сервіс тепер запускаецца на калькунатых копіях, а планавач знаходзіцца ў кожной з іх. У гэтым артыкуле пояснюецца, чаму дэкоратар @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-table пісае на UTC, тады як трэбавання — на местны час, і саме падчас перакладу між гэтымі двума форматамі вырабляецца справжняя рашэнне. Запісайце гэтыя мотыўвы пад раскладам, таму што з самай выраза cron іх нельга ясна разумець.

Ключовыя выводы

  • @Cron выконваецца ў кожным процэсе, який завантажвае модуль, таму колькасць його выконанняў зростае разам з колькасцю копій без жадных паведамленняў у кодзе.
  • Распадзеленыя блакіты вылечваюць дуплікаты, але ствараюць тых, хто працуе без рэзультата, і дапамагаюць з’яўляцца дадатковым залежнасцям.
  • Планаванне — это пытанне „калі“, якае належыць да зовнішняй інфраструктуры; аплікацыя должна кераваць толькі „што адбываецца“ пасля запуску.
  • Зберагаюце ідэмпотентнасць у будзь-якім случае, ідеальна ў меры, калі яна прымусова за дапамогою унікальнага обмежэння базы дадзеных, таму што адна доставка — это обява каго-іншага.
  • Тэрмінал задання патрабуе рэальнай захопленасці: секретны параметр, паўпорачаны з timingSafeEqual, перакананне вхідных дадзеных і разумны логавання.
  • Значэнні планаў каліматываць у UTC намеравана, на адной зоны часу, якая найбольшае обмежвае трэбаванні.