Réalisations

Image Resizer : la taille sur demande

Une image et toutes les tailles à la demande : j'ai écrit le service qui fabrique la variante depuis l'URL, en Docker ou en Lambda.

7 min de lecture commentaires

Le logo Image Resizer, sous-titré La taille sur demande, entre une photo de montagne en 4000 par 3000 et la même réduite en 1200 par 900

Depuis que je fais des applications web, j’ai toujours le même caillou dans la chaussure : les variantes de taille des images. J’ai fini par écrire l’outil qui me manquait, et il tourne aussi bien dans un conteneur que sur une fonction Lambda.

Le problème, ce n’est jamais l’image. C’est ses variantes

Le jour où tu veux un bon référencement et un site optimisé aux petits oignons, tu tombes toujours sur le même mur. Une image, ce n’est pas une image : c’est huit fichiers. Une version pour le mobile, une pour la tablette, une pour le grand écran, et chacune en double pour les écrans à forte densité. Le tout en WebP, parce que le JPEG coûte trois fois plus lourd pour le même rendu.

Et ça, c’est la partie facile. La partie pénible, c’est le contributeur. Il t’envoie une photo de 30 Mo en HEIC sortie de son iPhone, et il s’en fout complètement : il ne regarde même pas. Toi, derrière, tu voulais juste une vignette de 200 pixels de côté.

Il existe des services tout faits pour ça. Généralement payants, et pas forcément clé en main : il faut quand même leur mâcher le travail. Plus j’avais ce problème en tête, plus je me disais qu’il fallait trouver une solution. Et plus je cherchais, moins je la trouvais.

« Allez, ça va prendre 10 lignes de JS »

Bon, j’avais un peu tort. Mais ça a donné Image Resizer, une petite application qui ne fait qu’une chose : elle sert la variante que tu lui demandes, calculée au moment où tu la demandes.

C’est du full JavaScript. Elle tourne dans un conteneur Docker, elle tourne très bien dans Portainer, et elle tourne aussi sur une fonction Lambda serverless. Cette dernière option est celle que je trouve la plus marrante : tu as l’outil pour presque zéro euro, sans serveur, sans rien à maintenir.

L’URL, c’est la commande

Un exemple vaut mieux que dix mots. Voici une image PNG du blog, celle de l’article sur Duck DNS, un canard de 1536 × 1024 pixels. Je lui demande un bandeau de 1024 × 100, en WebP, avec un recadrage cover :

https://smea.tech/img/u/2026/08/duckdns-canard-bouee.png/_cover_1024_100_.webp
Bandeau de 1024 sur 100 pixels : une bande horizontale découpée au centre de l'image du canard, qui remplit toute la largeur
Avec cover, la boîte est remplie et ce qui dépasse est coupé.

Même taille, même image, mais je change la mécanique de redimensionnement et je demande du JPEG :

https://smea.tech/img/u/2026/08/duckdns-canard-bouee.png/_contain_1024_100_.jpg
Bandeau de 1024 sur 100 pixels : l'image entière du canard réduite au centre, avec de larges bandes vides de chaque côté
Avec contain, l’image entière rentre dans la boîte et le reste est comblé.

Rien d’autre n’a bougé que l’adresse. La structure est toujours la même :

/<source>/<chemin/du/fichier>/_<fit>_<largeur>_<hauteur>_<qualité>.<format>

Les champs vides prennent la valeur par défaut, donc /photo.jpg/_.webp suffit à obtenir du WebP correctement dimensionné. Le fit prend les cinq modes de sharp : cover, contain, fill, inside, outside. En sortie, tu as du JPEG, du PNG, du WebP et de l’AVIF. En entrée, il avale même le HEIC de l’iPhone dont je parlais plus haut.

Le choix de tout mettre dans le chemin plutôt qu’en paramètres d’URL n’est pas cosmétique. Chaque variante devient une adresse unique, et une adresse unique, les caches savent quoi en faire. Un ?w=320&h=240, beaucoup moins.

Je m’en sers sur ce blog

WordPress fabrique une demi-douzaine de vignettes à chaque téléversement, les garde pour toujours, et n’en sert jamais que deux ou trois. Le dossier des médias est le seul du site que je ne sais pas régénérer, donc le seul que je sauvegarde, et il grossissait de fichiers que personne ne regarde.

Maintenant je ne range qu’un fichier : l’original, en pleine résolution. Les variantes existent bien sur le disque elles aussi, mais dans le cache nginx, et ça change tout : ce dossier-là se jette et se refait tout seul. Toutes les images que tu vois sur ce blog, y compris celle en haut de cette page, sortent d’ici. La configuration tient en quelques lignes :

  images:
    image: smeagolworms4/image-resizer:latest
    restart: unless-stopped
    user: "${PUID:-1000}:${PGID:-1000}"
    environment:
      SOURCE_U: /uploads
      BASE_PATH: /img
      MAX_SIZE: "2560"
      DEFAULT_QUALITY: "82"
      ALLOWED_FORMATS: jpeg,png,webp,avif
    volumes:
      - ${DATA_DIR}/uploads:/uploads:ro
      - ${DATA_DIR}/img-cache:/cache

Le montage des médias est en lecture seule : le service n’a aucune raison d’écrire dans le dossier qui compte. Et SOURCE_U: /uploads déclare une source nommée u, ce qui explique le /img/u/… des adresses plus haut. C’est important pour la suite : aucune URL ne peut faire requêter au service une source qui n’a pas été déclarée.

Bénéfice inattendu, le jour où j’ai changé la largeur de la colonne du thème, je n’ai eu strictement rien à regénérer. Juste de nouvelles adresses.

Le cache devant, ce n’est pas une option

Le seul vrai bémol, et il faut le dire tout de suite : c’est un outil de redimensionnement, absolument pas un outil de cache. Il garde bien les originaux téléchargés et quelques conversions coûteuses sous le coude, mais chaque variante est calculée à la volée. Si tu ne mets rien devant, ton CPU va encaisser.

Donc tu mets un cache. nginx, Varnish, CloudFront, Cloudflare, ce que tu veux. Image Resizer est fait pour vivre derrière : il émet déjà les bons en-têtes, Cache-Control avec un s-maxage long, ETag, stale-while-revalidate. Chez moi c’est nginx, et voilà l’essentiel de la configuration :

proxy_cache_path /var/cache/nginx/images levels=1:2 keys_zone=images:50m
                 max_size=20g inactive=90d use_temp_path=off;

location ^~ /img/ {
    proxy_pass http://images:3000;

    proxy_cache images;
    proxy_cache_valid 200 90d;
    proxy_cache_valid 400 404 1m;
    proxy_cache_lock on;
    proxy_cache_use_stale updating error timeout http_500 http_502 http_503;

    # Tout est dans le chemin : ignorer les paramètres évite qu'un lien
    # traînant un ?utm_source crée une seconde entrée pour la même image.
    proxy_cache_key $uri;
}

Les deux lignes qui font tout le travail sont proxy_cache_lock et proxy_cache_use_stale. La première garantit qu’une rafale de requêtes sur une variante encore froide ne déclenche qu’un seul calcul, et pas cinquante en parallèle : c’est exactement ce genre de bousculade qui met un redimensionneur à genoux. La seconde fait qu’une variante déjà servie continue de l’être même si le service est arrêté.

Sur Lambda, ça coûte à peu près rien

C’est le mode que je préfère pour un petit site. Tu n’as ni serveur à louer, ni conteneur à surveiller, et la facture suit strictement l’usage.

Chaque version publie une archive zip prête à déposer, une par architecture. Prends celle en arm64 : sur Graviton, c’est environ 20 % moins cher à l’usage, et ça marche exactement pareil. L’archive fait dans les 10 Mo compressés, on est très loin des limites d’AWS.

La marche à suivre tient en quatre points :

  • créer la fonction en Node.js 22, architecture arm64, et déposer le zip ;
  • mettre le handler sur src/lambda.handler, la mémoire à 2048 Mo et le timeout à 30 secondes ;
  • poser les variables d’environnement : tes sources, plus CACHE_DIR=/tmp/cache parce que /var/task est en lecture seule, et HEIC_ENABLED=false puisque l’outil de conversion n’existe pas dans le runtime ;
  • activer une Function URL pour avoir une adresse publique, et c’est emballé, c’est pesé.

N’oublie surtout pas le CloudFront devant, avec la politique CachingOptimized et le transfert des paramètres d’URL désactivé. Sans lui, la même vignette est recalculée et facturée à chaque affichage, et l’intérêt de la manœuvre disparaît. Tout est détaillé dans le LAMBDA.md du dépôt.

Un piège dans lequel je suis tombé : si l’architecture du zip ne correspond pas à celle de la fonction, tu récupères un Could not load the sharp module parfaitement opaque. Vérifie ce point avant de chercher ailleurs.

Une clé secrète pour que personne ne s’amuse

Une adresse d’image est publique par définition, et rien n’empêche un plaisantin de demander 10 000 variantes différentes juste pour voir. On peut fermer la porte en générant une clé secrète : le service signe alors chaque URL en HMAC, et une adresse fabriquée à la main est refusée.

SIGNATURE_KEY=ta-clé-secrète

La signature se glisse dans le preset, avant l’extension :

/photos/beach.jpg/_cover_320_320_80_3dc222d73386b95c.webp

Dans le même esprit, les métadonnées sont supprimées par défaut à la sortie. Les données GPS de la photo prise avec le téléphone ne finissent pas en ligne sans que personne ne l’ait demandé.

C’est du MIT, et j’attends vos retours

C’est un outil vraiment pratique pour un site web, même petit. Il tourne facilement, et même sans conteneur si le cœur t’en dit. Les images publiées couvrent amd64, arm64 et arm/v7, donc le même tag marche sur ton PC, ton NAS ou ton Raspberry Pi.

Le tout est sur GitHub, en licence MIT. Je suis bien sûr friand de tout retour, et les pull requests sont les bienvenues si vous voulez bosser avec ou dessus.

Image d’en-tête générée par IA.

SmeagolWorms4

Les nouveaux articles par mail

Un mail par article publié. Rien d'autre, et désinscription en un clic.

Commentaires

Chargement des commentaires…

    Laisser un commentaire

    Vérification anti-robot en cours…

    Jamais publié.