Servidor rápido de procesamiento de imágenes con Go y Libvips
Go puede exponer una pequeña API HTTP mientras libvips ejecuta operaciones nativas sobre las imágenes. Aquí usamos govips para redimensionar, recortar, aplicar marcas de agua y convertir imágenes mediante un único manejador de solicitudes compartido.
Esta es una demostración acotada en loopback, no un servicio público de subida con autenticación. El procesamiento nativo de imágenes necesita aislamiento y límites operativos incluso cuando se validan las solicitudes y las dimensiones.
¿Por qué Libvips?
Libvips evalúa los pipelines de imagen bajo demanda y puede reducir el trabajo intermedio. La velocidad y el uso de memoria reales dependen de la operación, los códecs, las dimensiones de la imagen y la concurrencia. Mide tu carga de trabajo con benchmarks en lugar de suponer una aceleración universal.
Configurar tu entorno de Go
Requisitos previos
Usa Go 1.25 o una versión más reciente, un compilador de C, pkg-config y libvips 8.14 o una versión más reciente. El módulo govips fijado requiere Go 1.25 aunque documentación más antigua describa un mínimo inferior.
Instalación
En Ubuntu 24.04, instala la biblioteca de desarrollo nativa:
sudo apt-get install --no-install-recommends build-essential pkg-config libvips-dev
Instala Go 1.25 o una versión más reciente por separado con la guía de instalación de Go.
En macOS, brew install vips pkg-config instala las bibliotecas nativas. Sigue las
instrucciones de plataforma de govips para los ajustes específicos
del compilador.
Crea un proyecto y conserva ambos archivos de módulo:
mkdir image-api
cd image-api
go mod init example.com/image-api
go get github.com/davidbyttow/govips/v2/vips@v2.18.0
Construir un servidor básico de procesamiento de imágenes
Coloca el programa completo en main.go. Acepta entradas JPEG y PNG y emite una imagen fija. Los formatos
y los parámetros de operación son explícitos. Tanto la imagen como la marca de agua opcional pasan
por las mismas comprobaciones de tamaño y de decodificador.
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)
}
}
Ejecuta go run .. Ambos archivos subidos juntos deben caber dentro del límite de la solicitud. Los datos
multipart pueden desbordarse a disco temporal; el RemoveAll() diferido los limpia tanto en las solicitudes
exitosas como en las fallidas.
Implementar operaciones comunes con imágenes
Usa imágenes de tu propiedad para estas solicitudes locales. La salida es una imagen fija; esta API no es un flujo de trabajo de preservación de animaciones.
curl --fail-with-body -F 'file=@photo.jpg' \
'http://127.0.0.1:8080/process?operation=resize&width=320&height=180' -o resized.png
El redimensionado estira la imagen hasta las dimensiones solicitadas. El ancho y el alto se
convierten en factores de escala horizontal y vertical; pasar directamente cantidades de píxeles a ResizeWithVScale crearía una imagen muy grande.
Recortar una imagen
Las coordenadas de recorte se aplican después de la orientación 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
El recorte debe caber por completo dentro de la entrada. Las comprobaciones basadas en restas evitan el desbordamiento de una suma de desplazamientos y tamaños no confiables.
Añadir una marca de agua
Proporciona un JPEG o un PNG transparente más pequeño que la imagen base. Se coloca en la esquina inferior derecha:
curl --fail-with-body -F 'file=@photo.jpg' -F 'watermark=@logo.png' \
'http://127.0.0.1:8080/process?operation=watermark' -o watermarked.png
Convertir formatos de imagen
La salida JPEG compone la transparencia sobre blanco. PNG y WebP pueden conservarla:
curl --fail-with-body -F 'file=@photo.png' \
'http://127.0.0.1:8080/process?operation=convert&format=jpeg' -o converted.jpg
Optimizar el rendimiento y el uso de memoria
Ajustar la configuración de Libvips
El ejemplo establece un hilo nativo por operación y desactiva la caché de operaciones. Ajusta estas opciones con mediciones reales. El límite de 4 millones de píxeles en la fuente, los lados de salida de 2048 píxeles y las dos solicitudes activas limitan el trabajo aceptado; no acotan todas las asignaciones nativas de memoria.
Implementar un pool de workers
El canal acotado es un semáforo, no una cola de trabajos ilimitada. Una tercera solicitud simultánea recibe 503 de inmediato. Esto hace explícita la contrapresión sin un segundo conjunto de funciones de carga y exportación de imágenes. Los despliegues más grandes necesitan límites a nivel de toda la flota y workers adecuadamente aislados.
Monitorear el uso de recursos
Mide el RSS del proceso además del uso del heap de Go: las asignaciones nativas de libvips quedan fuera del heap de Go. Mide también el disco temporal, las solicitudes rechazadas, los fallos del decodificador y la latencia. Los tiempos límite de HTTP no cancelan el procesamiento nativo. Usa aislamiento de procesos para imponer límites estrictos de CPU y memoria.
Desplegar con docker
Usa versiones de Debian coincidentes para las bibliotecas de compilación y de ejecución. Este
Dockerfile espera tus go.mod, go.sum y 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"]
Publica solo en loopback para una prueba local:
docker build -t image-api .
docker run --rm --memory=512m --cpus=2 -p 127.0.0.1:8080:8080 image-api
Antes de un despliegue público, añade TLS, autenticación y autorización, límites de cuerpo en el proxy, controles antiabuso y una política de decodificadores revisada. El ejemplo de contenedor demuestra el empaquetado, no un aislamiento completo entre inquilinos. Para un procesamiento gestionado, explora el servicio de procesamiento de imágenes de Transloadit.
