Crea un servidor rápido para procesar imágenes con Go y Libvips
Go puede exponer una pequeña API HTTP mientras libvips realiza operaciones nativas sobre imágenes. Aquí usamos govips para redimensionar, recortar, añadir marcas de agua y convertir imágenes mediante un único manejador de solicitudes compartido.
Esta es una demostración con límites definidos en la interfaz de 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 verifican las solicitudes y las dimensiones.
¿Por qué Libvips?
Libvips evalúa los pipelines de imágenes 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 el rendimiento de tu carga de trabajo en lugar de suponer que siempre habrá una mejora de velocidad.
Configura tu entorno de Go
Requisitos previos
Usa una versión de Go con mantenimiento activo, un compilador de C, pkg-config y libvips. Este ejemplo se probó en Linux con Go 1.26.8, govips v2.18.0 y libvips 8.14.1 y 8.18.6. El módulo fijado declara Go 1.25.0 como versión mínima; ese mínimo es independiente de las versiones probadas aquí.
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 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 govips para cada plataforma para los ajustes
específicos del compilador.
En Bash, crea un proyecto nuevo y entra en él solo después de que las dependencias se hayan instalado correctamente. Conserva ambos archivos del módulo. Si la instalación falla, inspecciona el directorio creado parcialmente antes de volver a intentarlo:
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
Crea un servidor básico de procesamiento de imágenes
Coloca el programa completo en main.go. Acepta entradas JPEG y PNG y genera
una imagen estática. Los formatos y los parámetros de las operaciones son explícitos. Tanto la imagen
como la marca de agua opcional pasan por las mismas comprobaciones de tamaño y del 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
}
// 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)
}
}
Ejecuta go run . desde image-api y deja esa terminal abierta.
La dirección predeterminada es 127.0.0.1:8080; configura
LISTEN_ADDR para usar otra dirección si el puerto está ocupado. Cada archivo tiene
un límite de 8 MiB, 4.096 píxeles por lado y cuatro millones de píxeles. Ambos archivos y la sobrecarga
multipart deben caber juntos en el límite de solicitud de 8 MiB + 64 KiB, incluidos los bytes
posteriores al terminador multipart. Los datos multipart pueden volcarse al disco temporal;
la llamada diferida a RemoveAll() los elimina tanto en solicitudes exitosas como
fallidas. Una terminación abrupta del proceso puede dejar archivos temporales sin eliminar.
Implementa operaciones habituales sobre imágenes
En una segunda terminal, usa un directorio que contenga tus propios photo.jpg,
photo.png y logo.png.
El ejemplo de recorte necesita una foto de al menos 110 × 90 píxeles después de aplicar la orientación.
La salida es una imagen estática; esta API no es un flujo de trabajo para conservar animaciones.
Estos comandos de cURL sobrescriben los archivos de salida indicados. Con
--fail-with-body, un error HTTP puede reemplazar una salida por texto de error;
comprueba el estado de salida antes de abrirla como imagen. Si falta un archivo de entrada local,
la operación puede fallar antes de reemplazar una salida existente.
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 redimensionamiento estira la imagen hasta las dimensiones solicitadas. El ancho y el alto se
convierten en factores de escala horizontal y vertical; pasar cantidades de píxeles directamente a
ResizeWithVScale crearía una imagen muy grande.
Recorta 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 imagen de entrada. Las comprobaciones basadas en restas evitan el desbordamiento al sumar desplazamientos y tamaños no confiables.
Añade 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
Convierte formatos de imagen
La salida JPEG compone la transparencia sobre un fondo blanco. PNG y WebP pueden conservarla.
El cargador convierte las muestras PNG de 16 bits a sRGB de 8 bits antes del procesamiento: el valor
del fondo blanco 255 debe usar el mismo rango que la imagen. Consulta la
documentación de libvips sobre conversión de color
y aplanado del canal alfa.
Las exportaciones JPEG y WebP usan ajustes predeterminados con pérdida; este no es un flujo de
trabajo de archivado sin pérdida.
curl --fail-with-body -F 'file=@photo.png' \
'http://127.0.0.1:8080/process?operation=convert&format=jpeg' -o converted.jpg
Optimiza el rendimiento y el uso de memoria
Ajusta la configuración de Libvips
El ejemplo establece un hilo nativo por operación y desactiva la caché de operaciones. Ajusta esta configuración con mediciones reales. El redimensionamiento y el recorte admiten lados de salida de hasta 2.048 píxeles. Por lo tanto, el redimensionamiento puede producir 2.048 × 2.048 píxeles, un poco más que el límite de entrada de cuatro millones de píxeles. La conversión y la adición de marcas de agua conservan las dimensiones de la imagen base, que pueden superar los 2.048 en un lado. Estas comprobaciones y el límite de dos solicitudes no acotan todas las asignaciones de memoria nativa ni el tamaño de la salida codificada.
Implementa un grupo de procesos de trabajo
El canal acotado es un semáforo, no una cola de tareas sin límites. Una tercera solicitud simultánea recibe un 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 para toda la flota y procesos de trabajo debidamente aislados.
Monitorea el uso de recursos
Mide el RSS del proceso y el uso del heap de Go: las asignaciones nativas de libvips están fuera del heap de Go. Mide también el uso del disco temporal, las solicitudes rechazadas, los fallos del decodificador y la latencia. Los plazos límite de HTTP no cancelan el procesamiento nativo. Usa el aislamiento de procesos para imponer límites estrictos de CPU y memoria.
Las cabeceras malformadas y los fallos del decodificador producen errores, pero una decodificación exitosa no equivale a una comprobación de integridad. Al aceptar archivos desconocidos, inspecciona la salida completa, incluidos sus bordes. El programa elimina los datos EXIF de entrada antes de exportar porque, con exportadores antiguos de libvips, una opción de eliminación por sí sola puede conservarlos. govips conserva algunos metadatos técnicos internamente, incluidos los perfiles de color. Este ejemplo no es una herramienta integral de eliminación de datos privados ni un flujo de trabajo para reproducir con precisión cualquier perfil de color incrustado.
Despliega con Docker
Usa versiones de Debian coincidentes para las bibliotecas de compilación y de ejecución. Guarda esto
como Dockerfile en image-api, junto a
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"]
Detén el servidor local go run antes de publicar el contenedor en el mismo
puerto. Desde image-api, construye la imagen y publica solo en la interfaz de
loopback para una prueba 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
Al recibir SIGINT o SIGTERM, el programa deja de aceptar conexiones y espera hasta 35 segundos a los manejadores activos. El periodo de gracia de 45 segundos del contenedor deja tiempo para ese cierre; el periodo de gracia predeterminado de Docker en Linux es de 10 segundos. Si una operación nativa supera el plazo límite de la aplicación, el proceso termina sin la limpieza normal.
Antes de un despliegue público, añade TLS, autenticación y autorización, límites al cuerpo de las solicitudes en el proxy, controles contra el abuso y una política de decodificadores revisada. El ejemplo del contenedor demuestra el empaquetado, no el aislamiento completo entre inquilinos. Para un procesamiento administrado, explora el servicio de procesamiento de imágenes de Transloadit.
