Strona główna / Artykuły / Dlaczego zadania @Cron w NestJS uruchamiają się tylko raz na replikę i jak to naprawić

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ć.

1350 słów

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

  • @Cron dział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.
  • Zamki rozproszone eliminują duplikaty, ale powodują bezgłośne nieudane wykonywania zadań oraz dodatkową zależność.
  • Planowanie dotyczy kwestii „kiedy”, która należy do zewnętrznej infrastruktury; aplikacja powinna odpowiadać jedynie za to, „co się dzieje” po uruchomieniu.
  • Niezależnie od tego zachowajcie właściwość idempotencji, najlepiej poprzez unikalną ograniczenie w bazie danych, ponieważ jednorazowa dostawa to obietnica kogoś innego.
  • Koniecpunkt zadań wymaga rzeczywistej ochrony: tajnego klucza porównywanego z metodą timingSafeEqual, walidacji danych wejściowych oraz rozsądnego logowania.
  • Definiujcie harmonogramy celowo w formacie UTC, na podstawie strefy czasowej, która najbardziej ogranicza wymagania.
  • Literatura pokrewna