Einen schnellen Bildverarbeitungsserver mit Go und Libvips bauen
Go kann eine kleine HTTP-API bereitstellen, während libvips native Bildoperationen ausführt. Hier nutzen wir govips, um Bilder über einen gemeinsamen Request-Handler zu skalieren, zuzuschneiden, mit Wasserzeichen zu versehen und zu konvertieren.
Dies ist eine begrenzte Demonstration auf Loopback, kein authentifizierter öffentlicher Upload-Dienst. Native Bildverarbeitung benötigt Isolation und betriebliche Limits, auch wenn Anfragen und Abmessungen geprüft werden.
Warum Libvips?
Libvips wertet Bild-Pipelines bei Bedarf aus und kann Zwischenarbeit reduzieren. Die tatsächliche Geschwindigkeit und der Speicherverbrauch hängen von der Operation, den Codecs, den Bildabmessungen und der Parallelität ab. Führen Sie Benchmarks mit Ihrer eigenen Arbeitslast durch, statt eine universelle Beschleunigung anzunehmen.
Ihre Go-Umgebung einrichten
Voraussetzungen
Verwenden Sie ein gepflegtes Go-Release, einen C-Compiler, pkg-config und libvips. Dieses Beispiel wurde unter Linux mit Go 1.26.8, govips v2.18.0 sowie libvips 8.14.1 und 8.18.6 getestet. Das gepinnte Modul deklariert Go 1.25.0 als Mindestversion; diese Mindestversion ist unabhängig von den hier getesteten Versionen.
Installation
Installieren Sie unter Ubuntu 24.04 die native Entwicklungsbibliothek:
sudo apt-get install --no-install-recommends build-essential pkg-config libvips-dev
Installieren Sie Go separat mithilfe der Go-Installationsanleitung.
Unter macOS installiert brew install vips pkg-config die nativen Bibliotheken. Für compilerspezifische Einstellungen
folgen Sie den Plattformanweisungen von govips.
Erstellen Sie in Bash ein neues Projekt und wechseln Sie erst hinein, wenn die Installation der Abhängigkeiten erfolgreich war. Behalten Sie beide Moduldateien. Schlägt die Installation fehl, prüfen Sie das teilweise erstellte Verzeichnis, bevor Sie es erneut versuchen:
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
Einen einfachen Bildverarbeitungsserver bauen
Legen Sie das vollständige Programm in main.go ab. Es akzeptiert JPEG- und PNG-Eingaben und gibt ein
Standbild aus. Formate und Operationsparameter werden explizit angegeben. Sowohl das Bild als auch
das optionale Wasserzeichen durchlaufen dieselben Größen- und Decoder-Prüfungen.
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)
}
}
Führen Sie go run . in image-api aus und lassen Sie dieses Terminal geöffnet. Die Standardadresse ist
127.0.0.1:8080; setzen Sie LISTEN_ADDR, um eine andere Adresse zu verwenden, falls der Port belegt ist. Jede
Datei ist auf 8 MiB, 4.096 Pixel pro Seite und vier Millionen Pixel begrenzt. Beide Dateien und der
Multipart-Overhead müssen zusammen in das Anfragelimit von 8 MiB + 64 KiB passen, einschließlich der
Bytes nach dem Multipart-Terminator. Multipart-Daten können in temporäre Dateien auf der Festplatte
ausgelagert werden; der aufgeschobene Aufruf RemoveAll() bereinigt sie bei erfolgreichen und
fehlgeschlagenen Anfragen. Wird der Prozess abrupt beendet, können temporäre Dateien zurückbleiben.
Gängige Bildoperationen implementieren
Verwenden Sie in einem zweiten Terminal ein Verzeichnis, das Ihre eigenen Dateien photo.jpg, photo.png
und logo.png enthält. Das Zuschnittbeispiel benötigt ein Foto, das nach der Ausrichtung mindestens
110 × 90 Pixel groß ist. Die Ausgabe ist ein Standbild; diese API ist kein Workflow zum Erhalten von
Animationen. Diese cURL-Befehle überschreiben ihre jeweils benannten Ausgaben. Mit --fail-with-body kann
ein HTTP-Fehler eine Ausgabe durch Fehlertext ersetzen; prüfen Sie den Exit-Status, bevor Sie sie
als Bild öffnen. Fehlt eine lokale Eingabe, kann der Befehl fehlschlagen, bevor eine vorhandene
Ausgabe ersetzt wird.
curl --fail-with-body -F 'file=@photo.jpg' \
'http://127.0.0.1:8080/process?operation=resize&width=320&height=180' -o resized.png
Die Größenänderung streckt das Bild auf die angeforderten Abmessungen. Breite und Höhe werden zu
horizontalen und vertikalen Skalierungsfaktoren; würde man Pixelzahlen direkt an ResizeWithVScale übergeben,
entstünde ein sehr großes Bild.
Ein Bild zuschneiden
Die Zuschnittkoordinaten gelten nach der EXIF-Ausrichtung:
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
Der Zuschnitt muss vollständig innerhalb der Eingabe liegen. Prüfungen auf Basis von Subtraktionen verhindern einen Überlauf bei der Summe nicht vertrauenswürdiger Offsets und Größen.
Ein Wasserzeichen hinzufügen
Stellen Sie ein JPEG oder ein transparentes PNG bereit, das kleiner als das Basisbild ist. Es wird unten rechts platziert:
curl --fail-with-body -F 'file=@photo.jpg' -F 'watermark=@logo.png' \
'http://127.0.0.1:8080/process?operation=watermark' -o watermarked.png
Bildformate konvertieren
Bei JPEG-Ausgaben wird Transparenz auf einen weißen Hintergrund gelegt. PNG und WebP können sie
beibehalten. Der Loader konvertiert 16-Bit-PNG-Samples vor der Verarbeitung in 8-Bit-sRGB: Der weiße
Hintergrundwert 255 muss denselben Wertebereich wie das Bild verwenden. Siehe die
libvips-Dokumentation zur Farbkonvertierung
und zum Alpha-Flattening.
JPEG- und WebP-Exporte verwenden verlustbehaftete Standardeinstellungen; dies ist kein verlustfreier
Archivierungs-Workflow.
curl --fail-with-body -F 'file=@photo.png' \
'http://127.0.0.1:8080/process?operation=convert&format=jpeg' -o converted.jpg
Leistung und Speicherverbrauch optimieren
Libvips-Konfiguration abstimmen
Das Beispiel legt einen nativen Thread pro Operation fest und deaktiviert den Operations-Cache. Stimmen Sie diese Einstellungen anhand echter Messungen ab. Größenänderung und Zuschnitt akzeptieren Ausgabeseitenlängen bis zu 2.048 Pixel. Eine Größenänderung kann daher 2.048 × 2.048 Pixel erzeugen, etwas mehr als das Eingabelimit von vier Millionen Pixeln. Konvertierung und Wasserzeichen behalten die Abmessungen des Basisbilds bei, die auf einer Seite 2.048 überschreiten können. Diese Prüfungen und das Limit von zwei Anfragen begrenzen weder jede native Speicherzuweisung noch die Größe der codierten Ausgabe.
Einen Worker-Pool implementieren
Der begrenzte Channel ist ein Semaphor, keine unbegrenzte Job-Queue. Eine dritte gleichzeitige Anfrage erhält sofort 503. So wird Backpressure explizit, ohne einen zweiten Satz von Funktionen zum Laden und Exportieren von Bildern. Größere Deployments benötigen Limits über alle Instanzen hinweg und angemessen isolierte Worker.
Ressourcennutzung überwachen
Messen Sie neben der Go-Heap-Nutzung auch den RSS des Prozesses: Native libvips-Speicherzuweisungen liegen außerhalb des Go-Heaps. Messen Sie außerdem temporären Festplattenspeicher, abgelehnte Anfragen, Decoder-Fehler und Latenz. HTTP-Deadlines brechen die native Verarbeitung nicht ab. Setzen Sie harte CPU- und Speicherlimits mithilfe von Prozessisolation durch.
Fehlerhafte Header und Decoder-Fehler führen zu Fehlermeldungen, doch erfolgreiches Decodieren ist keine Integritätsprüfung. Wenn Sie unbekannte Dateien akzeptieren, prüfen Sie die vollständige Ausgabe einschließlich ihrer Ränder. Das Programm entfernt die EXIF-Daten der Eingabe vor dem Export, weil eine Strip-Option allein sie bei älteren libvips-Exportern beibehalten kann. govips behält intern einige technische Metadaten bei, darunter Farbprofile. Dieses Beispiel ist weder ein umfassendes Werkzeug zum Entfernen privater Daten noch ein Workflow zur originalgetreuen Wiedergabe beliebiger eingebetteter Farbprofile.
Mit Docker bereitstellen
Verwenden Sie für die Build- und Laufzeitbibliotheken passende Debian-Releases. Speichern Sie
Folgendes als Dockerfile in image-api, neben go.mod, go.sum und 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"]
Stoppen Sie den lokalen Server, der mit go run läuft, bevor Sie den Container auf demselben Port
veröffentlichen. Bauen Sie in image-api das Image und veröffentlichen Sie es für einen lokalen Test
nur auf Loopback:
docker build -t image-api . &&
docker run --rm --stop-timeout=45 --memory=512m --cpus=2 -p 127.0.0.1:8080:8080 image-api
Bei SIGINT oder SIGTERM nimmt das Programm keine neuen Verbindungen mehr an und wartet bis zu 35 Sekunden auf aktive Handler. Die Stopp-Karenzzeit des Containers von 45 Sekunden lässt Zeit für dieses Herunterfahren; die Standard-Karenzzeit von Docker unter Linux beträgt 10 Sekunden. Dauert eine native Operation länger als die Deadline der Anwendung, beendet sich der Prozess ohne normale Bereinigung.
Ergänzen Sie vor einem öffentlichen Deployment TLS, Authentifizierung und Autorisierung, Body-Limits im Proxy, Schutzmaßnahmen gegen Missbrauch und eine geprüfte Decoder-Richtlinie. Das Container-Beispiel demonstriert die Paketierung, keine vollständige Mandantenisolation. Für eine verwaltete Verarbeitung entdecken Sie den Bildverarbeitungsdienst von Transloadit.
