n8n im Queue-Mode — Main, Worker und Redis

n8n im Queue-Mode — Main, Worker und Redis

Artikel 11 · Serie: Einstieg in n8n

Bis hierher läuft die Pipeline in einem einzigen Prozess. Ein n8n-Container hinter Caddy nimmt das Ticket an, klassifiziert es über zwei AI-Backends, reichert es aus dem SAP-Backend an und routet es, alles im Hauptprozess. Für eine Demo reicht das. Für den produktiven Betrieb fehlt der Schritt, der Annahme und Ausführung trennt: der Queue-Mode. Dieser Artikel baut ihn ein und klärt zugleich, ab wann er sich lohnt und was er ausdrücklich nicht leistet.

Der Code zu diesem Artikel liegt auf Codeberg, Tag v0.11: codeberg.org/rotecodefraktion/n8n-einstieg.

Der Queue-Mode trennt Annahme von Ausführung

Im Default-Mode (regular) führt der Hauptprozess jede Execution selbst aus. Im Queue-Mode bleibt der Main-Prozess für die Annahme zuständig, schreibt die Execution aber in eine Redis-Queue, aus der ein oder mehrere Worker sie ziehen und ausführen. Der Main wird damit zum Dispatcher, die Worker werden zur eigentlichen Rechenkapazität.

Queue-Mode-Topologie: Client über Caddy an n8n Main, Main reiht in Redis ein, mehrere Worker ziehen aus der Queue und führen aus, Main und Worker schreiben in dieselbe PostgreSQL

Drei Prozesse teilen sich dabei dieselbe Postgres-Datenbank und denselben N8N_ENCRYPTION_KEY. Der Encryption-Key ist nicht optional gemeinsam: Ohne identischen Schlüssel kann der Worker die gespeicherten Credentials nicht entschlüsseln und jede Execution, die ein Credential braucht, scheitert.

Die Umstellung ist ein drittes Compose-Override, gestapelt auf das Basis-Setup und die Observability-Erweiterung aus Artikel 7. Es ergänzt einen redis-Service, einen n8n-worker-Service und schaltet den Main in den Queue-Mode:

services:
  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]

  n8n:
    environment:
      EXECUTIONS_MODE: queue
      QUEUE_BULL_REDIS_HOST: redis
      N8N_METRICS_INCLUDE_QUEUE_METRICS: "true"

  n8n-worker:
    build: .
    command: worker --concurrency=5
    environment:
      EXECUTIONS_MODE: queue
      QUEUE_BULL_REDIS_HOST: redis
      # DB-Env + identischer N8N_ENCRYPTION_KEY wie der Main

Hochgezogen wird der Stack, indem das Queue-Override an die bestehenden Dateien gehängt wird:

docker compose \
  -f docker-compose.yml \
  -f docker-compose.observability.yml \
  -f docker-compose.queue.yml \
  up -d

Dass der Worker bereitsteht, zeigt sein Log direkt nach dem Start:

n8n worker is now ready
 * Version: 2.21.4
 * Concurrency: 5

Der Smoke-Test verprüft das: Der Main nimmt den Webhook an, der Worker führt aus. Ein Ticket an den Eingang aus Artikel 10, dann ein Blick in beide Logs:

docker-n8n-1        Enqueued execution 267 (job 1)
docker-n8n-worker-1 Worker started execution 267 (job 1)
docker-n8n-worker-1 Worker finished execution 267 (job 1)

Der Main reiht ein, der Worker zieht und führt aus. Die Antwort kam mit HTTP 200 zurück, inklusive der SAP-Anreicherung aus Artikel 10, die jetzt im Worker-Prozess lief.

Ab wann lohnt sich der Queue-Mode?

Der Queue-Mode ist kein kostenloses Upgrade. Er bringt Redis als zusätzliche Abhängigkeit, einen zweiten Prozesstyp zum Verwalten und ein Debugging, das sich über Main- und Worker-Logs verteilt. Für die meisten selbstgehosteten Setups ist der regular-Mode die richtige Wahl. Der Wechsel lohnt sich, wenn einer dieser Punkte zutrifft:

  • Parallele Executions sättigen regelmäßig den Hauptprozess, die Oberfläche wird während der Läufe träge.
  • Einzelne Executions laufen lang, etwa große HTTP-Aufrufe oder schwere Code-Nodes, sodass ein Lastspitze sich sonst hinter dem Editor staut.
  • Die Ausführungskapazität soll unabhängig vom Main skalieren, oder ein Worker-Neustart soll den Editor nicht mitreißen.

Darunter bleibt der regular-Mode. Komplexität, die man nicht braucht, ist ein Maintenance-, Fehler- und Kostenfaktor, kein Feature. Er ist eben nicht, wie in vielen Anleitungen zu lesen, immer der überlegene Weg.

Worker skalieren horizontal

Der eigentliche Gewinn ist die Skalierung. Worker sind eigenständige Prozesse, die alle aus derselben Queue ziehen. Mehr Durchsatz heißt mehr Worker:

docker compose -f docker-compose.yml \
  -f docker-compose.observability.yml \
  -f docker-compose.queue.yml \
  up -d --scale n8n-worker=3

Zwei Stellschrauben bestimmen die Kapazität: die Anzahl der Worker und die --concurrency pro Worker, also wie viele Executions ein Worker parallel hält. Welche Einstellung passt, sagen die Queue-Metriken. Mit N8N_METRICS_INCLUDE_QUEUE_METRICS=true liefert der /metrics-Endpunkt des Main die relevanten Werte:

n8n_scaling_mode_queue_jobs_waiting    0
n8n_scaling_mode_queue_jobs_active     0
n8n_scaling_mode_queue_jobs_completed  1
n8n_scaling_mode_queue_jobs_failed     0

jobs_waiting ist der Rückstau. Bleibt er unter normaler Last über null, ist das das Signal, Worker hinzuzunehmen oder die Concurrency zu erhöhen. jobs_active nahe Worker × Concurrency heißt, die Kapazität ist ausgereizt. Aus diesen beiden Werten ergeben sich drei Zonen, an denen sich die Skalierungsentscheidung festmacht:

Skalierungs-Schwellen für den Queue-Mode: drei Zonen. Gesund bei jobs_waiting gleich null, nichts tun. Rückstau bei dauerhaft positivem jobs_waiting, Worker hinzufügen oder Concurrency erhöhen. Ausgereizt bei jobs_active nahe Kapazität und wachsendem Backlog, jetzt skalieren

Beide Werte stehen im mitgelieferten Grafana-Dashboard neben den Metriken aus Artikel 7: ein Stat „Jobs Waiting" mit Ampel-Schwellen und zwei Zeitreihen für Backlog und aktive Jobs.

Skalierung ist kein Retry

Hier ist eine Klarstellung nötig, weil sie häufig falsch erzählt wird. Der Queue-Mode skaliert die Ausführung, er retried nicht. Frühere n8n-Versionen hatten über die Bull-Bibliothek einen automatischen Retry für hängengebliebene Jobs. Mit n8n 2.0 ist dieser Mechanismus entfernt worden, die zugehörige Variable QUEUE_WORKER_MAX_STALLED_COUNT existiert nicht mehr. Die n8n-Dokumentation begründet das damit, dass das Feature oft für Verwirrung sorgte und nicht zuverlässig funktionierte.

Für den Betrieb heißt das: Eine fehlgeschlagene Execution wird im Queue-Mode nicht automatisch wiederholt. Resilienz gegen Fehler bleibt bei den Schichten aus Artikel 7. Der Node-retryOnFail mit einer Wartezeit zwischen den Versuchen fängt transiente Fehler ab, der globale Error-Workflow alarmiert und protokolliert. Wo es geht, sollten nach außen sichtbare Seiteneffekte idempotent sein, damit ein erneuter Lauf nach einem Fehler nichts doppelt anlegt. Der Queue-Mode löst ein Skalierungsproblem aber kein Zuverlässigkeitsproblem.

Task Runner laufen intern, in Produktion getrennt

Beim Start meldet der Worker eine Warnung, die einen letzten Produktionsaspekt aufmacht:

Failed to start Python task runner in internal mode. because Python 3 is missing

n8n führt den Code aus Code-Nodes nicht direkt im Hauptprozess aus, sondern in einem Task Runner. Den gibt es in zwei Modi. Im internen Modus, dem Default und dem, was diese Demo nutzt, startet n8n den Runner als Child-Prozess mit derselben uid und gid. Das ist einfach und braucht keine Konfiguration, isoliert den Runner aber nicht vom n8n-Prozess. Im externen Modus läuft der Runner in einem eigenen Container (n8nio/runners), der sich über einen Broker und einen gemeinsamen N8N_RUNNERS_AUTH_TOKEN mit n8n verbindet und in der Version an n8n gepinnt ist. Die n8n-Dokumentation empfiehlt für Produktion den externen Modus, weil er n8n und Runner sauber voneinander trennt.

Task-Runner-Modi: links der interne Modus mit dem Runner als Child-Prozess im n8n-Prozess (Demo), rechts der externe Modus mit n8nio/runners in einem eigenen Container, über Broker und Auth-Token an n8n gekoppelt (Produktion)

Die Python-Warnung ist in diesem Setup harmlos. Der interne Modus versucht, auch einen Python-Runner zu starten, den das n8n-Image nicht mitbringt. Die Pipeline nutzt nur JS-Code-Nodes, der JS-Runner registriert sich direkt danach. Der externe Modus ist hier bewusst nicht eingebaut, weil er für ein Entwicklungs- oder Demosystem Overkill ist. Die Isolation, die er bringt, zahlt sich erst aus, wenn fremder oder nicht vertrauenswürdiger Workflow-Code läuft. Auf einem Single-Dev-Localhost, auf dem ich jeden Node selbst schreibe, steht dem ein zusätzlicher Container, ein zu verwaltendes Token und das Versions-Pinning gegenüber, ohne realen Gewinn.

Es gibt allerdings einen zweiten, härteren Grund für den externen Modus. n8n 2.0 hat den alten, in-process laufenden Python-Code-Node (auf Pyodide-Basis) entfernt und durch eine Task-Runner-Implementierung mit nativem Python ersetzt. Laut n8n-Dokumentation funktionieren Python-Code-Nodes seitdem nur noch mit Task Runnern im externen Modus. Wer in 2.x Python im Code-Node nutzen will, kommt also nicht am externen Modus vorbei, samt N8N_NATIVE_PYTHON_RUNNER und, für Drittbibliotheken, einer Allowlist im Runner-Image. Für reine JS-Pipelines wie dieser bleibt der interne Modus die richtige Wahl. In einer Produktion mit mehreren Autoren, Python-Bedarf oder strengeren Sicherheitsvorgaben kippt die Abwägung, und der externe Modus wird zur richtigen Wahl.

Für genau diesen Fall liegt im Repo ein optionales Override, docker-compose.external-runners.yml, das einen n8nio/runners-Container mit nativem Python neben n8n stellt. Beim Bauen fiel eine Eigenheit auf, die man kennen muss: Natives Python verweigert standardmäßig jeden Import, auch aus der Standardbibliothek. Schon ein import sys scheitert mit „Security violations detected", bis man die Module in der Launcher-Konfiguration über N8N_RUNNERS_STDLIB_ALLOW freigibt. Mit dieser Freigabe liefert ein Python-Code-Node dann sauber seine Antwort, in meinem Test CPython 3.13 über den externen Runner. Die Details stehen im Repo unter docs/external-runners.md.

Produktions-Checkliste

Eine n8n-Instanz, die echte Tickets verarbeitet, braucht mehr als einen laufenden Container. Die wichtigsten Punkte, geordnet nach dem, was beim Versäumnis am meisten schmerzt:

  • Backup. Postgres hält alle Workflows, Credentials und Executions. Regelmäßig sichern und den Restore testen, nicht nur das Backup. Ein ungetestetes Backup ist eine Vermutung.
  • Encryption-Key. N8N_ENCRYPTION_KEY außerhalb des Repos in einem Secrets-Vault ablegen. Im Queue-Mode müssen Main und alle Worker denselben Schlüssel tragen. Eine Rotation bedeutet, alle Credentials neu zu erfassen, also eine geplante Migration, keine Routine.
  • HTTPS. TLS vor n8n terminieren, in dieser Reihe über Caddy. WEBHOOK_URL auf die öffentliche HTTPS-URL setzen, https://localhost ist nur ein lokaler Wert. Den n8n-Port nicht auf den Host veröffentlichen.
  • Zugriffsschutz. Jeder von außen erreichbare Webhook braucht Auth, der Eingang nutzt Header-Auth. Editor und Grafana hinter Authentifizierung halten, beides sind Admin-Oberflächen.
  • Updates. Die n8n-Image-Version pinnen, nicht latest verfolgen. Vor einem Update die Release-Notes und die 2.x-Breaking-Changes lesen, Postgres sichern, Main und Worker gemeinsam auf dieselbe Version heben.
  • Observability. Den Stack aus Artikel 7 laufen lassen und auf das Error-Workflow-Signal sowie auf einen wachsenden Queue-Rückstau alarmieren.

Diese Liste lässt drei Punkte aus, die in diesem Artikel schon eigene Abschnitte haben: Skalierung, Resilienz gegen Fehler und den Task-Runner-Modus. Die ausführliche Fassung im Repo unter docs/production-checklist.md führt sie als reguläre Checklistenpunkte mit auf, sodass dort nichts fehlt.

Übergang zu Artikel 12

Die Pipeline ist jetzt produktionsnah: sie nimmt resilient an, klassifiziert über zwei Backends, reichert aus SAP an, skaliert über Worker und ist hinter HTTPS und Auth abgesichert. Über elf Artikel ist daraus ein vollständiges System geworden. Damit stellt sich die Frage, die am Anfang bewusst offen blieb: Wo ist n8n das falsche Werkzeug? Artikel 12 nimmt sich die Grenzen vor, an konkreten Fällen, in denen Code, eine Message-Queue oder ein dediziertes Integrationsframework die bessere Wahl sind.

Queue-Mode lokal starten

Der Queue-Mode setzt auf das Stack aus Artikel 7 auf. Aus dem docker/-Verzeichnis das Queue-Override an die bestehenden Dateien hängen:

docker compose \
  -f docker-compose.yml \
  -f docker-compose.observability.yml \
  -f docker-compose.queue.yml \
  up -d

Redis, ein Worker und der auf Queue-Mode umgestellte Main starten. Wer keine Last erzeugt, sieht die Queue-Metriken auf null stehen, das ist korrekt. Für den Smoke-Test ein Ticket an https://localhost/webhook/ticket-ingest schicken (Header-Auth wie in Artikel 5) und in docker logs docker-n8n-worker-1 die Worker started/finished execution-Zeilen prüfen. Details in docs/queue-mode.md.