N8N mise à l'échelle horizontale

Décrivez le problème/l’erreur/la question

Nous recherchons des conseils sur la concurrence du mode file d’attente n8n et la mise à l’échelle horizontale.

Notre charge de travail prévue est d’environ 1 000 utilisateurs simultanés, et nous planifions de mettre à l’échelle nos workers n8n horizontalement.

Actuellement, nous exécutons des workers n8n sur des instances AWS EC2 t3a.medium. Nous n’utilisons pas AWS Fargate.

Notre configuration actuelle est :

  • 1 worker

  • Concurrence : 50

  • CPU : 1 vCPU

  • RAM : 1,5 GB

  • Base de données : PostgreSQL

  • OS : Amazon Linux

  • Mode d’exécution : Mode file d’attente

Nous aimerions comprendre :

  • Quel est la concurrence recommandée par worker pour ce type de charge de travail ?

  • La concurrence 50 est-elle trop élevée pour une instance t3a.medium avec 1 vCPU et 1,5 GB de RAM ?

  • Comment déterminer la concurrence optimale en fonction du CPU et de la mémoire ?

  • Comment déterminer le nombre optimal de workers ?

  • Comment surveiller la profondeur de la file d’attente et l’utiliser comme signal de mise à l’échelle horizontale ?

  • Quelles métriques devrions-nous utiliser pour la mise à l’échelle — profondeur de la file d’attente, CPU, mémoire, latence d’exécution, ou une combinaison ?

  • Existe-t-il une approche recommandée pour la mise à l’échelle automatique des workers EC2 en fonction de la profondeur de la file d’attente ?

  • Quel est le meilleur moyen de tester la charge de cette configuration et de déterminer la concurrence/le nombre de workers optimal ?

Par exemple, si nous augmentions finalement à :

10 × workers t3a.medium

comment devrions-nous déterminer si chaque worker devrait avoir une concurrence de 5, 10, 20, 50, etc. ?

Nous apprécierions toute recommandation ou expérience du monde réel avec n8n Mode file d’attente + PostgreSQL + workers EC2 mis à l’échelle horizontalement.

Quel est le message d’erreur (le cas échéant) ?

Pas de message d’erreur spécifique. Il s’agit principalement d’une question de planification de la capacité et de mise à l’échelle horizontale.

Veuillez partager votre flux de travail

Non applicable — il s’agit d’une question d’infrastructure/de mise à l’échelle des workers plutôt qu’un problème de flux de travail spécifique.

Partagez la sortie retournée par le dernier nœud

Non applicable.

Hé @Yash_Nerkar, en attendant une réponse, voici quelques ressources qui pourraient t’aider :

Ressources suggérées

Automatiquement associées à ta question.

Documentation :

Forum :

@jcuypers, @anon45958619, @hubschrauber - vous avez aidé à résoudre des problèmes similaires auparavant, pouvez-vous y jeter un œil ?

Suggéré automatiquement par le bot communautaire de n8n. C’est un programme pilote - partage tes commentaires ici.

Bonjour @Yash_Nerkar

Vous pouvez commencer par convertir les utilisateurs en nombre qui détermine réellement ceci : les exécutions en vol, c’est-à-dire les exécutions par seconde multipliées par les secondes d’exécution moyennes. 1 000 utilisateurs simultanés générant des webhooks occasionnels pourraient représenter 20 exécutions en vol, ou 500, et chaque réponse ci-dessous s’appuie sur ce nombre, non sur le nombre d’utilisateurs.

  1. Une concurrence de 50 sur 1 vCPU et 1,5 GB est élevée. Un worker est un processus Node avec une boucle d’événement unique, donc 50 ne fonctionne que lorsque les exécutions passent du temps à attendre HTTP ou la base de données. Toute transformation JSON se sérialise sur le cœur unique, et la mémoire est généralement le facteur limitant : les exécutions en vol multipliées par la taille de votre payload p95 doivent tenir dans 1,5 GB. La valeur par défaut est 10 pour une raison, et descendre en dessous de 5 déclenche un avertissement du worker lui-même.
  2. Déterminez-la par mesure, non par formule : chargez un worker à concurrence fixe avec vos vrais workflows, augmentez-la jusqu’à ce que la latence p95 se dégrade ou que la mémoire approche du plafond, et ce plateau est votre nombre par worker. Le nombre de workers est alors les exécutions en vol divisées par ce nombre, plus une marge de manœuvre.
  3. Pour le signal de mise à l’échelle, définissez N8N_METRICS_INCLUDE_QUEUE_METRICS=true sur l’instance principale et scrapez /metrics. n8n_scaling_mode_queue_jobs_waiting est la profondeur de votre file d’attente : une valeur non nulle soutenue signifie que les workers sont saturés, et c’est le déclencheur d’autoscale le plus propre que vous ayez. Poussez-le vers CloudWatch en tant que métrique personnalisée et pilotez l’ASG à partir de celui-ci, en réduisant uniquement lorsqu’il a été zéro pendant un moment. Le CPU et la mémoire sont des alarmes de santé par worker, non le signal de mise à l’échelle.
  4. Un piège EC2 pour le test de charge : t3a est expansible. Une exécution de test sur des crédits CPU frais affiche les performances que l’instance ne peut pas maintenir, puisqu’elle est limitée à la ligne de base une fois que les crédits s’épuisent. Testez suffisamment longtemps pour les épuiser ou utilisez une classe non expansible pour les workers que vous prévoyez d’utiliser intensivement.

Si vous passez à l’échelle de 10 workers, ne définissez pas par défaut une concurrence de 50. Utilisez cette logique :

Merci pour l’explication détaillée. Laissez-moi ajouter un peu de contexte sur notre flux de travail réel.

Chaque exécution n8n implique généralement :

  • Environ 10 appels API OpenAI

  • Plusieurs appels à la base de données

  • Quelques sous-workflows

  • Très peu de traitement gourmand en CPU

Donc le temps d’exécution moyen de ~20 secondes est surtout consacré à attendre les services externes (API OpenAI / base de données) plutôt que de faire du travail intensif en CPU.

Pour cette raison, notre utilisation du CPU est généralement assez faible, et nous envisageons d’utiliser une concurrence plus élevée des workers pour les maintenir occupés pendant que les exécutions attendent les opérations d’E/S.

Notre trafic attendu peut atteindre environ 200–300 utilisateurs/sec.

C’est là que je essaie de comprendre le compromis concurrence/mise en queue.

Par exemple, supposons que nous ayons un worker avec :

Concurrency = 20

et que nous recevions soudainement 100 exécutions.

Ma compréhension est à peu près :

100 exécutions entrantes
        ↓
20 commencent l'exécution
        ↓
80 restent en attente dans la queue Redis
        ↓
Au fur et à mesure que les exécutions se terminent
        ↓
les prochaines exécutions sont lancées

Puisque l’exécution moyenne prend ~20 secondes, n’est-ce pas que la concurrence plus faible augmenterait considérablement le temps d’attente en queue ?

Par exemple, si seules 20 exécutions peuvent s’exécuter en concurrence, le temps de réponse global pour certaines requêtes pourrait devenir beaucoup plus élevé que les 20 secondes d’exécution, car elles passent d’abord du temps à attendre dans la queue.

Donc j’essaie de comprendre comment nous devrions équilibrer :

concurrence du worker vs temps d’attente en queue vs nombre de workers

Par exemple, est-ce que quelque chose comme ceci aurait du sens ?

Option A
10 workers × concurrence 20
= 200 exécutions concurrentes

Option B
10 workers × concurrence 50
= 500 exécutions concurrentes

Étant donné que la majorité de notre temps de workflow est consacrée à l’attente d’OpenAI/DB plutôt qu’au CPU, est-ce qu’une concurrence plus élevée (par ex. 50) est réellement raisonnable, en supposant que la mémoire reste dans des limites sûres ?

Ou existe-t-il un meilleur moyen de déterminer la concurrence optimale expérimentalement ?

Aussi, si notre objectif est de maintenir le temps d’attente en queue bas lors d’une montée en charge soudaine (par exemple, 100 requêtes arrivant rapidement), devrions-nous principalement augmenter la concurrence par worker, augmenter le nombre de workers, ou utiliser une combinaison des deux ?

J’apprécierais particulièrement quelques conseils sur comment vous benchmarkeriez ceci pour un workflow qui est principalement lié aux E/S.

Salut @Yash_Nerkar

Fais les calculs de file d’attente avant de choisir entre A et B : slots nécessaires = débit d’arrivée × durée. À 200 à 300 exécutions par seconde et 20 secondes chacune, c’est 4 000 à 6 000 exécutions concurrentes, donc 200 et 500 slots sont tous les deux insuffisants d’un ordre de grandeur et l’arriéré croît sans limite. Si 200 à 300 par seconde représente des utilisateurs plutôt que des exécutions, mesure d’abord le nombre réel d’exécutions par seconde, tous les autres chiffres en découleront. Ton cas de pic est correct de toute façon : 100 arrivées contre 200 slots libres commencent toutes immédiatement, l’attente en file ne s’ajoute que lorsque débit × durée approche le nombre total de slots.

Pour le travail I/O-bound, une concurrence plus élevée par worker est raisonnable dans deux limites : la mémoire (charge en vol × payload p95 doit tenir) et le rayon d’explosion (un OOM à concurrence 50 tue 50 exécutions). Fixe d’abord le nombre total de slots, puis divise par la capacité mesurée par worker pour obtenir le nombre de workers, et monte en charge sur n8n_scaling_mode_queue_jobs_waiting qui reste non nul. Une chose qui te posera problème à cette échelle : des milliers d’appels OpenAI concurrents atteignent leurs limites de débit avant celles de n8n, et les tentatives de 429 étireront silencieusement tes 20 secondes, donc dimensionne aussi en fonction de ta limite OpenAI.

Pour soutenir 300 requêtes/sec avec un temps d’exécution moyen de 20s, vous avez besoin d’une « Capacité totale » d’environ 6 000 exécutions concurrentes

Sur une t3a.medium (1,5 GB RAM), vous serez probablement limité par la RAM avant d’atteindre 6 000. Supposons que vous puissiez augmenter la concurrence à 50 par worker de manière sûre.

Calcul de mise à l’échelle horizontale :

  • Capacité cible : 6 000
  • Concurrence maximale par worker : 50
  • Workers nécessaires : 6 000 / 50 = 120

Merci, ça a du sens. J’ai exécuté un test de charge sur notre configuration actuelle, et j’ai voulu partager les résultats car ils semblent assez différents du calcul théorique de 6 000 exécutions concurrentes.

Configuration actuelle :

  • 1 × t3a.medium

  • 1 vCPU

  • 1,5 GB de RAM

  • Mode Queue n8n

  • Concurrence des workers : 50

  • PostgreSQL

  • Le workflow a ~10 appels OpenAI + appels DB + sous-workflows

  • Le temps d’exécution moyen est d’environ 20 secondes

Pour le test de charge, nous ne voulons pas déclencher réellement tous les appels OpenAI et les opérations de base de données de manière répétée, car cela consommerait une quantité importante de crédits OpenAI et placerait une charge inutile sur notre DB.

Ainsi, pour le workflow de test de charge, nous avons remplacé ces appels externes par des nœuds Wait qui simulent approximativement le même temps d’attente consacré aux requêtes OpenAI/DB. Cela nous permet de reproduire un modèle d’exécution similaire avec attente I/O sans réellement effectuer ces requêtes externes.

J’ai exécuté un test k6 avec 50 VUs concurrentes, chaque VU faisant une requête.

Résultats :

  • 50 requêtes au total

  • 47 réussies

  • 3 échouées/timeouts → taux d’échec de 6 %

  • Durée HTTP moyenne : 35,48s

  • Médiane : 35,98s

  • p95 : 39,86s

  • Max : 39,88s

  • Durée du workflow n8n : ~35,6s en moyenne

Intéressamment, en regardant les exécutions directement dans l’interface utilisateur n8n, la première exécution a duré environ 21s et la dernière environ 29s.

Ainsi, même avec seulement 50 requêtes concurrentes, nous voyons déjà une latence importante et quelques timeouts.

Cela me rend hésitant à simplement supposer que concurrency=50 signifie qu’un t3a.medium peut fournir en toute sécurité 50 emplacements d’exécution utiles.

Durant notre workflow réel est principalement lié aux I/O (appels OpenAI + DB) plutôt que gourmand en CPU, j’essaie de comprendre ce qui pourrait expliquer ce comportement.

Vos résultats prouvent que ce qui fonctionne en théorie (Concurrence élevée) ne fonctionne pas en pratique sur cette infrastructure spécifique, même pour les charges de travail liées aux E/S.

Vos expériences ont correctement identifié que 1,5 Go de RAM combinés à un parallélisme élevé constituent le goulot d’étranglement, et non le CPU. N’augmentez pas la concurrence sur les instances plus petites ; à la place, faites de la montée en charge horizontale en ajoutant plus de workers avec une concurrence plus faible (par ex., 2 workers × 20 de concurrence).

Non, la requête n’a pas échoué. C’est juste que la réponse a expiré parce que nous avons défini 30 s comme délai d’expiration.

D’accord. Je vais vérifier ça.

Bonjour @Yash_Nerkar
Le post 11 déplace la cible. Le travail a réussi et le client a abandonné à 30s, donc ce que votre charge stress vraiment en premier ce sont les connexions HTTP ouvertes, pas les emplacements worker. En mode queue, le worker exécute pendant que le client reste connecté à l’instance principale ou webhook pendant toute la durée, donc 200 à 300 par seconde à 20 secondes chacun signifie que ce tier garde 4 000 à 6 000 sockets ouverts, et aujourd’hui c’est un processus sur la même machine que le worker.
Un délai d’expiration client de 30 secondes par rapport à une moyenne de 20 secondes échoue aussi sur la tail peu importe combien de workers vous ajoutez, et chacune de ces requêtes fait toujours ses 10 appels OpenAI, donc vous payez pour des réponses que personne ne reçoit.
Arrêtez de maintenir la connexion. Réglez le « Respond to Immediately » du nœud Webhook, qui retourne « Workflow got started » une fois que le job est en queue, puis livrez le résultat via un callback ou un endpoint de statut que le client interroge. Les délais d’expiration des requêtes cessent d’être une question de capacité et la profondeur de queue devient la seule chose sur laquelle vous dimensionnez.
Si le contrat API doit rester synchrone, séparez ce tier : exécutez des processus n8n webhook derrière le load balancer, gardez le processus principal hors du pool, et dimensionnez-les par connexions concurrentes plutôt que par CPU.

Merci, cela rend la question d’architecture beaucoup plus claire.

Nous avons relancé le même test avec le workflow basé sur le nœud Wait pour éviter de consommer des crédits OpenAI ou de surcharger PostgreSQL inutilement.

Avec 50 VUs, les résultats k6 étaient :

  • 50 requêtes
  • 100% HTTP 200
  • 0% d’échecs
  • HTTP p95 : 4,85s
  • Workflow p95 : ~4,97s

Le test sans connexion persistante se comporte donc bien.

Cependant, en examinant les exécutions n8n réelles, le temps d’exécution augmente toujours sous charge — les premières exécutions sont autour de 20s, tandis que les exécutions ultérieures peuvent atteindre ~28s. Je pense donc qu’il y a toujours une certaine contention/mise en file d’attente, même si le workflow simulé est principalement en attente.

Concernant l’architecture, notre plan actuel est de conserver l’instance n8n principale sur une EC2 dédiée et d’avoir uniquement les instances worker dans un groupe Auto Scaling, avec Redis gérant la file d’attente. Quelque chose comme :


                  Load Balancer
                       |
                       v
                 Main EC2
                n8n main/webhook
                       |
                       v
                     Redis
                       |
          +------------+------------+
          |            |            |
          v            v            v
       Worker 1     Worker 2     Worker 3
          \            |            /
           +-----------+-----------+
                       |
                       v
                  PostgreSQL

Cette séparation a-t-elle un sens ?

Mon raisonnement est que l’EC2 principal doit rester relativement stable, tandis que les workers sont ceux que nous évoluons horizontalement en fonction de la profondeur de la file d’attente/de la charge de travail.

Nous pouvons également modifier le webhook pour répondre immédiatement et traiter le résultat du workflow via un callback de notre côté, afin de ne pas garder la connexion HTTP ouverte pendant les 20-28 secondes entières.

Une chose sur laquelle je suis encore assez confus, c’est la véritable stratégie de mise à l’échelle des workers :

Devrions-nous continuer à mettre à l’échelle les workers principalement en fonction de la profondeur de la file d’attente ? Et quel serait un nombre raisonnable de concurrence par worker — quelque chose comme 20 exécutions par worker, ou devrions-nous nous rapprocher de 50–100 ?

La documentation de n8n mentionne environ 100–200 Mo de mémoire par exécution concurrente, mais je ne sais pas comment traduire cela en la bonne taille EC2 et le bon paramètre de concurrence pour notre charge de travail.

Par exemple, si je réduis la concurrence à 20 par worker, la file d’attente Redis ne croîtrait-elle pas beaucoup plus rapidement, ce qui signifierait que nous devrions continuer à ajouter plus de serveurs ? En revanche, si nous maintenons la concurrence autour de 50–100 et utilisons des instances avec plus de RAM/CPU, est-ce une meilleure approche ?

Je try donc de comprendre le juste équilibre entre :

  • Concurrence par worker
  • RAM/CPU par worker
  • Nombre de workers
  • Profondeur de la file d’attente Redis / seuil de mise à l’échelle

Pardon pour mes questions bêtes — j’apprends encore comment ASG et la mise à l’échelle horizontale fonctionnent, donc j’essaie de comprendre la bonne façon de penser à cela plutôt que de simplement ajouter plus d’EC2s au problème.

Votre p95 de 4,85s représente maintenant le temps d’acceptation et de mise en file d’attente, non le temps d’exécution du workflow. Les exécutions prennent toujours 20 à 28 secondes, donc la capacité des workers n’a pas augmenté entre les deux tests, vous avez arrêté de la mesurer avec k6. La montée de 20 à 28 secondes est votre signal de capacité, et le nombre HTTP vous indique maintenant seulement que l’instance principale suit le rythme.
Sous cette montée se trouve quelque chose que vous n’avez pas encore touché. DB_POSTGRESDB_POOL_SIZE est par défaut à 2, donc chaque worker ouvre deux connexions Postgres, quelle que soit la concurrence que vous définissez. Avec une concurrence de 50, ces cinquante exécutions poussent leurs écritures de données d’exécution à travers deux connexions et les suivantes attendent. Augmentez-la avec la concurrence et surveillez max_connections de Postgres, puisque votre total est le nombre de workers multiplié par la taille du pool :

DB_POSTGRESDB_POOL_SIZE=12

Oui. Le problème est qu’une seule instance principale est un point de défaillance unique, et en exécuter une deuxième nécessite le multi-instance principal, ce qui est réservé à l’Enterprise.

Non. La profondeur de la file d’attente suit le nombre total de slots, qui est le nombre de workers multiplié par la concurrence, donc 10 workers à 20 et 4 workers à 50 donnent tous les deux 200 slots et la même file d’attente. La concurrence décide seulement comment ces slots sont empaquetés sur les serveurs.

Celle qu’un worker peut supporter une fois que son pool correspond. Achetez ensuite le nombre de slots avec le nombre de workers, car empaqueter les slots de manière plus dense augmente le rayon d’explosion, un worker qui meurt avec une concurrence de 50 emporte 50 exécutions avec lui.

n8n ne publie pas ce chiffre, et aucune figure générale n’existe, car cela dépend de la taille de votre charge utile par exécution en vol. Mesurez-la : exécutez un worker à une concurrence fixe et surveillez RSS.

Concernant le temps d’exécution de 20 à 28 secondes, devrions-nous d’abord corriger la taille du pool PostgreSQL et faire un benchmark à nouveau avant de prendre toute décision concernant la concurrence des workers ?

Oui. Tout chiffre par worker que vous mesurez par rapport à un pool de 2 est une mesure du pool, pas du worker, et à la fois votre nombre de workers et votre seuil d’autoscaling sont dérivés de ce chiffre, donc l’erreur se propage dans chaque décision ultérieure.
Donnez à la réexécution une charge utile réaliste. Le pool porte les données d’exécution, donc un workflow de nœuds Wait écrit presque rien et ne montrera pas l’effet de la modification que vous venez de faire. Un nœud Code émettant du JSON de la taille d’une réponse OpenAI typique remet le volume d’écriture en place sans dépenser de crédits.
Si c’est toujours lent après cela, le même goulot d’étranglement a un deuxième levier. EXECUTIONS_DATA_SAVE_ON_SUCCESS est défini par défaut sur all, donc chaque exécution réussie écrit ses données complètes à travers ce pool :

EXECUTIONS_DATA_SAVE_ON_SUCCESS=none

Cela réduit ce qui traverse le pool au lieu de l’élargir, et les défaillances sont toujours sauvegardées à des fins de débogage.

L’appel EXECUTIONS_DATA_SAVE_ON_SUCCESS=none d’Anshul est approprié pour le problème de pression du pool, et la mise en garde selon laquelle les échecs sont toujours enregistrés te couvre pour les erreurs critiques. L’écart qu’il ouvre est plus subtil qu’un échec : à ton volume, les 429 d’OpenAI et les tentatives (Lopez l’a signalé dans le post 6) ne se matérialisent pas toujours comme une exécution échouée, parfois une tentative ou un chemin continuer-en-cas-d’échec renvoie une exécution techniquement réussie avec un complétage tronqué ou vide. Une fois que l’enregistrement des données de succès s’arrête, cette exécution ne laisse aucune trace pour la détecter après coup, tu ne remarquerais quelque chose qu’en aval lorsque quelque chose construit sur ce résultat se casse plus tard.

Assez bon marché pour ajouter sans modifier tes calculs de pool : une exécution synthétique planifiée, entrée fixe, résultat correct connu, à travers le même pipeline sur un calendrier, alertant en cas de discordance ou d’exécution manquante. Cela ne remplace pas la journalisation des données de succès, c’est une vérification qui survit à sa désactivation, car elle ne dépend pas du tout de l’historique d’exécution.

Je serais ravi d’entrer dans plus de détails sur la façon dont nous structurons cela si utile.