La configuration que tout le monde a
Si vous auto-hébergez Buzz, vous faites presque à coup sûr tourner une version du paquet compose de production, deploy/compose/compose.yml. Il démarre cinq services : le relais, Postgres, Redis, MinIO, et un minio-init à usage unique qui crée un bucket buzz-media. MinIO est le backend des médias. Chaque image, note vocale et vidéo que quelqu'un colle dans le chat devient un objet S3, et le relais l'atteint à http://minio:9000 par le réseau compose.
Regardez le bloc des volumes, car c'est là que se joue cette histoire. buzz-postgres-data et buzz-minio-data sont tous deux des volumes Docker locaux : ils reposent donc sur le même système de fichiers, de la même machine. Votre historique de conversation et vos archives de médias partagent un disque, une réserve d'IOPS et un seul chiffre d'espace libre. Le premier jour, cela ne pose aucun problème. Les modes de défaillance arrivent plus tard, et aucun ne s'annonce.
Le paquet compose de production de block/buzz, abrégé : MinIO sert les médias depuis un volume Docker local sur la même machine que Postgres et le relais.
Plein avant d'être plein
Les deux stockages n'ont pas le même appétit. Postgres grossit de quelques kilo-octets par message et touche le disque par petites écritures synchrones, sensibles à la latence. Les médias grossissent de plusieurs mégaoctets par pièce jointe, en flux. Mettez-les sur un même disque et chaque gros envoi entre en concurrence avec chaque fsync dont votre journal d'événements a besoin, et c'est la courbe des médias, la plus raide, qui décide quand le disque se remplit pour les deux.
Le point plus subtil : MinIO cesse d'accepter les écritures avant que le disque soit plein, à dessein. Dans le MinIO actuel (hasSpaceFor dans cmd/object-api-utils.go), une écriture entrante est comptée double pour la comptabilité de l'erasure coding, un flux sans longueur déclarée est budgété à 1 Gio forfaitaire (diskAssumeUnknownSize), et l'écriture est refusée si elle doit pousser le disque au-delà de diskFillFraction, codé en dur à 99 pour cent. Une fois là, la réponse de l'API est la suivante : Storage backend has reached its minimum free drive threshold. Please delete a few objects to proceed.
En pratique, les envois commencent à échouer alors que df affiche encore des gigaoctets libres. Une vidéo de 500 Mo a besoin d'un gigaoctet entier de marge pour être acceptée, un flux de longueur inconnue est compté pour un gigaoctet quelle que soit sa taille réelle, et le dernier pour cent du disque n'a jamais été à vous. Si votre alerte se déclenche à 95 pour cent d'occupation, MinIO risque d'y arriver avant elle. Alertez sur les refus du backend de stockage, pas sur le graphique du disque.
L'envoi à deux secondes
Première leçon de la production : chaque envoi de média portait presque exactement deux secondes de latence. Une capture d'écran de 40 Ko, deux secondes. Une photo de 3 Mo, deux secondes et des poussières. La machine était au repos, la bande passante suffisait, et le chiffre était étrangement rond, ce qui trahit tout. La charge produit de la gigue. Deux secondes pile, c'est un minuteur.
Le minuteur est dans le client. Les crates médias de Buzz compilent le crate rust-s3 avec sa fonctionnalité fail-on-err (crates/buzz-media/Cargo.toml), qui transforme toute réponse non 2xx en erreur, y compris un 404 parfaitement légitime. Et avant d'écrire un nouvel objet, le chemin d'envoi demande au stockage si le sidecar de métadonnées et le blob existent déjà : deux appels head dans upload.rs. Au premier envoi, les deux réponses sont à juste titre des 404, les deux 404 deviennent des erreurs, et la logique de reprise du crate attend une seconde entière avant de relancer chacune. Deux vérifications d'existence, deux pauses, 2 000 ms de rien avant qu'un seul octet de votre fichier ne bouge.
Rien n'était lent. Rien n'était surchargé. Le relais attendait poliment la fin d'une politique de reprise, sur une réponse qui n'a jamais été une erreur, deux fois par envoi, à chaque envoi. La leçon vaut bien au-delà de Buzz : quand un chiffre de latence est rond et constant, arrêtez de profiler le CPU et allez lire la politique de reprise de chaque client sur le chemin.
La lecture qui a lieu deux fois
Deuxième leçon de la production. Buzz stocke un petit sidecar de métadonnées à côté de chaque blob, et le chemin de service le lit pour connaître l'extension canonique et le type MIME, une bonne conception contre l'usurpation du content-type. Mais suivez une requête média à travers crates/buzz-relay/src/api/media.rs et vous verrez le sidecar lu une fois pour la réponse MIME, puis relu dans resolve_s3_key, qui va chercher de son côté le même objet pour reconstruire la même information. Ajoutez le HEAD et le GET sur le blob lui-même, et une requête média coûte quatre allers-retours successifs vers le stockage.
Face à un MinIO en boucle locale, un aller-retour prend une ou deux millisecondes, donc le doublon est invisible, et c'est exactement le problème : le stockage local pardonne ce schéma et vous le cache. Placez une vraie latence réseau sous le même code et chaque requête paie le doublon, à quelques dizaines de millisecondes par saut. Nous l'absorbons avec un petit cache de métadonnées dans notre couche de stockage, plafonné assez bas pour que les octets des médias ne puissent jamais l'envahir. La leçon : comptez vos allers-retours tant que le stockage est encore local, car le jour où vous le déplacez, c'est ce schéma qu'on vous facture, en millisecondes.
Pourquoi un plus gros volume n'est pas la solution
Le correctif réflexe consiste à monter un volume de stockage bloc et à y déplacer buzz-minio-data. Les médias ont désormais de la place, et vous avez une nouvelle facture, mal construite : le stockage bloc se paie au gigaoctet provisionné, autour de 0,08 à 0,10 $ par Go-mois chez les grands clouds (chiffres indicatifs, votre fournisseur peut différer), et vous payez tout le volume, qu'il soit plein ou vide. Le plancher de 99 pour cent aggrave les choses, car la marge qu'exige MinIO est de l'espace que vous devez provisionner sans jamais pouvoir l'utiliser.
Vous possédez aussi désormais un deuxième système à état. Le volume doit être surveillé, redimensionné (une opération sur le système de fichiers, sur des données vivantes, que vous planifierez avec soin), capturé en instantanés et surtout sauvegardé, car votre dump Postgres ne contient pas vos médias. Une sauvegarde qui oublie le volume des médias se restaure en un historique de chat où chaque image, note vocale et fichier partagé est un lien mort. Personne ne le découvre le jour de la sauvegarde. On le découvre le jour de la restauration.
Ce que nous exploitons à la place
Nous avons remplacé la couche de stockage des médias sous nos relais hébergés : le relais parle toujours S3, mais les objets médias vivent dans Cloudflare R2, entièrement hors de la machine. Chaque espace de travail écrit dans son propre espace de noms dédié, et le disque du relais retrouve ce qu'un disque de relais doit contenir : la base d'événements et le logiciel. La croissance des médias ne décide plus de la taille de la machine que vous louez.
Cela change aussi la réponse sur le chiffrement au repos. Le compose standard ne configure aucun chiffrement au repos pour MinIO, donc les objets médias reposent sur le disque du VPS comme des fichiers en clair, et quiconque peut lire ce disque, ou un instantané ou une image de sauvegarde de celui-ci, peut lire chaque pièce jointe. R2 chiffre tout au repos, toujours. C'est une affirmation comparative, pas magique : les clés sont celles du fournisseur de stockage, donc cela protège les octets sur les disques et dans les instantanés, et cela vous demande de faire confiance à Cloudflare plutôt qu'à quiconque a un jour touché au stockage de votre VPS.
Et cela règle ce que signifie une sauvegarde. Nos sauvegardes traitent les médias comme une partie de l'artefact, pas comme une annexe : un objet média absent d'une sauvegarde fait échouer la sauvegarde, chaque objet est vérifié contre son hachage de contenu, et une restauration fait ses preuves en passant une requête HEAD sur chaque clé média qu'elle prétend contenir. Une restauration incapable de produire vos fichiers échoue bruyamment au moment de la restauration, le seul moment acceptable pour l'apprendre.
Les chiffres
Le stockage R2 Standard coûte 0,015 $ par Go-mois, sans aucuns frais de sortie, d'après la grille tarifaire publiée par Cloudflare au moment où nous écrivons. 500 Go de médias d'équipe coûtent 7,50 $ par mois à stocker, et les servir à votre équipe ne coûte rien en transfert. Les opérations sont facturées à part, 4,50 $ par million d'écritures et 0,36 $ par million de lectures, et une charge de travail de chat n'approche pas des volumes où cette ligne compte.
Les mêmes 500 Go sur un volume de stockage bloc, aux 0,08 à 0,10 $ par Go-mois indicatifs, reviennent à 40 à 50 $ par mois, provisionnés en avance sur la croissance, plus la marge qu'exige le plancher d'espace libre, plus ce que votre fournisseur facture pour servir ces octets. L'écart de prix n'est pas la raison profonde de sortir les médias de la machine ; la raison, c'est la forme de l'exploitation. Mais l'écart paie le déménagement.
Encore une chose, à propos des noms
Un bref avertissement tiré du même carnet de production : choisissez avec soin le nom d'hôte de votre relais avant que quiconque ne vous rejoigne, car l'identité de votre communauté finit par y être liée, et vous ne tenez pas à découvrir la mécanique d'un renommage après que cinquante personnes ont fait de l'ancien nom leur maison.
Où cela vous laisse
Nous exploitons tout ce qui précède en service géré sur hachiflow.com : 50 $ par mois, forfaitaires, votre propre relais sur name.hachiflow.chat, personnes et agents illimités, et les médias sur R2 exactement comme décrit ici.
Et si vous préférez continuer à vous auto-héberger, prenez les trois correctifs qui comptent et gardez-les : sortez les médias du disque du relais, alertez sur les refus du backend de stockage plutôt que sur df, et traitez une sauvegarde sans médias comme une sauvegarde échouée. Ces trois-là ne coûtent rien, et ils font l'essentiel de la valeur de cette page.