Business & Strategie - Technologie & Innovation

Kubernetes ist fuer Solo-Freelancer die falsche Wahl

Ein Hintergrundjob klingt nach der sauberen Lösung, sobald ein Auftrag nach dem HTTP-Request weiterlaufen soll. Für Solo-Freelancer ist der übliche Reflex – Redis, Queue-Bibliothek und dauerhaft laufender Worker – aber oft die falsche Wahl. Nicht weil Queues schlecht wären, sondern weil ein zweiter Dienst Probleme erzeugt, die bei wenigen Aufträgen schwerer wiegen als sein Geschwindigkeitsvorteil. Mein Default wäre eine Job-Tabelle in der vorhandenen Datenbank.

Redis als Standard-Queue vergrößert zuerst die Fehlerfläche

Stell dir eine Anwendung vor, die nach einer Bestellung Daten an einen Buchhaltungsdienst sendet. Der HTTP-Request soll nicht auf dessen Antwort warten, weil ein langsamer Fremddienst sonst auch den Checkout verzögert. Das ist ein guter Grund für asynchrone Verarbeitung. Es ist noch kein guter Grund für Redis 7, BullMQ 5 und einen zusätzlichen Node.js-20-Prozess: Diese Kombination braucht eine eigene Verbindung, Zugangsdaten, Deployments und eine Entscheidung darüber, was bei Ausfällen passiert.

Für einen Freelancer ohne Infrastruktur-Team zählt dabei nicht nur die monatliche Rechnung. Ein Worker muss nach einem Absturz wieder starten, seine Konfiguration muss zum Webprozess passen, und Fehler müssen irgendwo sichtbar werden. Redis kann Jobs zuverlässig aufnehmen, aber die Geschäftsaktion und das Einreihen des Jobs liegen dann in unterschiedlichen Systemen. Schreibt die Anwendung erst die Bestellung nach PostgreSQL und fällt vor dem Schreiben nach Redis aus, existiert die Bestellung ohne Versandauftrag. Schreibt sie in umgekehrter Reihenfolge, kann ein Worker Daten suchen, deren Datenbanktransaktion noch zurückgerollt wird.

Dein erster Prozess kostet mehr Wartung als dein Backend lenkt den Blick auf den zusätzlichen Prozess; ich würde noch früher ansetzen und die zusätzliche Datenhaltung vermeiden, weil gerade die Lücke zwischen Bestellung und Job schwer nachträglich zu reparieren ist. Ich würde nicht vorsorglich einen Redis-Worker betreiben, nur weil „Hintergrundjob“ auf einer Architekturzeichnung steht: Ohne konkrete Last- oder Fristanforderung kauft mir das vor allem einen weiteren Ausfallpfad.

Die Gegenposition ist nachvollziehbar. BullMQ liefert unter anderem Retry- und Parallelisierungsmechanismen, die man nicht nebenbei nachbauen sollte. Diese Vorteile zahlen sich aus, wenn viele Jobs gleichzeitig laufen oder unterschiedliche Teams sie betreiben. Bei einer kleinen Anwendung mit einer bereits bezahlten PostgreSQL-Instanz muss der Vorteil jedoch gegen Betrieb und Fehlersuche gerechnet werden, nicht gegen eine idealisierte Queue ohne Wartung.

Eine Job-Tabelle kann den entscheidenden Auftrag atomar festhalten

Für den Buchhaltungsversand würde ich Bestellung und Job in derselben PostgreSQL-Transaktion schreiben. Entweder werden beide Änderungen bestätigt oder keine. Eine eindeutige Job-ID wie invoice:42 verhindert dabei, dass derselbe Anwendungsbefehl versehentlich zwei offene Aufträge erzeugt. Das ist enger und nützlicher als das Versprechen „genau einmal ausgeführt“: Ein externer HTTP-Dienst kann antworten, während die eigene Anwendung die Antwort wegen eines Verbindungsabbruchs nicht mehr erhält.

Das folgende SQL läuft in PostgreSQL 16. Es legt einen Job an und reserviert bis zu 10 fällige Jobs für einen Abruf. Die zehn sind ein einstellbarer Batchwert, keine allgemeingültige Kapazitätsgrenze. FOR UPDATE SKIP LOCKED lässt gleichzeitige Abrufe bereits gesperrte Zeilen überspringen; das Lease macht liegen gebliebene Jobs nach einem Absturz erneut abrufbar.

CREATE TABLE IF NOT EXISTS jobs (
  id text PRIMARY KEY, payload jsonb NOT NULL,
  lease_until timestamptz, done_at timestamptz
);
INSERT INTO jobs(id, payload) VALUES ('invoice:42', '{"order_id":42}')
ON CONFLICT (id) DO NOTHING;
WITH picked AS (
  SELECT id FROM jobs WHERE done_at IS NULL
  AND (lease_until IS NULL OR lease_until < now())
  ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 10
)
UPDATE jobs SET lease_until = now() + interval '5 minutes'
WHERE id IN (SELECT id FROM picked) RETURNING id, payload;

Die 5 Minuten sind eine Lease-Dauer zum Anpassen: Sie muss zur Laufzeit und zum Timeout des echten Versands passen. Nach erfolgreicher Antwort setzt die Anwendung done_at; nach einem Fehler bleibt der Job für einen späteren Versuch stehen. In einer produktiven Tabelle gehören außerdem Versuchszähler, letzter Fehler und ein Zeitpunkt für den nächsten Versuch dazu, weil ein dauerhaft abgelehnter Datensatz sonst bei jedem Abruf erneut Aufmerksamkeit verbraucht. Das Beispiel zeigt bewusst nur Reservierung und Wiederauffindbarkeit, keinen fertigen Buchhaltungsversand.

Die Transaktion schützt auch nur die Arbeit innerhalb der eigenen Datenbank. Beim Aufruf des Buchhaltungsdienstes verwende ich einen stabilen Idempotency-Key, sofern dessen HTTP-API ihn unterstützt. Unterstützt sie ihn nicht, brauche ich vor einem erneuten Senden eine Abfrage nach der externen Vorgangsnummer oder einen manuellen Klärungsweg. Ein Primärschlüssel in PostgreSQL verhindert schließlich keine doppelte Rechnung in einem fremden System.

Für eine lokale Einprozess-Anwendung könnte SQLite 3 mit Write-Ahead Logging ebenfalls genügen, weil es keinen Datenbankserver benötigt. Ich würde SQLite hier aber nicht als austauschbaren Ersatz für PostgreSQL behandeln: SKIP LOCKED und die gezeigte Reservierung sind PostgreSQL-spezifisch, und mehrere getrennte Hosting-Instanzen teilen nicht automatisch dieselbe SQLite-Datei.

Ein geplanter Abruf ist für tolerante Fristen besser als ein dauerhafter Worker

Die Job-Tabelle erledigt noch keine Arbeit. Jemand muss sie abrufen. Wenn die Buchhaltungsdaten innerhalb einiger Minuten statt innerhalb einiger Sekunden ankommen dürfen, würde ich einen bereits verfügbaren Scheduler einen geschützten HTTP-Endpunkt aufrufen lassen. Der Endpunkt reserviert einen kleinen Batch, verarbeitet ihn bis zu seinem Laufzeitlimit und endet. So gibt es außerhalb der Aufrufe keinen eigenen Prozess, dessen Neustart und Speicherverbrauch ich betreuen muss.

Eine Frist von 15 Minuten wäre hier eine fachliche Vorgabe zum Abstimmen, keine Eigenschaft von PostgreSQL. Als Scheduler kann zum Beispiel GitHub Actions mit einem schedule-Cron dienen; GitHub dokumentiert als kleinsten Abstand 5 Minuten, garantiert damit aber keinen pünktlichen Start. Für zeitkritische Aufträge wäre das folglich keine verlässliche Uhr. Ein vom Hostinganbieter angebotener Scheduler kann praktischer sein, doch auch dessen Verzögerungen und Ausfallverhalten müssen zur vereinbarten Frist passen.

Ich würde den Endpunkt nicht öffentlich ohne Schutz anbieten, weil sonst beliebige Aufrufe Datenbankarbeit und externe API-Aufrufe auslösen könnten. Ein Secret im Authorization-Header, TLS und ein begrenztes Laufzeitbudget sind ein kleinerer Betriebsaufwand als ein weiterer Dauerprozess, aber kein Ersatz für eine Prüfung der Zugriffsrechte. HTTP 202 Accepted passt zur ursprünglichen Bestellung nur dann, wenn die Oberfläche klar zwischen „Auftrag angenommen“ und „an Buchhaltung übertragen“ unterscheidet; andernfalls wird aus einer technischen Entkopplung eine irreführende Erfolgsmeldung.

Du bist Backend Dev und jetzt Oncall Der versteckte Wartungsballast beschreibt den Bereitschaftsaufwand treffend; ich ziehe daraus nicht die Folgerung, jede Hintergrundarbeit zu vermeiden, weil auch ein synchroner Fremdaufruf nachts scheitern kann. Ich will vielmehr eine Fehlerstelle, die ohne sofortige Reaktion übersteht: Ein liegen gebliebener Job bleibt sichtbar und wird später erneut versucht.

Dafür reicht „Cron läuft“ als Kontrolle nicht. Ich würde das Alter des ältesten unerledigten Jobs und die Zahl fehlgeschlagener Versuche erfassen. Prometheus kann solche Metriken sammeln; OpenTelemetry kann die Spur vom Auftrag bis zum externen HTTP-Aufruf verbinden. Beides würde ich nur einführen, wenn die bestehende Hostingumgebung es schon aufnimmt. Andernfalls liefern eine einfache Admin-Abfrage und eine Benachrichtigung bei überschrittener Frist für eine einzelne Anwendung schneller verwertbare Hinweise.

BullMQ gewinnt erst, wenn eine konkrete Grenze der Datenbanklösung erreicht ist

Die Entscheidung lautet nicht „professionelle Queue oder Bastellösung“, sondern PostgreSQL-Job-Tabelle gegen Redis mit BullMQ. Die Job-Tabelle gewinnt bei überschaubarem Volumen, tolerierbaren Minutenfristen und bereits vorhandener Datenbank, weil Auftrag und Geschäftsdaten gemeinsam gespeichert werden. Ihr Preis sind selbst geschriebene Regeln für Leases, Wiederholungen, Fehleransicht und Aufräumen. BullMQ gewinnt, wenn viele parallele Worker, differenzierte Wiederholungspläne oder getrennte Job-Erzeuger tatsächlich gebraucht werden. Sein Preis sind Redis-Betrieb oder ein Managed-Tarif, ein laufender Worker und die weiterhin nötige Abstimmung mit der Geschäftsdatenbank.

Ich würde diesen Wechsel an Messungen knüpfen. Angenommen, die über 7 Tage gemessene Wartezeit bis zum Start liegt am 95. Perzentil bei 4 Sekunden, während der Auftrag laut Vertrag binnen Minuten starten darf: Dann rechtfertigt Startlatenz noch keinen neuen Dienst. Steigt das gemessene Alter offener Jobs dagegen regelmäßig über die zugesagte Frist, ist zuerst zu prüfen, ob Scheduler, externer Dienst oder Verarbeitungskapazität bremst. Redis hilft nicht gegen eine fremde API, die jeden Aufruf drosselt.

Auch die Datenbank hat Kosten. Häufige Abfragen, wachsende Job-Tabellen und zu lange Transaktionen können die eigentliche Anwendung beeinträchtigen. Deshalb gehören ein Index für fällige Jobs, begrenzte Batches und eine Aufbewahrungsregel für erledigte Einträge zur Lösung, sobald die Tabelle wächst. Eine Grenze wie „ab tausend Jobs immer Redis“ würde ich nicht übernehmen, weil tausend kurze HTTP-Aufrufe und tausend rechenintensive PDF-Erzeugungen völlig verschiedene Last erzeugen.

Amazon SQS ist eine weitere sinnvolle Option, wenn ein verwalteter Queue-Dienst bereits Teil des Projekts ist. Er nimmt dir den Redis-Server ab, aber nicht den Consumer, die Idempotenz oder die Klärung fehlgeschlagener Geschäftsvorgänge. Für einen Solo-Freelancer ist „managed“ deshalb kein Synonym für „wartungsfrei“; es verschiebt einen Teil der Verantwortung zum Anbieter und lässt die anwendungsspezifischen Entscheidungen bei dir.

Der erste Schritt ist ein Auftrag mit einer echten Frist

Nimm einen bestehenden Vorgang, der heute im HTTP-Request auf einen Fremddienst wartet. Schreibe seine späteste zulässige Abschlusszeit auf, prüfe, ob deine Datenbank den Auftrag in derselben Transaktion festhalten kann, und protokolliere eine Woche lang Start, Abschluss und Fehler. Erst wenn diese Werte eine konkrete Grenze zeigen, entscheide über Scheduler oder Queue. Bis dahin ist der kleinste zuverlässige Job der bessere Standard.