Einen schnellen Bildverarbeitungs-Server mit Go und Libvips bauen
Go kann eine kleine HTTP-API bereitstellen, während libvips native Bildoperationen ausführt. Hier verwenden wir govips, um Bilder über einen gemeinsamen Request-Handler in der Größe zu ändern, zuzuschneiden, mit Wasserzeichen zu versehen und zu konvertieren.
Dies ist eine begrenzte Loopback-Demonstration, kein authentifizierter öffentlicher Upload-Dienst. Native Bildverarbeitung benötigt Isolation und betriebliche Limits, selbst wenn Anfragen und Abmessungen geprüft werden.
Warum Libvips?
Libvips wertet Bild-Pipelines bei Bedarf aus und kann Zwischenschritte reduzieren. Die tatsächliche Geschwindigkeit und Speichernutzung hängen von der Operation, den Codecs, den Bildabmessungen und der Nebenläufigkeit ab. Vermessen Sie Ihre Arbeitslast mit Benchmarks, statt von einer universellen Beschleunigung auszugehen.
Ihre Go-Umgebung einrichten
Voraussetzungen
Verwenden Sie Go 1.25 oder neuer, einen C-Compiler, pkg-config und libvips 8.14 oder neuer. Das gepinnte govips-Modul erfordert Go 1.25, auch wenn ältere Dokumentation ein niedrigeres Minimum beschreibt.
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 1.25 oder neuer separat mithilfe der Go-Installationsanleitung.
Unter macOS installiert brew install vips pkg-config die nativen Bibliotheken. Folgen Sie den
Plattformanweisungen von govips für compilerspezifische
Einstellungen.
Erstellen Sie ein Projekt und behalten Sie beide Moduldateien:
mkdir image-api
cd image-api
go mod init example.com/image-api
go get github.com/davidbyttow/govips/v2/vips@v2.18.0
Einen einfachen Bildverarbeitungs-Server bauen
Legen Sie das vollständige Programm in der Datei main.go ab. Es akzeptiert JPEG- und PNG-Eingaben
und gibt ein Standbild aus. Formate und Operationsparameter sind 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
}
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) {
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() }
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.Print("Image server failed.")
os.Exit(1)
}
}
Führen Sie go run . aus. Beide Upload-Dateien zusammen müssen in das Anfragelimit passen.
Multipart-Daten können auf temporären Festplattenspeicher ausgelagert werden; das mit defer
aufgerufene RemoveAll() räumt sie bei erfolgreichen und fehlgeschlagenen Anfragen auf.
Gängige Bildoperationen umsetzen
Verwenden Sie für diese lokalen Anfragen Bilder, die Ihnen gehören. Die Ausgabe ist ein Standbild; diese API ist kein Workflow zur Erhaltung von Animationen.
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 Pixelanzahlen direkt an ResizeWithVScale übergeben,
entstünde ein sehr großes Bild.
Ein Bild zuschneiden
Die Zuschnittkoordinaten gelten nach der EXIF-Orientierung:
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 in die Eingabe passen. Auf Subtraktion basierende Prüfungen vermeiden einen Überlauf bei der Summe aus nicht vertrauenswürdigen Offsets und Größen.
Ein Wasserzeichen hinzufügen
Übergeben Sie ein JPEG oder ein transparentes PNG, 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
Die JPEG-Ausgabe kombiniert Transparenz mit einem weißen Hintergrund. PNG und WebP können sie erhalten:
curl --fail-with-body -F 'file=@photo.png' \
'http://127.0.0.1:8080/process?operation=convert&format=jpeg' -o converted.jpg
Performance und Speichernutzung optimieren
Die Libvips-Konfiguration abstimmen
Das Beispiel setzt einen nativen Thread pro Operation und deaktiviert den Operations-Cache. Stimmen Sie diese Einstellungen anhand echter Messungen ab. Das Quelllimit von 4 Millionen Pixeln, die Ausgabeseiten von 2048 Pixeln und zwei aktive Anfragen begrenzen die angenommene Arbeit; sie begrenzen nicht jede native Speicherzuweisung.
Einen Worker-Pool umsetzen
Der begrenzte Channel ist ein Semaphor, keine unbegrenzte Job-Queue. Eine dritte gleichzeitige Anfrage erhält sofort 503. Das macht Backpressure explizit, ohne einen zweiten Satz von Funktionen zum Laden und Exportieren von Bildern. Größere Deployments benötigen flottenweite Limits und angemessen isolierte Worker.
Ressourcennutzung überwachen
Messen Sie neben der Go-Heap-Nutzung auch den RSS des Prozesses: native libvips-Allokationen 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. Nutzen Sie Prozessisolation, um harte CPU- und Speicherlimits durchzusetzen.
Mit Docker bereitstellen
Verwenden Sie für die Build- und Laufzeitbibliotheken übereinstimmende Debian-Releases. Dieses
Dockerfile erwartet Ihre Dateien 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"]
Veröffentlichen Sie für einen lokalen Test nur auf Loopback:
docker build -t image-api .
docker run --rm --memory=512m --cpus=2 -p 127.0.0.1:8080:8080 image-api
Ergänzen Sie vor einem öffentlichen Deployment TLS, Authentifizierung und Autorisierung, Body-Limits im Proxy, Missbrauchskontrollen und eine geprüfte Decoder-Richtlinie. Das Container-Beispiel demonstriert das Packaging, nicht die vollständige Mandantenisolation. Für verwaltete Verarbeitung entdecken Sie den Dienst zur Bildverarbeitung von Transloadit.
