Retour au blog

Enderbot : la passerelle Discord réécrite en Go, puis la chasse aux millisecondes

EnderSpirit +Léo Sauvage 16 min de lecture Enderbot

Six semaines pour sortir discord.js de la production, remplacer le client par un binaire Go qui parle le même protobuf, faire passer le monorepo sous Bun, puis aller chercher dans les métriques les 2,2 Go de RSS que personne n'avait vus.

Enderbot est un jeu qui se joue dans Discord : un monorepo TypeScript où un cœur applicatif, un site SvelteKit et un client passerelle se parlent en protobuf par dessus RabbitMQ, avec Postgres, Redis et Prometheus derrière. Le client passerelle, c'est la partie qui tient la connexion à Discord, encaisse les évènements et pousse les messages. C'était historiquement du discord.js, un process Node par shard. Ça ne l'est plus.

Le site d'Enderbot, la partie SvelteKit du monorepo.
ender.gg, la face visible du monorepo : le cœur, le site et la passerelle partagent les mêmes files.
Un process au lieu de seize

Le client Discord passe de discord.js, un process Node par shard, à un binaire Go qui tient les seize shards à lui seul.

Le contrat de fil n'a pas bougé

Mêmes messages protobuf, mêmes clés Redis, mêmes métriques : le cœur applicatif tourne sans une ligne modifiée.

2,2 Go de RSS expliqués

Des caches sans plafond retenaient environ 2,7 Go par jour, et une métrique à 40 295 s ne mesurait qu'un sleep attendu par erreur.

Un retour arrière utilisé pour de vrai

La bascule du 9 août a été annulée le jour même. Sept jours de correctifs plus tard, le client JS a pu être supprimé.

Un étage de build en moins

Le cœur n'est plus empaqueté : Bun exécute les sources, et le bundle de 2962 modules disparaît avec ses quatre bundlers.

Une CI qui teste enfin

Le monorepo n'avait aucun job de test. Il en a deux, et 4196 tests passent à l'identique sous le nouveau runtime.

Qui a fait quoi

Le travail décrit ici s'est joué à deux, sur deux terrains nettement séparés. La passerelle est entièrement l'œuvre d'EnderSpirit : le constat de départ, la proposition de repartir sur du Go, le choix de la bibliothèque, l'écriture du module, la bascule en production et jusqu'à la suppression du client JS. Je n'ai pas écrit une ligne du Go dont parle cet article, je le raconte depuis l'autre bout du monorepo. De mon côté j'ai pris le cœur : son runtime, sa mesure en production et les correctifs qui en sortent.

Pourquoi sortir discord.js

Le raisonnement est celui d'EnderSpirit, qui tenait la passerelle et voyait la facture de près. Un process par shard, c'est un cache mémoire par shard, un heap par shard et un watchdog maison pour relancer ceux qui décrochent. À seize shards, la facture mémoire du client rivalisait avec celle du cœur alors qu'il ne fait que du relais. Le calcul métier vit ailleurs, donc la passerelle n'avait aucune raison de rester dans le même langage que lui.

Le remplaçant, écrit par EnderSpirit, s'appelle discord-go : un module Go bâti sur disgo, un seul process qui tient tous les shards, le nombre de shards résolu tout seul depuis /gateway/bot. Cent vingt six fichiers Go, une trentaine de milliers de lignes, et un démarrage à froid d'environ quatre vingt dix secondes à seize shards, imposé par la limite d'identify de Discord.

126 fichiers Go
30 257 lignes écrites
94 % de couverture
16 shards, un seul process
~90 s de démarrage à froid
0 ligne changée dans le cœur

Le contrat de fil comme point fixe

La règle qu'il s'est fixée, et qui a rendu la réécriture tenable : ne rien changer de ce qui traverse le réseau. Les mêmes messages protobuf sur les files client-to-core et core-to-client-{shardId}, les mêmes clés Redis status-discord-*, les mêmes métriques enderbot_discord_*. Le cœur tourne sans une ligne modifiée, et les tableaux de bord Grafana continuent d'afficher les mêmes séries.

Concrètement, les types Go sont générés depuis les mêmes .proto que le TypeScript. Les numéros de champ sont le contrat, jamais édités à la main.

évènementsclient-to-coreétatmétriquescore-to-clientDISCORDGateway15 shardsCLIENT PASSERELLEdiscord-go1 process, disgodiscord.js1 process par shard, retiréCONTRAT DE FILRabbitMQprotobufRedisstatus-discord-*Prometheusenderbot_discord_*APPLICATIFcoreTypeScript, BunPostgreSQLPrisma

Faites glisser le schéma pour le parcourir

Le client est la seule pièce remplacée : tout ce qui traverse le réseau reste identique.
cd discord-go
./gen-proto.sh        # shared/proto/**/*.proto -> internal/wire/
go build ./... && go test ./...

La parité se joue sur le comportement, pas sur l'API

La partie longue n'a pas été de reproduire les appels, mais les habitudes de discord.js. Elles ont occupé une journée entière de correctifs, le 11 août, tous signés EnderSpirit. Une bibliothèque de ce calibre transporte des décisions non écrites, et chacune se paie en bug de production si on ne la rejoue pas :

  • les permissions doivent ignorer un timeout, exactement comme discord.js le fait, sinon un membre exclu temporairement perd des droits qu'il devrait garder
  • le rôle @everyone voyage avec les rôles du membre, et compte tous les membres du cache
  • les positions des salons et des rôles sont calculées, pas lues telles quelles
  • un avatar animé se sert en .gif, un attachement qui arrive sans nom doit quand même en recevoir un
  • les messages privés se répondent, les nameLocalizations des choix d'autocomplétion se transmettent
  • le repli sur une édition simple ne vaut que pour un jeton périmé, pas pour toute erreur

Les paquets faits main sont montés à 94 % de couverture, et une revue adversariale dédiée a servi à chercher les écarts de parité restants plutôt qu'à relire du style.

Bascule, retour arrière, bascule

Le passage en production n'a pas été un interrupteur, et il a été piloté du même côté que le reste de la passerelle. Les deux clients ont tourné en double, pilotés par un drapeau Redis discord-client-active, avec un mode veille froide des deux côtés : le process boote passif, sonde le drapeau toutes les deux secondes, et l'activation recrée entièrement le client disgo. Fermer un client disgo bloque ses seaux REST et la réouverture devient impossible, donc on le reconstruit.

  1. 9 août
    Le client Go passe par défaut

    discord-go entre dans le compose de base, le client JS recule derrière un profil Docker prévu pour le retour arrière.

  2. 9 août
    Retour arrière le jour même

    Le client JS reprend la main. Le chemin de repli n'était pas décoratif, il a servi en quelques heures.

  3. 10 au 13 août
    Veille froide et parité

    Drapeau Redis discord-client-active des deux côtés, sonde toutes les deux secondes, et la longue série de correctifs de comportement.

  4. 16 août
    Le client JS est supprimé

    Avec lui partent le système de bascule, le service de compose, l'onglet d'administration et ses cinq locales.

lectureactifpassifreconstructionDÉCISIONdrapeau Redisdiscord-client-activesondetoutes les 2 sCLIENTSdiscord-goactif, 15 shardsdiscord.jsveille froideACTIVATIONclient disgo recrééjamais réouvertTRAFICRabbitMQclient-to-core

Faites glisser le schéma pour le parcourir

Les deux clients bootent passifs et sondent le même drapeau : basculer, c'est écrire une clé Redis.

Depuis, un échec d'activation fait sortir le process, et Docker le relance.

Pourquoi Bun, et ce que le passage a cassé

Pendant qu'EnderSpirit refaisait la passerelle, j'ai attaqué le runtime du cœur. La raison n'est pas la mode, c'est une couche entière devenue inutile : le cœur était livré en bundle esbuild, un dist/out.js de 2962 modules reconstruit à chaque changement, et trois autres bundlers vivaient à côté pour la CLI de scrap, l'export du wiki et fakecord. Bun lit le TypeScript directement, donc tout cet étage disparaît. Une trace d'erreur pointe de nouveau un fichier source plutôt qu'une ligne dans un bundle.

2962 modules dans le bundle supprimé
4 bundlers retirés
4196 tests, identiques à Node
7 j de quarantaine sur les dépendances
0 Node dans les images
2 jobs de test qui n'existaient pas

Ce qui a rendu la bascule possible, c'est la migration canvas faite plus tôt : toutes les dépendances natives qui restaient sont du N-API avec binaires préconstruits, que Bun charge sans y toucher, et le seul appel Node délicat du code, AsyncLocalStorage dans le logger, fonctionne. bun install remplace pnpm, et le blocage d'approvisionnement est reporté tel quel dans bunfig.toml : sept jours de quarantaine avant qu'une version publiée soit installable. Le régime des scripts d'installation se resserre au passage, puisque pnpm les autorisait tous en bloc dans les images alors que Bun n'exécute que ceux des quatre paquets nommés explicitement.

Le prix se paie sur les différences de sémantique, pas sur les performances. Cinq pièges, tous trouvés en une journée :

  • useDefineForClassFields : tsc le déduit à false, Bun le suppose à true. Un champ initialisé depuis une propriété de constructeur voyait donc undefined, et toutes les commandes liées à une guilde levaient. L'option est maintenant écrite noir sur blanc.
  • un npx prettier au démarrage du générateur de clés de langue, sur un fichier que l'API prettier venait de formater deux lignes plus haut. Redondant, et introuvable dans une image Bun.
  • bun --watch redémarre sur toute écriture dans l'arbre surveillé : les générateurs qui réécrivaient des octets identiques provoquaient soixante démarrages en trois minutes. Ils comparent avant d'écrire, et le fichier de log est sorti de l'arbre.
  • zod sous vitest : le runner de modules renvoyait undefined pour le binding nommé, ce qui cassait les 95 fichiers qui l'importent ainsi. Le paquet est inliné dans la config vitest.
  • better-sqlite3 n'est pas seulement à recompiler, Bun l'intercepte et lève. fakecord passe à bun:sqlite, dont les génériques, l'absence de pragma et le null au lieu de undefined se rattrapent aux trois getters concernés.

Ce que ça donne une fois posé : plus aucune image ne contient Node, l'étage de production du cœur se réduit à une installation et une copie des sources, et le réglage de taille de heap disparaît puisque JSC se dimensionne sur la RAM disponible. La CI tourne sous Bun et a gagné deux jobs de test qu'elle n'avait jamais eus, ce qui explique qu'une assertion sensible à l'ordre ait pu pourrir sans que personne le voie. Un script de garde refuse désormais tout retour de Node : shebang, appel à un gestionnaire de paquets, image de base ou action de CI.

Ce que disaient réellement les métriques

Une fois la passerelle stabilisée, c'est mon terrain qui passe à la question. J'ai profilé le cœur en production plutôt que de deviner. Relevé sur une fenêtre de 4993 secondes d'uptime :

2,2 Go
RSS
heap 903 Mo
352 ms
lag event loop max
p99 11,8 ms
21 %
d'un cœur CPU
1061 s sur 4993 s

L'hypothèse de départ était mauvaise. Je cherchais des lectures disque synchrones et du crypto bloquant ; il n'y en avait quasiment pas, et passer à Bun.file ou Bun.hash n'aurait rien gagné de mesurable. Le temps était ailleurs.

guildMemberInformationUpdate 40 295 s
un sommeil de 30 s attendu par le handler
read guild 762 s
96 ms × 7916 appels
commande summon 153,6 s
2648 ms de moyenne sur 58 appels
structure:vote-period-check 685 ms/min
un verrou et une transaction par guilde

Temps cumulé par point chaud, sur la même fenêtre de 4993 s.

Ce qui a été corrigé

Les 40 295 secondes de guildMemberInformationUpdate n'étaient pas du travail : le handler RabbitMQ attendait un debounce de trente secondes qui existait « au cas où d'autres informations arrivent ». Le rafraîchissement part désormais sans être attendu, ses erreurs journalisées, et la métrique se referme tout de suite.

Le RSS venait de caches sans plafond. Les entités guilde traînent leurs salons, leurs rôles et leurs emojis dans des Map qui ne rendaient jamais la mémoire ; les avatars Discord jamais rendus retenaient à eux seuls environ 2,7 Go de RSS par jour. Deux caches bornés en LRU, l'un pour les entités guilde, l'autre pour les buffers et images du chargeur d'assets, avec des plafonds très au dessus du working set actif pour que le taux de hit ne bouge pas.

summon, la commande la plus lente du jeu, composait jusqu'à vingt quatre frames dans une boucle await séquentielle. sharp encode sur le pool libuv, dimensionné à douze threads : la boucle n'en utilisait qu'un. Les trois boucles passent par une map concurrente bornée à six, l'ordre des frames restant garanti par construction puisque c'est lui qui définit le GIF.

  • une quinzaine de N+1 supprimés d'un coup : crons de ferme, classements, snapshots quotidiens, autocomplétion de boutique, collections de familiers
  • les statistiques de guilde agrégées en SQL au lieu d'être chargées entièrement en mémoire
  • deux index manquants sur command_logs et players.last_time_activity, où sept count() partaient en full scan avec un p95 à 4,7 s
  • les encodages canvas sortis du thread principal, et le verrou salon plus auteur ramené du message à la commande pour qu'une chaîne de commandes ne gèle plus le salon
  • le trafic Redis applicatif basculé sur le client natif de Bun, qui parle RESP sans traverser la couche de compatibilité node:net
  • les quantiles de latence Redis relabellisés par opération, avec des buckets qui commencent sous la milliseconde au lieu de cinq

Vingt crons pour une seule seconde

44 tâches planifiées
20 sur la même seconde
3 protégées de la ré-entrance
3,5 s sur 5,5 s de démarrage

Sur quarante quatre tâches planifiées, vingt partageaient la seconde HH:00:00 et se disputaient le pool Prisma une fois par heure. Un décalage stable est maintenant dérivé du hash du couple module:job, borné sous la minute pour qu'aucune tâche ne sorte de la minute pour laquelle elle était planifiée. Au passage, la garde de ré-entrance couvrait trois jobs sur quarante quatre via des drapeaux écrits à la main : elle vaut désormais pour tous, et le fuseau UTC que plusieurs schedules documentaient en commentaire est enfin garanti par le code.

décalageregroupéPLANIFIÉ44 tâches20 à HH:00:00balayage de boot141 structuresÉTALEMENThash(module:job)décalage < 1 mingarde de ré-entrance44 jobs sur 44RESSOURCEpool PrismaPostgreSQLEFFETcharge étaléeplus de pic horairedémarrage3,5 s repris sur 5,5 s

Faites glisser le schéma pour le parcourir

Le décalage est dérivé du hash du job : il est stable d'un redémarrage à l'autre, sans table à tenir.

Autre boucle qui n'additionnait que des allers-retours réseau : le balayage de démarrage parcourait les 141 structures une par une, chacune avec son verrou Redis et sa transaction. Il pesait 3,5 s sur les 5,5 s qui séparent le lancement du process de la mise en service du serveur web.

Le poids des images

Pendant que je faisais passer le monorepo sous Bun, EnderSpirit s'attaquait aux images Docker : base slim, installations filtrées par service, séparation build et runtime, polices Noto dégraissées. Puis les deux couches qui coûtaient le plus cher. Un chown -R des node_modules vivait dans son propre RUN, ce qui fait réécrire chaque fichier dans une nouvelle couche : le node_modules existait deux fois dans l'image. Bun installe par ailleurs les deux variantes libc des binaires natifs alors que l'image est glibc.

enderbot-core 2,59 Go 1,44 Go -44 %
enderbot-web-dev 1,6 Go 899 Mo -44 %
enderbot-web-preview 898 Mo 851 Mo -5 %

Le mois a aussi été celui d'une remise à plat de l'outillage, côté EnderSpirit : montée sur le TypeScript 7 natif et sur Vite 8, dépendances mortes évacuées, et un plugin de lint maison branché pour traquer le code sans intention.

vérification de types plus rapide
build du site plus rapide
45 dépendances mortes retirées
3678 défauts de lint résorbés

Ce que la production a mesuré

Les chiffres précédents viennent d'un profil ponctuel. Le Prometheus de production garde quinze jours, ce qui couvre exactement les trois bascules : la passerelle Go qui prend les quinze shards le 13 août, la suppression du client JS le 16, et le passage à Bun dans la nuit du 22. La charge n'a pas bougé sur la fenêtre, environ 15 490 serveurs Discord, donc l'avant et l'après se comparent honnêtement.

processus de passerelle 16 1 un seul
mémoire résidente passerelle 4,3 à 9,0 Go 1,0 à 1,6 Go 4x moins
mémoire de la machine 10 à 16 Go 7,5 à 10,7 Go plafond tenu
latence WebSocket Discord 106 ms 106 ms inchangée

Le passage à Bun se lit tout aussi nettement sur le cœur, redémarré en Bun 1.4.0 le 22 août au matin :

RSS du cœur 4,5 à 5,7 Go 2,4 à 3,8 Go 40 % de moins
lag event loop p99 11 à 16 ms 6 à 9 ms divisé par deux
p95 des commandes 1,94 s 1,44 s 500 ms
CPU du cœur 0,33 cœur 0,35 cœur stable

Le p95 des commandes reste haut, et c'est attendu : il inclut les allers et retours vers Discord ainsi que les attentes volontaires de certains handlers. Ce qui compte, c'est que la même charge passe maintenant avec deux gigaoctets de moins et un event loop deux fois plus calme.

Ce qui reste

read guild à 762 secondes cumulées est le prochain gros morceau : la lecture charge systématiquement salons, rôles et emojis, même quand l'appelant ne veut que le préfixe ou la langue. Un chemin « guilde nue » demande de trier les appelants un par un. buttonClick à 1587 ms est une métrique agrégée sur tous les boutons : il faut d'abord la labelliser par customId pour savoir lesquels sont lents.

La leçon qui vaut au delà d'Enderbot tient en deux lignes. Une réécriture ne devient raisonnable que si un contrat sérialisé stable permet de faire cohabiter l'ancien et le nouveau, avec un chemin de retour arrière qu'on emprunte vraiment. Et une passe de performance guidée par les métriques de production trouve autre chose que ce qu'on croyait chercher : ici, pas de disque bloqué, mais des caches sans plafond et un sleep attendu par erreur.