Tous les articles

Pourquoi j'ai écrit un CDN d'images de 90 lignes plutôt que payer un service

28 juin 20265 min de lecture
J'ai ouvert la page de tarifs d'un service d'images à la volée pour Wakfuli, j'ai regardé les paliers par nombre de transformations mensuelles, et j'ai refermé l'onglet. Pas parce que le prix était scandaleux en soi, mais parce qu'il était calculé pour un profil de charge qui n'était pas le mien : beaucoup d'écritures, beaucoup de régions, beaucoup de formats exotiques. Mon besoin réel tenait en une phrase : servir des icônes et sprites du jeu (statistiques, sorts, types d'objets, monstres) en plusieurs tailles et en webp, avec un volume de fichiers sources qui bouge rarement et un trafic de lecture qui peut être élevé.

Entre payer pour une infrastructure taillée pour du contenu utilisateur massif et écrire quelque chose d'adapté à mon cas, j'ai choisi la deuxième option. Le résultat tient dans un seul controller AdonisJS.

Ce que fait réellement le controller

La route intercepte une liste fixe de dossiers d'assets (items, spells, monsters, stats, etc.) plus un préfixe virtuel /images/, tout le reste retombant sur le middleware statique classique d'Adonis. Pour une requête qui matche, la logique est linéaire :

  1. 1

    Résoudre

    Résoudre le chemin du fichier source.
  2. 2

    Lire

    Lire les paramètres de requête.
  3. 3

    Chercher

    Chercher dans le cache disque.
  4. 4

    Transformer

    Sinon, transformer avec sharp et écrire le résultat en cache.
Le chemin rapide : cache hit
ts
const width = request.input("w") ? parseInt(request.input("w"), 10) : undefined;
const height = request.input("h") ? parseInt(request.input("h"), 10) : undefined;
const quality = request.input("q") ? parseInt(request.input("q"), 10) : 80;
const format = request.input("fm") || "webp";
 
const cacheKey = createHash("md5")
  .update(`${filePath}-${width}-${height}-${quality}-${format}`)
  .digest("hex");
const cachePath = join(cacheDir, `${cacheKey}.${format}`);
 
if (existsSync(cachePath)) { 
  const image = readFileSync(cachePath);
  response.header("Content-Type", `image/${format}`);
  response.header("Cache-Control", "public, max-age=31536000");
  return response.send(image);
}

La clé de cache est un simple hash du chemin et des paramètres. Si le fichier existe déjà, on le sert directement depuis le disque, sans repasser par sharp.

w / h
Largeur / hauteur cible, en pixels. Optionnelles : sharp ne redimensionne que si l'une des deux est fournie.
q
Qualité d'encodage, défaut 80.
fm
Format de sortie, défaut webp (jpeg ou png en option).

Sinon, un seul bloc applique un resize si largeur ou hauteur sont fournies, puis encode dans le format demandé avec la qualité voulue. Le buffer résultant est écrit sur disque avant d'être renvoyé, pour que la requête suivante avec les mêmes paramètres tombe dans le cas rapide.

Le Cache-Control: public, max-age=31536000 fait le reste du travail : une fois qu'un navigateur ou un CDN en amont a récupéré une variante, il ne revient plus la demander pendant un an. Sur un catalogue d'assets qui change par déploiement plutôt qu'en continu, c'est exactement la bonne granularité.

Pourquoi ça suffit pour ce profil de charge

Un service managé du type Cloudinary ou imgix vend de la robustesse pour des scénarios que je n'ai pas :

Cloudinary / imgix
  • Purge en temps réel (le contenu change sous les pieds des utilisateurs)
  • Distribution multi-région (latence globale garantie)
  • Transformations avancées (recadrage intelligent, watermarking, détection de visage)
Mon profil de charge
  • Corpus d'images fixe, connu à l'avance
  • Espace de variantes borné (quelques largeurs, deux ou trois formats)
  • Sharp transforme en quelques millisecondes, le cache disque absorbe la répétition

Pas besoin de payer pour des capacités que je n'utilise jamais.

Les limites, sans les cacher

Ce choix a un coût que j'assume plutôt que de le découvrir en production.

Ce choix a un coût, assumé

  • Pas de purge distribuée : si je change une image source, je dois moi-même invalider ou vider le cache, il n'y a rien d'automatique.
  • Pas de multi-région : tout part d'un seul serveur, donc la latence dépend de la géographie des joueurs.
  • Le cache est local au disque du serveur : il disparaît à chaque redéploiement, avec une rafale de cache misses juste après.
  • La sécurité des chemins repose entièrement sur la liste de dossiers autorisés codée en dur dans les routes : simple, mais à moi de la maintenir correctement si j'ajoute des dossiers d'assets.

Le seuil pour basculer

Si Wakfuli grossit au point d'avoir des joueurs répartis sur plusieurs continents avec des exigences de latence serrées, ou si le contenu se met à changer assez souvent pour que l'absence de purge distribuée devienne un vrai problème opérationnel, ou si je dois ajouter une deuxième instance et donc gérer un cache partagé entre plusieurs serveurs, ce sera le signal pour repasser sur du managé.

À retenir

Ce n'est pas une question de principe, c'est une question de courbe : le jour où maintenir ces 90 lignes coûte plus cher en temps qu'un abonnement, l'abonnement gagne. Pour l'instant, connaître précisément mon profil de charge m'a évité de payer pour un problème que je n'ai pas.