Alle Beiträge

NestJS-Cronjobs mit mehreren Instanzen: @nestjs/locks

· 5 Min. Lesezeit

nestjs
typescript

Läuft eine App auf mehreren Servern, ist sie ausfallsicherer und verteilt die Last. Geplante Aufgaben wie ein nächtlicher Export oder eine Erinnerungs-Mail laufen dann aber auf jedem Server, also doppelt oder dreifach. Mit @nestjs/locks gibt es dafür jetzt eine offizielle Lösung, und ich finde sie richtig gut.

Kurz gesagt

  • Neu: Das offizielle NestJS-Paket @nestjs/locks sorgt dafür, dass eine geplante Aufgabe (ein Cronjob) nur auf einem Server läuft.
  • Betroffen: Jede NestJS-App, die mit mehr als einer Instanz läuft und Cronjobs im Code hat, z. B. auf AWS EC2 oder in Kubernetes.
  • Zu tun: Paket einbinden, einen gemeinsamen Speicher anbinden (Redis oder die vorhandene Datenbank) und die betroffenen Jobs markieren. Die Infrastruktur bleibt, wie sie ist.
  • Nutzen: Keine doppelten E-Mails oder Exporte mehr und kein eigener Server oder Container nur für Cronjobs.

Das Problem

NestJS bringt mit @nestjs/schedule einen Scheduler mit. Ein Cronjob ist damit schnell geschrieben:

coffee.service.ts
@Injectable()
export class CoffeeService {
  @Cron("0 7 * * *")
  async orderBeans() {
    // Kaffeebohnen nachbestellen
  }
}

Mit einer Instanz ist das kein Problem. Starten aber zwei Instanzen hinter einem Load Balancer, hat jede ihren eigenen Scheduler. Um 7 Uhr bestellen dann beide Bohnen, ohne voneinander zu wissen, und am nächsten Tag stehen zwei Pakete vor der Tür.

Das Problem hatte ich auch schon. Gerade auf Hosting wie AWS EC2 gibt es keinen einfachen Weg, einen Job nur auf einer Maschine laufen zu lassen. Die Lösung war bisher z. B. ein separater Container, der zeitgesteuert von außen gestartet wird, den Job mit nest-commander ausführt und sich danach wieder beendet. Ein anderer Weg war ein selbstgebauter Lock in Redis. Beides funktioniert, aber beides ist zusätzlicher Aufwand, den man pflegen muss.

Die Lösung mit @nestjs/locks

1. Paket installieren

bash
npm i --save @nestjs/locks

Ich habe das Beispiel mit NestJS 12 getestet.

2. Einen gemeinsamen Store anbinden

Damit die Instanzen sich absprechen können, brauchen sie einen gemeinsamen Store für die Locks. Ich würde Redis nehmen, wenn es schon im Projekt ist, sonst die vorhandene Datenbank.

Den Store schreibst du selbst, er ist aber kurz: eine Klasse mit den Methoden acquire(), renew() und release(). Die Doku hat vollständige Vorlagen für Redis (ioredis) sowie für Postgres mit Drizzle oder TypeORM. Der Store wird als normaler Provider registriert:

app.module.ts
import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { LocksModule } from "@nestjs/locks";
import { RedisLockStore } from "./locks/redis-lock.store";

@Module({
  imports: [ScheduleModule.forRoot(), LocksModule.forRoot()],
  providers: [RedisLockStore],
})
export class AppModule {}

Beim Start steht im Log, welcher Store aktiv ist. So siehst du sofort, ob die Registrierung geklappt hat.

3. Den Job markieren

Am Job selbst kommt nur ein Decorator dazu:

coffee.service.ts
import { Injectable } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { OnOneInstance } from "@nestjs/locks"; 
@Injectable()
export class CoffeeService {
  @Cron("0 7 * * *")
  @OnOneInstance({ key: "coffee:order-beans" })   async orderBeans() {
    // Kaffeebohnen nachbestellen. Einmal. Nicht zweimal.
  }
}

Um 7 Uhr versuchen alle Instanzen, den Lock zu bekommen. Eine schafft es und führt den Job aus, die anderen überspringen diesen Run.

4. Shutdown-Hooks aktivieren

main.ts
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(process.env.PORT ?? 3000);

Damit gibt eine Instanz ihren Lock beim Beenden sofort frei, z. B. bei einem Deployment. Sonst bleibt er bestehen, bis die TTL abgelaufen ist.

Bonus: Overlapping verhindern

Für Jobs, die öfter laufen und manchmal länger brauchen, gibt es @WithoutOverlapping(). Läuft der vorherige Run noch, wird der nächste übersprungen:

coffee.service.ts
@Cron("*/5 * * * *")
@WithoutOverlapping({ key: "coffee:brew" })
async brew() {
  // Kaffee brühen. Läuft die Maschine noch, wird dieser Run übersprungen.
}

Warum das funktioniert

Ein Lock ist hier eine Lease mit TTL (Time to Live), standardmäßig 30 Sekunden. Die Instanz, die sie hält, verlängert sie im Hintergrund, solange der Job läuft.

Stürzt die Instanz ab, verlängert niemand mehr, und die Lease läuft von selbst ab. Es bleibt also kein Lock für immer hängen, und beim nächsten Run übernimmt eine andere Instanz.

Stolperfallen

  • Kein Store in Production: Ohne registrierten Store nutzt das Paket einen In-Memory-Store, der nur eine Instanz schützt. Mit NODE_ENV=production startet die App dann bewusst nicht.
  • key immer setzen: Ohne key heißt der Lock ClassName.methodName, hier also CoffeeService.orderBeans. Benennst du Klasse oder Methode um, ändert sich auch der Lock.
  • Verpasste Runs werden nicht nachgeholt: Wird ein Run übersprungen, läuft der Job erst beim nächsten Termin wieder.
  • Lange, schreibende Jobs: Hängt eine Instanz kurz (z. B. bei einem langen Garbage-Collection-Lauf), kann sie ihren Lock verlieren und trotzdem noch schreiben. Dafür gibt es Fencing-Tokens, die in der Doku beschrieben sind.

Fazit

Cronjobs in einer App mit mehreren Instanzen waren in NestJS lange ein Fall für Workarounds. Mit @nestjs/locks reicht jetzt ein Decorator und ein gemeinsamer Speicher, den die meisten Projekte ohnehin schon haben. Für mich ist das eines der nützlichsten NestJS-Pakete der letzten Zeit.

Du hast Cronjobs, die in deiner NestJS-App doppelt laufen? Schreib mir.