Créer un serveur de traitement d’images rapide avec Go et libvips
Go peut exposer une petite API HTTP pendant que libvips effectue les opérations natives sur les images. Nous utilisons ici govips pour redimensionner, recadrer, filigraner et convertir des images via un seul gestionnaire de requêtes partagé.
Il s’agit d’une démonstration bornée, limitée à l’interface de bouclage (loopback), et non d’un service public de téléversement authentifié. Le traitement natif d’images nécessite une isolation et des limites opérationnelles, même lorsque les requêtes et les dimensions sont vérifiées.
Pourquoi libvips ?
Libvips évalue les pipelines d’images à la demande et peut réduire le travail intermédiaire. La vitesse et l’utilisation mémoire réelles dépendent de l’opération, des codecs, des dimensions des images et de la concurrence. Mesurez les performances de votre propre charge de travail plutôt que de supposer une accélération universelle.
Configuration de votre environnement Go
Prérequis
Utilisez une version maintenue de Go, un compilateur C, pkg-config et libvips. Cet exemple a été testé sous Linux avec Go 1.26.8, govips v2.18.0 et les versions 8.14.1 et 8.18.6 de libvips. Le module épinglé déclare Go 1.25.0 comme version minimale ; ce minimum est distinct des versions testées ici.
Installation
Sous Ubuntu 24.04, installez la bibliothèque de développement native :
sudo apt-get install --no-install-recommends build-essential pkg-config libvips-dev
Installez Go séparément en suivant le guide d’installation de Go.
Sous macOS, brew install vips pkg-config installe les bibliothèques natives. Suivez les
instructions de govips propres à chaque plateforme pour les réglages
spécifiques au compilateur.
Dans Bash, créez un nouveau projet et n’y entrez qu’une fois l’installation des dépendances réussie. Conservez les deux fichiers de module. Si l’installation échoue, inspectez le répertoire partiellement créé avant de réessayer :
mkdir image-api &&
(
cd image-api &&
go mod init example.com/image-api &&
go get github.com/davidbyttow/govips/v2/vips@v2.18.0
) &&
cd image-api
Création d’un serveur de traitement d’images de base
Placez le programme complet dans main.go. Il accepte des entrées JPEG et PNG et produit une image
fixe. Les formats et les paramètres des opérations sont explicites. L’image et le filigrane
facultatif passent tous deux par les mêmes vérifications de taille et de décodeur.
package main
import (
"bytes"
"context"
"errors"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"log"
"mime/multipart"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/davidbyttow/govips/v2/vips"
)
const maxBytes = 8 << 20
const maxPixels = 4_000_000
var slots = make(chan struct{}, 2)
var invalid = errors.New("unsupported image request")
func number(values url.Values, key string, fallback, minimum, maximum int) (int, error) {
text := values.Get(key)
if text == "" {
if _, present := values[key]; present { return 0, invalid }
return fallback, nil
}
if len(text) > 4 { return 0, invalid }
for _, character := range text {
if character < '0' || character > '9' { return 0, invalid }
}
value, err := strconv.Atoi(text)
if err != nil || value < minimum || value > maximum { return 0, invalid }
return value, nil
}
func loadImage(header *multipart.FileHeader) (*vips.ImageRef, error) {
file, err := header.Open()
if err != nil { return nil, err }
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
if err != nil || len(data) == 0 || len(data) > maxBytes { return nil, invalid }
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || (format != "jpeg" && format != "png") ||
config.Width < 1 || config.Height < 1 ||
config.Width > 4096 || config.Height > 4096 ||
config.Width > maxPixels/config.Height {
return nil, invalid
}
source, err := vips.NewImageFromBuffer(data)
if err != nil { return nil, err }
if source.Pages() > 1 {
source.Close()
return nil, invalid
}
if err := source.AutoRotate(); err != nil {
source.Close()
return nil, err
}
// Normalize 16-bit PNG samples before compositing or using an 8-bit white background.
if err := source.ToColorSpace(vips.InterpretationSRGB); err != nil {
source.Close()
return nil, err
}
return source, nil
}
func transform(source *vips.ImageRef, operation string, values url.Values,
form *multipart.Form) error {
switch operation {
case "resize", "crop":
width, err := number(values, "width", 256, 1, 2048)
if err != nil { return err }
height, err := number(values, "height", 256, 1, 2048)
if err != nil { return err }
if operation == "resize" {
// govips expects scale factors, not output pixel dimensions.
return source.ResizeWithVScale(float64(width)/float64(source.Width()),
float64(height)/float64(source.Height()), vips.KernelLanczos3)
}
left, err := number(values, "left", 0, 0, 4096)
if err != nil { return err }
top, err := number(values, "top", 0, 0, 4096)
if err != nil { return err }
if width > source.Width() || height > source.Height() ||
left > source.Width()-width || top > source.Height()-height {
return invalid
}
return source.ExtractArea(left, top, width, height)
case "watermark":
overlay, err := loadImage(form.File["watermark"][0])
if err != nil { return err }
defer overlay.Close()
if overlay.Width() > source.Width() || overlay.Height() > source.Height() {
return invalid
}
return source.Composite(overlay, vips.BlendModeOver,
source.Width()-overlay.Width(), source.Height()-overlay.Height())
case "convert":
return nil
}
return invalid
}
func encode(source *vips.ImageRef, format string) ([]byte, error) {
// Older libvips exporters can retain EXIF despite the strip option.
if err := source.RemoveMetadata(); err != nil { return nil, err }
var output []byte
var err error
switch format {
case "jpeg":
if source.HasAlpha() {
if err := source.Flatten(&vips.Color{R: 255, G: 255, B: 255}); err != nil {
return nil, err
}
}
params := vips.NewJpegExportParams()
params.StripMetadata = true
output, _, err = source.ExportJpeg(params)
case "png":
params := vips.NewPngExportParams()
params.StripMetadata = true
output, _, err = source.ExportPng(params)
case "webp":
params := vips.NewWebpExportParams()
params.StripMetadata = true
output, _, err = source.ExportWebp(params)
default:
return nil, invalid
}
return output, err
}
func processImage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
select {
case slots <- struct{}{}:
defer func() { <-slots }()
default:
http.Error(w, "Image processor is busy.", http.StatusServiceUnavailable)
return
}
values, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
http.Error(w, "Invalid parameters.", http.StatusBadRequest)
return
}
operation := values.Get("operation")
allowed := map[string]bool{"operation": true, "format": true}
switch operation {
case "resize", "crop":
allowed["width"], allowed["height"] = true, true
if operation == "crop" { allowed["left"], allowed["top"] = true, true }
case "convert", "watermark":
default:
http.Error(w, "Unsupported operation.", http.StatusBadRequest)
return
}
for key, entries := range values {
if !allowed[key] || len(entries) != 1 {
http.Error(w, "Invalid parameters.", http.StatusBadRequest)
return
}
}
format := values.Get("format")
if _, present := values["format"]; !present { format = "png" }
if format != "png" && format != "jpeg" && format != "webp" {
http.Error(w, "Unsupported format.", http.StatusBadRequest)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBytes+64*1024)
err = r.ParseMultipartForm(1 << 20)
if r.MultipartForm != nil { defer r.MultipartForm.RemoveAll() }
// Multipart parsing stops at its final boundary; count any remaining request bytes too.
if err == nil { _, err = io.Copy(io.Discard, r.Body) }
if err != nil {
code := http.StatusBadRequest
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) { code = http.StatusRequestEntityTooLarge }
http.Error(w, "Invalid or oversized upload.", code)
return
}
form := r.MultipartForm
expected := 1
if operation == "watermark" { expected = 2 }
if len(form.Value) != 0 || len(form.File) != expected || len(form.File["file"]) != 1 ||
(operation == "watermark" && len(form.File["watermark"]) != 1) {
http.Error(w, "Provide the required image files.", http.StatusBadRequest)
return
}
source, err := loadImage(form.File["file"][0])
if err != nil {
http.Error(w, "Unsupported image.", http.StatusBadRequest)
return
}
defer source.Close()
if err := transform(source, operation, values, form); err != nil {
http.Error(w, "Image operation could not be completed.", http.StatusBadRequest)
return
}
output, err := encode(source, format)
if err != nil {
http.Error(w, "Image encoding failed.", http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "image/"+format)
w.Write(output)
}
func run() error {
if err := vips.Startup(&vips.Config{ConcurrencyLevel: 1, MaxCacheSize: 0}); err != nil {
return err
}
defer vips.Shutdown()
address := os.Getenv("LISTEN_ADDR")
if address == "" { address = "127.0.0.1:8080" }
listener, err := net.Listen("tcp", address)
if err != nil { return err }
mux := http.NewServeMux()
mux.HandleFunc("POST /process", processImage)
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5*time.Second,
ReadTimeout: 15*time.Second, WriteTimeout: 30*time.Second, IdleTimeout: 30*time.Second}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
served := make(chan error, 1)
go func() { served <- server.Serve(listener) }()
select {
case err := <-served:
if errors.Is(err, http.ErrServerClosed) { return nil }
return err
case <-ctx.Done():
deadline, cancel := context.WithTimeout(context.Background(), 35*time.Second)
defer cancel()
if err := server.Shutdown(deadline); err != nil {
// Never shut libvips down while a native handler is still running.
log.Print("Image server shutdown deadline exceeded.")
os.Exit(1)
}
return nil
}
}
func main() {
if err := run(); err != nil {
log.Printf("Image server failed: %v", err)
os.Exit(1)
}
}
Exécutez go run . depuis image-api et laissez ce terminal ouvert. L’adresse par défaut est
127.0.0.1:8080 ; définissez LISTEN_ADDR pour utiliser une autre adresse si le port est occupé. Chaque
fichier est limité à 8 MiB, 4 096 pixels par côté et quatre millions de pixels. Ensemble, les
deux fichiers et la surcharge multipart doivent tenir dans la limite de requête de 8 MiB + 64 KiB,
y compris les octets situés après le terminateur multipart. Les données multipart peuvent être
écrites temporairement sur disque ; le RemoveAll() différé les nettoie aussi bien pour les requêtes
réussies que pour les requêtes en échec. Un arrêt brutal du processus peut laisser des fichiers
temporaires.
Mise en œuvre des opérations d’images courantes
Dans un second terminal, utilisez un répertoire contenant vos propres fichiers photo.jpg, photo.png et
logo.png. L’exemple de recadrage nécessite une photo d’au moins 110 × 90 pixels après
orientation. La sortie est une image fixe ; cette API n’est pas un flux de travail de préservation
des animations. Ces commandes cURL écrasent les fichiers de sortie nommés. Avec --fail-with-body, une erreur
HTTP peut remplacer un fichier de sortie par le texte de l’erreur ; vérifiez le code de sortie avant
de l’ouvrir comme image. Une entrée locale manquante peut faire échouer la commande avant qu’un
fichier de sortie existant ne soit remplacé.
curl --fail-with-body -F 'file=@photo.jpg' \
'http://127.0.0.1:8080/process?operation=resize&width=320&height=180' -o resized.png
Le redimensionnement étire l’image aux dimensions demandées. La largeur et la hauteur deviennent des
facteurs d’échelle horizontal et vertical ; transmettre directement des nombres de pixels à
ResizeWithVScale créerait une image très volumineuse.
Recadrage d’une image
Les coordonnées de recadrage s’appliquent après l’orientation EXIF :
curl --fail-with-body -F 'file=@photo.jpg' \
'http://127.0.0.1:8080/process?operation=crop&left=10&top=10&width=100&height=80' -o crop.png
Le recadrage doit tenir entièrement dans l’image d’entrée. Des vérifications fondées sur la soustraction évitent le dépassement de capacité d’une somme de décalages et de tailles non fiables.
Ajout d’un filigrane
Fournissez un JPEG ou un PNG transparent plus petit que l’image de base. Il est placé en bas à droite :
curl --fail-with-body -F 'file=@photo.jpg' -F 'watermark=@logo.png' \
'http://127.0.0.1:8080/process?operation=watermark' -o watermarked.png
Conversion de formats d’image
La sortie JPEG compose la transparence sur un fond blanc. PNG et WebP peuvent la conserver. Le
chargeur convertit les échantillons PNG 16 bits en sRGB 8 bits avant le traitement : la valeur de
fond blanc 255 doit utiliser la même plage que l’image. Consultez la documentation de libvips sur
la conversion des couleurs
et l’aplatissement du canal alpha.
Les exports JPEG et WebP utilisent par défaut une compression avec perte ; il ne s’agit pas d’un flux
de travail d’archivage sans perte.
curl --fail-with-body -F 'file=@photo.png' \
'http://127.0.0.1:8080/process?operation=convert&format=jpeg' -o converted.jpg
Optimisation des performances et de l’utilisation de la mémoire
Réglage de la configuration de libvips
L’exemple définit un thread natif par opération et désactive le cache des opérations. Ajustez ces réglages à partir de mesures réelles. Le redimensionnement et le recadrage acceptent des côtés de sortie allant jusqu’à 2 048 pixels. Le redimensionnement peut donc produire 2 048 × 2 048 pixels, soit légèrement plus que la limite d’entrée de quatre millions de pixels. La conversion et l’ajout de filigrane conservent les dimensions de l’image de base, qui peuvent dépasser 2 048 sur un côté. Ces vérifications et la limite de deux requêtes ne bornent pas chaque allocation native ni la taille de la sortie encodée.
Mise en place d’un pool de workers
Le canal borné est un sémaphore, et non une file d’attente de tâches illimitée. Une troisième requête simultanée reçoit immédiatement une réponse 503. Cela rend la contre-pression explicite sans nécessiter un second ensemble de fonctions de chargement et d’export d’images. Les déploiements plus importants nécessitent des limites à l’échelle de toute la flotte et des workers correctement isolés.
Surveillance de l’utilisation des ressources
Mesurez la RSS du processus ainsi que l’utilisation du tas Go : les allocations natives de libvips se trouvent en dehors du tas Go. Mesurez également l’espace disque temporaire, les requêtes rejetées, les échecs du décodeur et la latence. Les délais HTTP n’annulent pas le traitement natif. Utilisez l’isolation des processus pour imposer des limites strictes de CPU et de mémoire.
Les en-têtes malformés et les échecs du décodeur produisent des erreurs, mais un décodage réussi ne constitue pas une vérification d’intégrité. Lorsque vous acceptez des fichiers inhabituels, inspectez la sortie complète, y compris ses bords. Le programme supprime les données EXIF de l’entrée avant l’export, car une simple option de suppression peut les conserver avec d’anciens exporteurs libvips. govips conserve certaines métadonnées techniques en interne, notamment les profils de couleur. Cet exemple n’est ni un outil complet de nettoyage des données personnelles, ni un flux de travail permettant de reproduire fidèlement des profils de couleur intégrés arbitraires.
Déploiement avec Docker
Utilisez des versions de Debian identiques pour les bibliothèques de compilation et d’exécution.
Enregistrez ceci sous Dockerfile dans image-api, à côté de go.mod, go.sum et
main.go :
FROM golang:1.26-bookworm AS build
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY main.go ./
RUN CGO_ENABLED=1 go build -o /image-api .
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends libvips42 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /image-api /usr/local/bin/image-api
USER 65534:65534
ENV LISTEN_ADDR=0.0.0.0:8080
EXPOSE 8080
CMD ["/usr/local/bin/image-api"]
Arrêtez le serveur local go run avant de publier le conteneur sur le même port. Depuis
image-api, construisez l’image et publiez-la uniquement sur l’interface de bouclage pour un test
local :
docker build -t image-api . &&
docker run --rm --stop-timeout=45 --memory=512m --cpus=2 -p 127.0.0.1:8080:8080 image-api
À la réception de SIGINT ou SIGTERM, le programme cesse d’accepter les connexions et attend jusqu’à 35 secondes que les gestionnaires actifs se terminent. Le délai de grâce de 45 secondes accordé à l’arrêt du conteneur laisse le temps à cette procédure ; le délai de grâce par défaut de Docker sous Linux est de 10 secondes. Si une opération native dépasse le délai de l’application, le processus se termine sans nettoyage normal.
Avant un déploiement public, ajoutez TLS, l’authentification et l’autorisation, des limites de taille du corps des requêtes au niveau du proxy, des protections contre les abus et une politique de décodeurs vérifiée. L’exemple de conteneur illustre l’empaquetage, et non une isolation complète entre locataires. Pour un traitement géré, découvrez le service de traitement d’images de Transloadit.
