Criar servidor rápido para processar imagens com Go e Libvips
Go pode expor uma pequena API HTTP enquanto a libvips executa operações nativas de imagem. Aqui, usamos govips para redimensionar, recortar, aplicar marcas-d’água e converter imagens por meio de um único manipulador de requisições compartilhado.
Esta é uma demonstração com limites definidos e restrita a loopback, não um serviço público de upload com autenticação. O processamento nativo de imagens precisa de isolamento e 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 real e o uso de memória 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 universal de velocidade.
Configurar seu ambiente Go
Pré-requisitos
Use uma versão do Go que ainda receba manutenção, um compilador C, pkg-config e libvips. Este exemplo foi testado no Linux com Go 1.26.8, govips v2.18.0 e libvips 8.14.1 e 8.18.6. O módulo com versão fixada declara Go 1.25.0 como versão mínima; esse mínimo é independente das versões testadas aqui.
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 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.
No Bash, crie um projeto e entre no diretório dele somente após a instalação bem-sucedida das dependências. Mantenha os dois arquivos de módulo. Se a instalação falhar, inspecione o diretório parcialmente criado antes de tentar novamente:
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
Criar 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 de operação 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
}
// 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)
}
}
Execute go run . a partir de image-api e deixe esse terminal
aberto. O endereço padrão é 127.0.0.1:8080; defina LISTEN_ADDR para
usar outro endereço se a porta estiver ocupada. Cada arquivo está limitado a 8 MiB, 4.096 pixels por
lado e quatro milhões de pixels. Os dois arquivos e a sobrecarga multipart, juntos, devem caber no
limite de 8 MiB + 64 KiB da requisição, incluindo os bytes após o terminador multipart. Os dados
multipart podem ser gravados temporariamente em disco; a chamada adiada a
RemoveAll() faz a limpeza tanto nas requisições bem-sucedidas quanto nas que falham.
O encerramento abrupto do processo pode deixar arquivos temporários para trás.
Implementar operações comuns de imagem
Em um segundo terminal, use um diretório que contenha seus próprios photo.jpg,
photo.png e logo.png.
O exemplo de recorte precisa de uma foto com pelo menos 110 × 90 pixels após a aplicação da orientação.
A saída é uma imagem estática; esta API não é um fluxo de trabalho para preservar animações. Estes
comandos cURL sobrescrevem os arquivos de saída nomeados. Com --fail-with-body, um erro
HTTP pode substituir um arquivo de saída por texto de erro; verifique o status de término antes de
abri-lo como imagem. A ausência de um arquivo de entrada local pode causar uma falha antes que uma
saída existente seja substituída.
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 se tornam
fatores de escala horizontal e vertical; passar quantidades de pixels diretamente para
ResizeWithVScale criaria uma imagem muito grande.
Recortar uma imagem
As coordenadas de recorte se aplicam após a 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 deve caber inteiramente na imagem de entrada. Verificações baseadas em subtração evitam estouro na soma de deslocamentos e tamanhos não confiáveis.
Adicionar uma marca-d’água
Forneça um JPEG ou 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
Converter formatos de imagem
A saída JPEG compõe a transparência sobre um fundo branco. PNG e WebP podem preservá-la. O carregador
converte amostras PNG de 16 bits para sRGB de 8 bits antes do processamento: o valor de fundo branco
255 deve usar a mesma faixa de valores da imagem. Consulte a documentação
da libvips sobre conversão de cores
e achatamento do canal alfa.
As exportações JPEG e WebP usam configurações padrão com perdas; este não é um fluxo de trabalho de
arquivamento sem perdas.
curl --fail-with-body -F 'file=@photo.png' \
'http://127.0.0.1:8080/process?operation=convert&format=jpeg' -o converted.jpg
Otimizar o desempenho e o uso de memória
Ajustar 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 redimensionamento e o recorte aceitam lados de saída de até 2.048 pixels. Assim, o redimensionamento pode produzir 2.048 × 2.048 pixels, um pouco mais que o limite de quatro milhões de pixels da entrada. A conversão e a aplicação de marcas-d’água mantêm as dimensões da imagem base, que podem exceder 2.048 em um dos lados. Essas verificações e o limite de duas requisições não limitam todas as alocações nativas nem o tamanho da saída codificada.
Implementar um pool de workers
O canal com capacidade limitada é um semáforo, não uma fila ilimitada de tarefas. Uma terceira requisição simultânea recebe 503 imediatamente. Isso torna explícito o controle de contrapressão, sem um segundo conjunto de funções de carregamento e exportação de imagens. Implantações maiores precisam de limites que abranjam toda a frota e de workers adequadamente isolados.
Monitorar o uso de recursos
Meça o RSS do processo e também o uso do heap do Go: as alocações nativas da libvips ficam fora do heap do Go. Meça também o uso temporário de disco, as requisições rejeitadas, as falhas do decodificador e a latência. Os prazos-limite HTTP não cancelam o processamento nativo. Use isolamento de processos para impor limites rígidos de CPU e memória.
Cabeçalhos malformados e falhas do decodificador produzem erros, mas uma decodificação bem-sucedida não é uma verificação de integridade. Ao aceitar arquivos desconhecidos, inspecione toda a saída, incluindo suas bordas. O programa remove o EXIF da entrada antes da exportação porque, com exportadores mais antigos da libvips, usar apenas uma opção de remoção de metadados pode preservá-lo. O govips mantém alguns metadados técnicos internamente, incluindo perfis de cores. Este exemplo não é uma ferramenta abrangente de remoção de dados para privacidade nem um fluxo de trabalho para reprodução precisa de perfis de cores incorporados arbitrários.
Implantar com docker
Use versões correspondentes do Debian para as bibliotecas de compilação e de execução. Salve isto
como Dockerfile em image-api, ao lado de
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"]
Pare o servidor local go run antes de publicar o contêiner na mesma porta.
A partir de image-api, compile a imagem e publique apenas em loopback para um
teste 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
Ao receber SIGINT ou SIGTERM, o programa para de aceitar conexões e aguarda até 35 segundos pelos manipuladores ativos. O período de tolerância de 45 segundos para parada do contêiner deixa tempo para esse encerramento; no Docker, o período de tolerância padrão no Linux é de 10 segundos. Se uma operação nativa ultrapassar o prazo-limite da aplicação, o processo termina sem a limpeza normal.
Antes de uma implantação pública, adicione TLS, autenticação e autorização, limites de corpo das requisições no proxy, controles contra abuso e uma política de decodificadores revisada. O exemplo de contêiner demonstra o empacotamento, não o isolamento completo entre tenants. Para processamento gerenciado, conheça o serviço de processamento de imagens da Transloadit.
