SSD Nodes Learn 🎉 VPS dès $5.50/mois
Guides Matt ConnorPar Matt Connor

NATS, RabbitMQ ou Kafka sur un seul VPS ?

Comparez les garanties de livraison, le coût mémoire et disque, les redémarrages et le suivi des backlogs. Découvrez quand Postgres suffit sur un seul serveur.

La réponse courte pour un seul serveur

Une message queue sur un seul VPS est un choix qui concerne les garanties de livraison, pas la vitesse. Sur une machine unique, le broker est rarement le goulot d’étranglement, car votre code applicatif, votre base de données et votre disque unique atteignent leurs limites avant lui. Choisissez l’outil dont le comportement en cas de panne vous convient, puis mesurez la machine dont vous disposez réellement.

Voici quatre options, dans l’ordre où la plupart des lecteurs devraient les envisager.

  • Utilisez la base de données que vous exploitez déjà. Postgres avec SELECT ... FOR UPDATE SKIP LOCKED constitue une job queue fonctionnelle et n’ajoute aucun nouveau processus à monitorer.
  • Utilisez RabbitMQ lorsque chaque message représente une unité de travail qui doit être acquittée, réessayée un nombre limité de fois, puis placée quelque part où un humain peut l’examiner.
  • Utilisez NATS lorsque les messages sont des événements auxquels plusieurs composants de votre système réagissent. Activez JetStream pour les événements qui doivent survivre à un redémarrage.
  • Utilisez Kafka lorsqu’un outil en aval ne parle que le protocole Kafka. Sur un seul serveur, c’est presque la seule raison qui le justifie encore.

La suite de ce guide explique le raisonnement : le coût de chaque option en mémoire et sur disque pour un petit VPS, son comportement lorsque la machine redémarre, ainsi que la commande exacte qui permet d’afficher un backlog avant que vos utilisateurs ne le ressentent.

Ce que signifie réellement une garantie de livraison

Au plus une fois signifie que le broker remet le message, puis l’oublie. Si aucun consumer n’est connecté ou si un consumer s’arrête au milieu du traitement, le message est perdu et rien ne le signale.

Au moins une fois signifie que le consumer envoie un accusé de réception (un ack) une fois le traitement terminé avec succès. Tant que cet ack n’est pas arrivé, le broker conserve le message et le remettra. C’est pourquoi vos handlers doivent être idempotents : traiter deux fois le même message ne doit pas débiter la carte deux fois. Une garantie de livraison exactement une fois, de bout en bout, n’est pas fournie par un broker. Elle repose sur une clé unique dans votre propre base de données.

Le replay est une propriété distincte. Une queue supprime un message une fois qu’il a été acquitté. Un log le conserve pendant une période de rétention. Un nouveau consumer peut donc démarrer au début et lire tout l’historique. Kafka et NATS JetStream sont des logs. RabbitMQ est une queue. Cette différence influence davantage les architectures que le débit.

Le dead lettering désigne le traitement appliqué à un message qui échoue constamment. Sans ce mécanisme, un message poison boucle indéfiniment. La boucle ressemble alors à un worker très occupé plutôt qu’à un worker en panne.

Commencez par Postgres et laissez le broker faire ses preuves

La plupart des workloads associés à une seule application représentent quelques milliers de jobs en arrière-plan par jour. Une table suffit.

CREATE TABLE job (
  id        bigserial   PRIMARY KEY,
  payload   jsonb       NOT NULL,
  run_after timestamptz NOT NULL DEFAULT now(),
  attempts  int         NOT NULL DEFAULT 0
);
CREATE INDEX job_ready_idx ON job (run_after, id);

Un worker récupère un job dans le cadre d’une transaction.

BEGIN;
SELECT id, payload
  FROM job
 WHERE run_after <= now()
 ORDER BY id
   FOR UPDATE SKIP LOCKED
 LIMIT 1;
-- run the work, then remove the row
DELETE FROM job WHERE id = $1;
COMMIT;

FOR UPDATE SKIP LOCKED constitue l’élément essentiel. Il verrouille la ligne renvoyée et ignore toute ligne déjà verrouillée par une autre transaction. Deux workers ne récupèrent donc jamais le même job. Si un worker plante, Postgres annule sa transaction, libère le verrou et rend la ligne visible pour le worker suivant. Vous obtenez une livraison au moins une fois, des retries en incrémentant attempts, ainsi qu’une table de dead letters, le tout avec la durabilité que vous payez déjà. Le backlog se consulte avec une seule requête : SELECT count(*) FROM job WHERE run_after <= now();

Ses limites. Chaque récupération et chaque suppression constituent une écriture. Un débit élevé de jobs laisse donc des versions obsolètes de lignes, et une queue table est le cas classique où le bloat dépasse les capacités d’autovacuum. Les jobs longs aggravent le problème, car une transaction maintenue ouverte pendant toute la durée du traitement retarde également l’horizon de vacuum pour toute la base de données. Le polling ajoute de la latence, et LISTEN avec NOTIFY supprime le polling, mais pas les écritures. Lorsque la table des jobs devient la table la plus sollicitée, ou lorsqu’un second service a besoin des mêmes événements, déplacez le traitement ailleurs. Ce choix dépend aussi du déploiement de la base de données. Déterminez donc si la base de données s’exécute dans Docker ou sur l’hôte avant d’ajouter un broker à côté.

Redis est l’autre composant que vous exécutez peut-être déjà. Redis Streams fournit des consumer groups avec XADD et XREADGROUP, une pending list par groupe, ainsi que XAUTOCLAIM pour reprendre le travail d’un consumer qui a cessé de fonctionner. C’est léger et rapide. La limite à connaître sur une seule machine : avec le réglage appendfsync everysec courant, une coupure de courant peut entraîner la perte d’environ une seconde d’écritures. Cela convient à l’invalidation de cache, mais pas aux paiements. Si votre application est un processus unique reposant sur SQLite en production sur un VPS, le même modèle de récupération et de suppression fonctionne. SQLite n’a toutefois pas d’équivalent de SKIP LOCKED, et chaque worker se sérialise sur l’unique write lock.

Routage par sujets avec NATS core, sans mémoire

docker run -d --name nats \
  -p 4222:4222 -p 127.0.0.1:8222:8222 \
  nats:2.14 -m 8222

En août 2026, la branche actuelle du serveur est la 2.14. -m 8222 active le port de monitoring HTTP. Il est désactivé par défaut et ne fournit aucune authentification. Liez-le donc à localhost comme indiqué plus haut.

NATS core fonctionne en mode « au plus une fois » et ne stocke rien. Un publisher envoie un message sur un sujet tel que orders.created, et chaque subscriber dont le filtre correspond en reçoit une copie. Si aucun subscriber n’est abonné, le message est perdu et le publisher ne reçoit aucune erreur, car son rôle s’est terminé lorsque le serveur a accepté les octets. Un queue group, c’est-à-dire plusieurs subscribers partageant le même nom de groupe, demande au serveur de sélectionner un membre par message. Cela répartit le travail sans stocker de queue.

L’empreinte mémoire comprend l’état des subscriptions et un write buffer pour chaque connexion. Elle dépend donc du nombre de connexions, pas du volume de messages, et rien ne s’accumule sur le disque. Le comportement au redémarrage en découle : les messages en cours de transmission sont perdus, les clients se reconnectent seuls et aucune étape de récupération n’est nécessaire.

Il n’y a pas de backlog à surveiller. Surveillez plutôt les pertes. Lorsqu’un subscriber lit son socket plus lentement que le serveur n’y écrit, le buffer du serveur pour ce client se remplit. Si le client n’a pas rattrapé son retard à l’expiration du write deadline, le serveur ferme toute la connexion et incrémente un compteur.

curl -s http://localhost:8222/varz | jq '.slow_consumers, .connections, .in_msgs, .out_msgs'

Une valeur slow_consumers qui augmente continuellement indique que des messages sont perdus. Déclenchez donc une alerte dessus au lieu de la consulter une seule fois. NATS core convient aux messages dont la valeur expire rapidement : une métrique, une mise à jour de présence ou une invalidation de cache que l’événement suivant remplacera de toute façon.

NATS JetStream : streams persistants et replay dans le même processus

JetStream n’est pas un produit distinct. C’est un sous-système du même binaire, activé par un seul flag.

docker run -d --name nats \
  -p 4222:4222 -p 127.0.0.1:8222:8222 \
  -v nats-data:/data \
  nats:2.14 -js -sd /data -m 8222

-sd /data définit le répertoire de stockage. Si vous l’omettez, JetStream stocke ses données dans /tmp, avec une persistance aussi fiable que son nom le laisse entendre. Créez un stream avec la CLI, incluse dans l’image nats-box.

docker run --rm -it --network host natsio/nats-box:latest \
  nats stream add ORDERS \
    --subjects 'orders.>' \
    --storage file \
    --retention limits \
    --max-age 72h \
    --max-bytes=1073741824 \
    --discard old \
    --defaults

Chaque limite est utile sur un petit serveur. --storage file détermine ce qui survit à un crash, contrairement à un stream en mémoire. --max-bytes=1073741824 limite le stream à 1 GiB écrit, exprimé en octets, et --discard old supprime les messages les plus anciens lorsque la limite est atteinte au lieu de refuser les nouvelles écritures. Si vous ne définissez pas cette limite, un publisher qui s’emballe remplit le disque. Votre base de données s’arrête alors aussi, car elle utilise le même disque.

Un consumer durable conserve sa position dans le stream et la conserve après un redémarrage. Définissez --max-deliver sur le consumer afin qu’un message qui échoue systématiquement cesse d’être redélivré indéfiniment. Lorsqu’un message a épuisé son nombre de tentatives de livraison, JetStream publie un advisory sur $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>. S’abonner à ce subject permet de construire la dead letter path que RabbitMQ fournit comme fonctionnalité. Vous devez réellement l’implémenter vous-même.

Pour voir les messages en attente, exécutez nats stream report afin d’obtenir le nombre de messages stockés, puis nats consumer report ORDERS pour connaître le nombre d’acknowledgements en attente et de messages non traités par consumer. C’est le nombre de messages non traités qu’il faut surveiller. La consommation disque est visible avec du -sh sur le répertoire de stockage, et elle augmente jusqu’à ce qu’une limite de rétention la réduise.

RabbitMQ : acquitter chaque message et isoler les échecs

docker run -d --name rabbitmq \
  -p 5672:5672 -p 127.0.0.1:15672:15672 \
  -v rabbitmq-data:/var/lib/rabbitmq \
  rabbitmq:4-management

En août 2026, la série actuelle est la 4.3. Le port 5672 correspond à AMQP (advanced message queuing protocol) et le port 15672 à l’interface de gestion. Laissez l’interface sur localhost et utilisez-la via un tunnel SSH.

Déclarez les files avec l’argument x-queue-type défini sur quorum ; la valeur par défaut reste classic. Les quorum queues sont toujours durables et écrivent les données sur disque avant toute autre opération. Sur un nœud, vous obtenez ainsi un comportement unique et prévisible, au lieu d’une combinaison de files durables et transitoires. Définissez la dead letter target avec une policy.

docker exec rabbitmq rabbitmqctl set_policy DLX ".*" \
  '{"dead-letter-exchange":"my-dlx", "dead-letter-routing-key":"my-routing-key"}' \
  --apply-to queues --priority 7

Un message est envoyé vers la dead letter queue pour quatre raisons : un consumer le rejette avec basic.reject ou basic.nack et requeue défini sur false, son TTL (time to live) par message expire, la file dépasse une limite de longueur ou le message dépasse la limite de livraisons d’une quorum queue. Cette limite vaut 20 par défaut depuis RabbitMQ 4.0. Ainsi, un handler qui lève une exception et envoie un nack réessaie vingt fois, puis transmet le message au dead letter exchange au lieu de boucler.

La mémoire réserve des surprises avec RabbitMQ sur un petit VPS. Le high watermark par défaut vaut 0.6 de la RAM disponible. Lorsque le nœud le dépasse, RabbitMQ bloque toutes les connexions qui publient des messages. Votre application ne reçoit pas d’erreur. Elle effectue une publication qui ne retourne jamais, ce qui ressemble à un blocage dans votre propre code. Le journal de démarrage affiche la valeur calculée par le nœud :

Memory high watermark set to 1024 MiB (1073741824 bytes) of 8192 MiB (8589934592 bytes) total

L’alarme de disque bloque les publishers de la même manière lorsque l’espace libre passe sous 50 MB par défaut. Les quorum queues ajoutent leur propre calcul : la documentation prévoit au moins 32 bytes de métadonnées en mémoire par message, soit environ 1 MB pour 30,000 messages, et recommande en RAM au moins trois fois la taille effective du write-ahead log. La limite du WAL vaut 512 MiB par défaut. Cette recommandation nécessite donc à elle seule 1.5 GB. Sur un serveur de 2 GB, réduisez cette valeur dans rabbitmq.conf au lieu de supposer que la valeur par défaut conviendra.

raft.wal_max_size_bytes = 64000000
vm_memory_high_watermark.relative = 0.5

Le backlog se résume à deux nombres. Leur combinaison indique le type de panne.

docker exec rabbitmq rabbitmqctl list_queues name messages messages_ready messages_unacknowledged

messages_ready attend un consumer. messages_unacknowledged a été livré mais n’a jamais reçu d’ack. Une hausse du nombre de messages non acquittés à côté d’un nombre de messages prêts stable signifie que vos workers ont pris les tâches mais ne les terminent plus. Il s’agit d’un problème différent d’une file simplement en retard.

Kafka sur une seule machine, et quand cela cesse d’avoir du sens

KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
bin/kafka-storage.sh format --standalone -t $KAFKA_CLUSTER_ID -c config/server.properties
bin/kafka-server-start.sh config/server.properties

Voici le quickstart de Kafka 4.3.1, à jour en août 2026, exécuté en mode KRaft (Kafka Raft, le controller intégré qui a remplacé ZooKeeper dans Kafka 4.0). L’équivalent avec un conteneur est apache/kafka:4.3.1.

Le script de démarrage définit export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" si vous ne l’avez pas déjà fait. Le broker réserve donc 1 GB de heap Java avant même de stocker un message, et il attend en plus de la RAM libre pour le page cache dans lequel il lit. Sur un VPS de 2 GB, votre application se retrouve donc en concurrence avec la JVM pour la mémoire restante.

La rétention est la surprise suivante. log.retention.hours vaut par défaut 168, soit sept jours, et log.retention.bytes vaut par défaut -1, ce qui signifie qu’il n’y a aucune limite de taille. Kafka conserve les messages pendant toute cette période, qu’ils aient été lus ou non par chaque consumer. C’est la fonctionnalité recherchée, mais sur un petit disque, elle peut aussi provoquer la panne. Définissez donc une limite en octets par topic avant de le découvrir à vos dépens.

Voici maintenant le point essentiel. Un seul broker implique un replication factor de 1. acks=all correspond donc à un seul fsync sur un seul disque. Vous bénéficiez de la durabilité d’une seule machine, avec le coût d’exploitation d’un broker JVM et d’un controller. Les partitions apportent du parallélisme entre des brokers que vous n’avez pas. La réplication, la rack awareness et les autres fonctions prévues pour un parc de machines restent inactives. JetStream fournit le même replay durable sur cette même machine, avec une fraction de la mémoire. Deux raisons peuvent tout de même justifier Kafka ici : un outil en aval ne parle que le protocole Kafka (change data capture avec Debezium ou chargeur analytique), ou vous reproduisez en miniature une topologie de production. Prévoir d’évoluer vers un cluster revient à prévoir l’achat de machines supplémentaires. En attendant, le compromis est le même que pour exécuter k3s sur un seul nœud : vous payez la complexité d’un cluster pour la fiabilité d’un seul nœud.

Dans Kafka, le backlog correspond au consumer lag.

bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group my-group

Lisez la colonne LAG. Elle correspond à LOG-END-OFFSET moins CURRENT-OFFSET pour chaque partition. Si le lag augmente sur une partition alors que les autres restent stables, cela indique une répartition inégale des clés. Tous les messages ayant la même clé arrivent sur la même partition, et un seul consumer les traite.

Ce qui se passe lorsque le serveur redémarre

Core NATS perd tout ce qui est en cours de traitement, puis redémarre immédiatement, car il n’y a rien à récupérer. JetStream recharge les streams et la position des consumers depuis le répertoire de stockage. Les consumers reprennent donc à l’offset qu’ils avaient atteint. RabbitMQ récupère les quorum queues depuis le disque, tandis que les queues transient classiques et les messages publiés sans le mode de livraison persistent sont perdus. Kafka relit ses segments de log au démarrage. Après un arrêt non propre, cette analyse de récupération peut prendre plusieurs minutes sur un petit disque avant que le broker accepte une connexion.

Deux éléments doivent être configurés une fois pour toutes. Attribuez une restart policy au conteneur (restart: unless-stopped) ou activez l’unité systemd. Le broker redémarrera ainsi automatiquement après un reboot dû à une mise à niveau du kernel. Gérez ensuite l’ordre de démarrage. Si le broker devient ready vingt secondes après votre application, il refusera les premières connexions. Certaines bibliothèques clientes quittent également le processus au lieu de réessayer. Faites dépendre l’application du broker avec des healthchecks Compose qui empêchent le démarrage d’un service dépendant tant que le broker n’est pas ready.

Coût sur votre propre VPS : mesurez-le au lieu de reprendre des chiffres publiés

Les débits publiés sont mesurés sur du matériel auquel vous n’avez pas accès, généralement un serveur multicœur équipé d’un stockage NVMe local. Considérez-les comme une limite supérieure et mesurez les performances de votre serveur.

docker stats --no-stream
free -m
sudo du -sh /var/lib/docker/volumes/*/_data

Exécutez ces tests lorsque le broker est inactif, puis recommencez avec votre trafic réel. L’écart entre les deux mesures détermine si le broker peut fonctionner à côté de votre application. Pour obtenir une estimation minimale du débit, utilisez le générateur de charge fourni par chaque projet plutôt que les chiffres d’un article de blog : nats bench pub test --msgs 100000 --clients 2 pour NATS, bin/kafka-producer-perf-test.sh pour Kafka et PerfTest pour RabbitMQ. Si vous exécutez le générateur sur le même VPS, vous mesurez le broker et le générateur ensemble. Cela convient, à condition de le préciser lorsque vous publiez le résultat.

Une même limite s’applique à tous ces brokers. Chaque option durable attend la fin d’un fsync. Sur un VPS utilisant un stockage réseau, le disque fixe donc la limite. Changer de broker ne la déplacera pas.

Trois charges de travail et la file de messages adaptée à chacune

  1. Tâches en arrière-plan pour une application web, par exemple l’envoi d’e-mails, le redimensionnement d’images ou l’envoi de webhooks. Commencez avec Postgres et SKIP LOCKED. Passez à RabbitMQ avec des quorum queues lorsque vous avez besoin d’accusés de réception par message, d’une limite de livraisons et d’une dead letter queue que vous pouvez inspecter sans développer vous-même cette logique, ou lorsque la table des tâches est devenue la table la plus sollicitée de la base de données.
  2. Événements auxquels plusieurs services internes réagissent, lorsqu’un message perdu est rapidement remplacé par un message plus récent. Utilisez Core NATS, avec des subjects comme mécanisme de routage et des queue groups lorsque vous devez répartir le travail. Ajoutez un stream JetStream pour l’ensemble restreint de subjects qui doivent survivre à un redémarrage, et laissez le reste en mémoire.
  3. Journal d’événements que les consommateurs lisent depuis le début, pour constituer une piste d’audit, reconstruire un read model ou alimenter ultérieurement des outils d’analytics. Utilisez JetStream avec un stockage sur disque et une limite explicite en octets. Choisissez Kafka uniquement lorsqu’un outil en aval exige le protocole Kafka, et acceptez le heap de la JVM comme coût de cette compatibilité.

Sur un serveur unique, le mauvais choix ne coûte pas en débit. Il coûte au moment de la récupération à trois heures du matin, lorsque vous devez savoir si les messages existent encore. Choisissez en fonction de cela.

FAQ

Puis-je exécuter Kafka sur un VPS de 2 GB ?

Il démarre, mais les ressources seront limitées. bin/kafka-server-start.sh définit KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" lorsque vous ne l’avez pas remplacé. La JVM réserve donc 1 GB avant de stocker le moindre message, et Kafka dépend de la mémoire libre restante pour le page cache. Si vous ajoutez votre application et votre base de données sur le même serveur, vous utilisez le swap. Vous obtenez également un facteur de réplication de 1. Cela signifie que acks=all correspond à un seul fsync sur un seul disque. Vous supportez donc le coût d’exploitation de Kafka sans bénéficier de son modèle de durabilité. NATS JetStream permet une relecture durable sur le même matériel avec beaucoup moins de mémoire.

Ai-je besoin d’une file de messages si j’exécute déjà Postgres ?

Souvent, non. Une lecture de table job avec SELECT ... FOR UPDATE SKIP LOCKED dans une transaction fournit une livraison au moins une fois, des workers concurrents sûrs, des nouvelles tentatives et une table de dead letter, sans service supplémentaire à superviser et avec les sauvegardes que vous effectuez déjà. Certains signaux indiquent toutefois qu’il faut changer d’architecture : la table de la file devient votre charge d’écriture la plus importante et autovacuum prend du retard, des jobs de longue durée maintiennent des transactions ouvertes et bloquent vacuum pour toute la base de données, ou un second service doit consommer les mêmes événements indépendamment.

Dois-je utiliser NATS JetStream ou RabbitMQ pour les jobs en arrière-plan ?

RabbitMQ si vous voulez un accusé de réception par message, une limite de livraison et un routage vers une dead letter intégrés. Les quorum queues sont toujours durables. Depuis RabbitMQ 4.0, la limite de livraison est fixée par défaut à 20. Une policy envoie les messages ayant atteint cette limite vers un dead letter exchange que vous pouvez vider et inspecter. Utilisez JetStream si les mêmes événements doivent ensuite pouvoir être relus par d’autres consumers. Un stream conserve les messages après leur accusé de réception, contrairement à une queue. Avec JetStream, vous définissez --max-deliver et construisez vous-même le chemin de dead letter à partir de l’advisory $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>.

Comment savoir jusqu’où mes consumers ont pris du retard ?

Chaque broker dispose d’une commande. Pour RabbitMQ, rabbitmqctl list_queues name messages messages_ready messages_unacknowledged distingue le travail en attente d’un consumer du travail livré mais jamais confirmé. Pour JetStream, nats consumer report <stream> affiche les messages non traités et les accusés de réception en attente pour chaque consumer. Pour Kafka, kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group <group> affiche une colonne LAG pour chaque partition. Core NATS n’a aucun backlog à consulter, car il ne stocke rien. Surveillez plutôt le compteur slow_consumers sur http://localhost:8222/varz : il compte les connexions que le serveur a fermées parce que les consumers avaient pris du retard, ce qui correspond à une perte de messages.

#nats#rabbitmq#kafka#message-queue#architecture