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