Crie um servidor rápido para processar imagens com Go e Libvips
O Go pode expor uma pequena API HTTP enquanto a libvips executa operações nativas com imagens. Aqui usamos o govips para redimensionar, recortar, aplicar marca-d’água e converter imagens por meio de um único handler de requisições compartilhado.
Esta é uma demonstração limitada em loopback, não um serviço público de upload autenticado. O processamento nativo de imagens precisa de isolamento e de limites operacionais mesmo quando as requisições e as dimensões são verificadas.
Por que Libvips?
A libvips avalia pipelines de imagem sob demanda e pode reduzir o trabalho intermediário. A velocidade e o uso de memória reais dependem da operação, dos codecs, das dimensões da imagem e da concorrência. Faça benchmarks da sua carga de trabalho em vez de presumir um ganho de velocidade universal.
Configurando seu ambiente Go
Pré-requisitos
Use Go 1.25 ou mais recente, um compilador C, pkg-config e libvips 8.14 ou mais recente. O módulo govips fixado exige Go 1.25, mesmo que documentações mais antigas descrevam um mínimo inferior.
Instalação
No Ubuntu 24.04, instale a biblioteca nativa de desenvolvimento:
sudo apt-get install --no-install-recommends build-essential pkg-config libvips-dev
Instale o Go 1.25 ou mais recente separadamente usando o guia de instalação do Go.
No macOS, brew install vips pkg-config instala as bibliotecas nativas. Siga as
instruções de plataforma do govips para configurações específicas do
compilador.
Crie um projeto e mantenha os dois arquivos do 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
Criando um servidor básico de processamento de imagens
Coloque o programa completo em main.go. Ele aceita entradas JPEG e PNG e gera uma imagem estática.
Os formatos e os parâmetros das operações são explícitos. Tanto a imagem quanto a marca-d’água
opcional passam pelas mesmas verificações de tamanho e 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)
}
}
Execute go run .. Os dois arquivos enviados, juntos, precisam caber no limite da requisição. Os
dados multipart podem ser gravados temporariamente em disco; o RemoveAll() adiado faz a limpeza tanto
em requisições bem-sucedidas quanto em requisições com falha.
Implementando operações comuns com imagens
Use imagens que sejam suas nessas requisições locais. A saída é uma imagem estática; esta API não é um fluxo de trabalho para preservar animações.
curl --fail-with-body -F 'file=@photo.jpg' \
'http://127.0.0.1:8080/process?operation=resize&width=320&height=180' -o resized.png
O redimensionamento estica a imagem até as dimensões solicitadas. A largura e a altura viram fatores
de escala horizontal e vertical; passar quantidades de pixels diretamente para ResizeWithVScale criaria
uma imagem muito grande.
Recortando uma imagem
As coordenadas de recorte são aplicadas depois da orientação 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
O recorte precisa caber inteiramente dentro da entrada. Verificações baseadas em subtração evitam o overflow de uma soma de deslocamentos e tamanhos não confiáveis.
Adicionando uma marca-d’água
Forneça um JPEG ou um PNG transparente menor que a imagem base. Ele é posicionado no canto inferior direito:
curl --fail-with-body -F 'file=@photo.jpg' -F 'watermark=@logo.png' \
'http://127.0.0.1:8080/process?operation=watermark' -o watermarked.png
Convertendo formatos de imagem
A saída em JPEG compõe a transparência sobre um fundo branco. PNG e WebP podem mantê-la:
curl --fail-with-body -F 'file=@photo.png' \
'http://127.0.0.1:8080/process?operation=convert&format=jpeg' -o converted.jpg
Otimizando desempenho e uso de memória
Ajustando a configuração da Libvips
O exemplo define uma thread nativa por operação e desativa o cache de operações. Ajuste essas configurações com medições reais. O limite de 4 milhões de pixels na origem, os lados de saída de 2048 pixels e as duas requisições ativas limitam o trabalho aceito; eles não limitam todas as alocações nativas.
Implementando um pool de workers
O canal limitado é um semáforo, não uma fila de trabalhos ilimitada. Uma terceira requisição simultânea recebe 503 imediatamente. Isso torna o backpressure explícito sem exigir um segundo conjunto de funções de carregamento e exportação de imagens. Implantações maiores precisam de limites válidos para toda a frota e de workers devidamente isolados.
Monitorando o uso de recursos
Meça o RSS do processo, além do uso do heap do Go: as alocações nativas da libvips ficam fora do heap do Go. Meça também o disco temporário, as requisições rejeitadas, as falhas de decodificador e a latência. Os prazos HTTP não cancelam o processamento nativo. Use isolamento de processos para impor limites rígidos de CPU e memória.
Implantando com Docker
Use versões correspondentes do Debian para as bibliotecas de build e de runtime. Este Dockerfile
espera seus arquivos go.mod, go.sum e 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"]
Publique apenas no loopback para um teste local:
docker build -t image-api .
docker run --rm --memory=512m --cpus=2 -p 127.0.0.1:8080:8080 image-api
Antes de uma implantação pública, adicione TLS, autenticação e autorização, limites de corpo no proxy, controles contra abuso e uma política de decodificadores revisada. O exemplo de contêiner demonstra o empacotamento, não um isolamento completo entre locatários. Para processamento gerenciado, conheça o serviço de processamento de imagens da Transloadit.
