Dlaczego zadania @Cron w NestJS uruchamiają się tylko raz na replikę i jak to naprawić
Dowiedz się, dlaczego harmonogramy @Cron wewnątrz procesu mnożą się, gdy usługa NestJS jest skalowana, oraz jak zewnętrzny wyzwalacz, operacje zapisu idempotentne i zabezpieczony endpoint mogą to naprawić.
Zadanie wykonywane co noc, które powinno tworzyć dokładnie jedną запис dla każdej jednostki, zaczyna generować duplikaty: identyczne wiersze dla tej samej jednostki i daty rozpoczęcia, zapisane z różnicą kilku sekund, przy czym żaden z nich nie jest uszkodzony. W kodzie zadania nic się nie zmieniło. Zmianą jest to, że usługa teraz działa na kilku replikach, a planer znajduje się w każdej z nich. Ten artykuł wyjaśnia, dlaczego dekorator @Cron w NestJS zachowuje się w ten sposób, porównuje trzy sposoby na jego naprawę oraz omawia wybrany projekt, w tym szczegóły dotyczące bezpieczeństwa i stref czasowych.
Jak powstają duplikaty
Rozważmy zadanie, które przenosi każdą aktywną jednostkę do następnego okna czasowego, tworząc każdego dnia jedną nową запис dla niej. Idiomaticzna implementacja w NestJS wykorzystuje dekorator @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);
}
}
}
To jest dokładnie to, co sugeruje dokumentacja, i działa bez zarzutu, gdy usługa jest uruchamiana jako pojedyncza instancja.
Problemy pojawiają się po skalowaniu poziomym. Przy trzech instancjach istnieją trzy procesy, z których każdy uruchamia moduł, a @Cron rejestruje w nich swój timer. Nic ich nie koordynuje: o godzinie 08:00 wszystkie trzy uruchamiają się jednocześnie. Ponieważ zadanie nie sprawdza idempotencji ani nie używa blokad, przy każdym uruchomieniu szuka już istniejącego okna czasowego, nie znajduje go i zapisuje własną kopię.
Następują dwa główne problemy. Najbardziej oczywisty to duplikacja danych. Mniej oczywisty to marnotrawstwo zasobów: każda kopia wykonywać te same zadania w tym samym momencie, co skutkuje koniecznością dodatkowych wysiłków na oczyszczenie wyników. Planer zadań w usłudze skalowanej to nie tylko błąd dotyczący poprawności działania; zgodnie z projektem zużywa zasoby obliczeniowe proporcjonalnie do liczby kopii. W kodzie nie ma żadnych wskazówek na ten temat, dlatego problem ten najczęściej pojawia się w danych testowych, a nie podczas przeglądania.
Trzy sposoby na rozwiązanie
Opcja 1: zamykanie rozproszone
Zachowaj @Cron, ale spraw, by instancje konkurowały o blokadę, na przykład blokadę doradczą bazy danych lub klucz w pamięci podręcznej, i pozwól uruchomić się tylko zwycięzcy. To działa, ale zamienia widoczną awarię na niewidoczną. Duplikatów wierszy przynajmniej można znaleźć i usunąć. Przerwany proces nie może tego zrobić: jeśli mechanizm blokad jest niedostępny o godzinie 08:00 lub instancja ulegnie awarii trzymając blokadę przed upływem jej czasu ważności, zadanie po prostu się nie wykona i nikt tego nie zauważy, dopóki brakujący okres nie spowoduje problemów kilka dni później. Dodatkowo powstaje nowa zależność oraz nowy, niewidoczny tryb awarii, aby skompensować umieszczenie zegara w niewłaściwej warstwie.
Opcja 2: sama idempotencja
Zrób tak, by zadanie mogło być wykonywane więcej niż raz bez żadnych konsekwencji. Jest to tanie i poprawne rozwiązanie, ale nadal trzy kontenery budzą się każdej nocy, by wykonywać zbędne prace.
Opcja 3: przeniesienie planowania poza aplikację
Aplikacja nie powinna decydować o kiedy ma się wykonać zadanie. Niech zewnętrzny planer zarządza czasem i wysyła jeden żądanie HTTP do zwykłego punktu końcowego; aplikacja decyduje jedynie o czym się stanie, gdy otrzyma to żądanie. Jeden wyzwalacz powoduje jedną eksploatację, a dodawanie replik już niczego nie pomnaża. Powszechnymi miejscami dla takiego wyzwalacza są CronJob w Kubernetes, usługa planera dostawcy chmury lub harmonogram rurociągu CI.
Wybrany projekt łączy opcję 3 z właściwością idempotencji z opcji 2 jako zabezpieczeniem.
Nowy projekt
Dekorator @Cron został usunięty, a logika zadania znajduje się za punktem końcowym:
@Post('jobs/run')
async runJob(@Body() body: RunJobDto) {
this.assertValidSecret(body.secret);
return this.jobs.run(body.jobKey);
}
Zewnętrzny planer wywołuje tę funkcję raz w wyznaczonym czasie. Balanser obciążeń kieruje żądanie do jednej instancji, która wykonuje zadanie; pozostałe repliki nigdy nie biorą udziału. Przekazanie jobKey umożliwia pojedynczemu punktowi końcowemu wysyłanie kilku zadań.
Praktyczna poprawka: zadanie trwające kilka minut może przekroczyć czas timeoutu HTTP planera. W przypadku długotrwałych zadań rozważ szybkie potwierdzenie otrzymania żądania i wykonywanie pracy w tle, jednocześnie zapobiegając jej nakładaniu się.
Zachowanie właściwości idempotentności
Kontrola idempotentności pozostaje konieczna, ponieważ obietnica „wykonania dokładnie raz” jest standardem w infrastrukturze, który zazwyczaj jest przestrzegany, ale czasami łamany: planer próbuje ponownie po upływie timeoutu, ktoś ręcznie uruchamia zadanie lub instancja restartuje się w trakcie jego wykonywania.
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(/* ... */);
}
Zanim utworzono okno, metoda szuka takiego o tym samym identyfikatorze entity i dacie rozpoczęcia, a jeśli istnieje, wraca wcześniej. Należy pamiętać, że mechanizm sprawdzenia przed dodaniem również może prowadzić do problemów, jeśli dwie operacje przebiegają dokładnie jednocześnie. Niezawodnym rozwiązaniem jest unikalna restrykcja w bazie danych dotycząca identyfikatora entity i daty rozpoczęcia, dzięki czemu duplikaty powstałe w trakcie równoległych operacji zostaną odrzucone podczas próby dodania, zamiast zostać zapisane. Aby dowiedzieć się więcej na temat projektowania operacji zapisu odpornych na ponawianie, zapoznaj się z naszym przewodnikiem po kluczach idempotencji w Node.js POST endpointach.
Jakie są koszty tej zmiany
Endpoint zadania to endpoint publiczny
Zmiana prywatnego, nocnego zadania na trasę HTTP tworzy przycisk, który każdy może naciskać wielokrotnie. Dlatego taka trasa wymaga wspólnego sekretu, a sposób porównywania tego sekretu ma znaczenie:
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();
}
}
Porównywanie za pomocą provided === expected może prowadzić do wycieku informacji poprzez kwestie czasowe: porównanie ciągów znaków może zakończyć się po pierwszym niezgodnym znaku, więc czas potrzebny na wykrycie różnicy wskazuje, na ile przypuszczenie było poprawne, co umożliwia atakownikowi stopniowe odzyskiwanie tajnych danych. Funkcja timingSafeEqual z modułu crypto w Node porównuje dane w czasie stałym. Wymaga ona buforów o równej długości, dlatego najpierw sprawdza się jej długość; haszowanie obu wartości przed porównaniem zapobiega ujawnieniu nawet długości. Recenzenci rzadko zwracają na to uwagę, a poleganie na tym, że endpoint pozostanie nieodkryty, nie jest skuteczną strategią.
Należy rozważyć dwa dodatkowe kroki w celu zwiększenia bezpieczeństwa. Wysyłanie sekretu w nagłówku żądania zamiast w jego ciele zapobiega jego przechwytywaniu przez narzędzia do logowania treści ciała żądania. Ponadto weryfikacja tego, czy secret jest niepustym łańcuchem w obiekcie RunJobDto, uniemożliwia wywołanie błędu przez Buffer.from w przypadku braku danych wejściowych.
Wybór godziny to decyzja dotycząca produktu
Drugi koszt łatwo jest niedocenić: wybór czasu. Zadanie musi być wykonywane po rozpoczęciu dnia dla każdego użytkownika, a użytkownicy znajdują się w różnych strefach czasowych Stanów Zjednoczonych, więc godzina w południe na wschodnim wybrzeżu to nadal czas przed świtem na zachodnim wybrzeżu. Dlatego harmonogram jest ustalany na określoną godzinę w UTC, wybraną względem najbardziej zachodniej strefy czasowej, w której działa firma, ponieważ to jest czas bezpieczny we wszystkich miejscach. Cron tab używa czasu UTC, podczas gdy wymagania dotyczą czasu lokalnego, i to właśnie przy tłumaczeniu między nimi podejmowana jest ostateczna decyzja. Zapisz ten uzasadnienie obok harmonogramu, ponieważ nie wynika on bezpośrednio z samego wyrażenia cron.
Główne wnioski
@Crondziała we wszystkich procesach, które ładują ten moduł, więc liczba jego wykonań rośnie wraz z liczbą replik bez żadnych ostrzeżeń w kodzie.
timingSafeEqual, walidacji danych wejściowych oraz rozsądnego logowania.Literatura pokrewna
- Wewnątrz interceptorów NestJS: Usunięcie regresji latencji 96% w skali masowej — Dowiedz się, jak pipeline wykonywania AOP w NestJS oraz pułapki przy dezinstalacji RxJS spowodowały gwałtowny wzrost latencji P99, oraz jak stworzyć interceptor audytowy bez zużycia pamięci w celu jego naprawy.
- Od zapisu do adresu URL: Bezpieczne przechowywanie i serwowanie plików użytkowników w Express — Dowiedz się, gdzie aplikacje Express powinny przechowywać załadowane pliki, jak express.static mapuje folder na adresy URL oraz jakie środki ochronne zapobiegają temu, by pliki przesłane przez użytkowników stały się luką bezpieczeństwa.