Dockeritzar una API en Go: binaris petits, imatges netes i desplegament simple
Dockerfile multi-stage per a Go, binaris estàtics, variables d'entorn, healthcheck i desplegament. Imatges mínimes i sense sorpreses.

Una API en Go compila a un binari únic. Una imatge Docker d’aquest binari pot pesar menys de 10 MB. Compara això amb la imatge típica d’una aplicació Java (300-500 MB amb la JVM), Python (200-400 MB amb dependències) o Node (200-300 MB amb node_modules). Aquesta diferència no és cosmètica: afecta el temps de desplegament, el cost d’emmagatzematge d’imatges, l’arrencada en fred i la superfície d’atac del teu contenidor.
Vinc de desplegar serveis en Kotlin amb Spring Boot. Cada imatge Docker partia de 400 MB com a mínim. El Dockerfile era un artefacte d’enginyeria: multi-stage amb Maven, cacheo de dependències, layer caching, i tot i així el resultat era pesat i lent d’arrencar. Quan vaig construir la meva primera imatge Docker per a Go, el Dockerfile tenia 12 línies i la imatge final pesava 8 MB. No perquè jo fos més intel·ligent, sinó perquè Go elimina la major part de la complexitat que Docker ha de gestionar en altres llenguatges.
Per què Go i Docker encaixen tan bé
Docker resol un problema fonamental: empaquetar una aplicació amb totes les seves dependències perquè funcioni igual en qualsevol entorn. Com més dependències té la teva aplicació, més feina té Docker. I més coses poden trencar-se.
Go minimitza aquest problema per disseny:
- Binari estàtic: sense runtime, sense intèrpret, sense màquina virtual. El binari és l’aplicació completa.
- Sense dependències del sistema: amb
CGO_ENABLED=0, el binari no enllaça contra libc ni cap biblioteca compartida. Funciona en qualsevol Linux. - Cross-compilation trivial: compilar per a
linux/amd64des de macOS és una variable d’entorn, no un pipeline. - Arrencada instantània: sense warm-up de JVM, sense càrrega de mòduls, sense JIT. El procés comença a servir peticions en mil·lisegons.
- Consum de memòria predictible: sense garbage collector pesat ni overhead de runtime. Un servei HTTP bàsic arrenca amb 5-10 MB de RAM.
Tot això significa que la imatge Docker de la teva API en Go pot ser extremadament petita, ràpida de construir i ràpida de desplegar. I no necessites trucs per aconseguir-ho.
Dockerfile bàsic: una sola etapa
Comencem pel més simple. Un Dockerfile d’una sola etapa que compila i executa la teva API:
FROM golang:1.23-alpine
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server ./cmd/api
EXPOSE 8080
CMD [\"./server\"]Això funciona. La teva API compila i s’executa dins del contenidor. Però hi ha un problema evident: la imatge final inclou tot el toolchain de Go (compilador, eines, fonts), totes les dependències descarregades i el teu codi font. El resultat és una imatge de 300-400 MB.
docker build -t la-meva-api:single .
docker images la-meva-api:single
# REPOSITORY TAG SIZE
# la-meva-api single 387MBAixò és inacceptable per a producció. Estàs desplegant el compilador juntament amb la teva aplicació. És com enviar el taller mecànic juntament amb el cotxe.
Multi-stage build: l’enfocament estàndard
La solució a Docker és usar builds multi-stage. Compiles en una etapa amb totes les eines necessàries i copies només el binari resultant a una imatge final mínima.
# === Etapa 1: compilació ===
FROM golang:1.23-alpine AS builder
WORKDIR /app
# Caché de dependències
COPY go.mod go.sum ./
RUN go mod download
# Compilar
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags=\"-s -w\" -o server ./cmd/api
# === Etapa 2: imatge final ===
FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
USER nobody:nobody
CMD [\"./server\"]Analitzem les decisions clau:
CGO_ENABLED=0: genera un binari estàtic sense dependències de C. Imprescindible perquè funcioni en imatges mínimes.GOOS=linux GOARCH=amd64: compilació creuada explícita. Encara que estiguis compilant a Linux, és bona pràctica declarar-ho.-ldflags=\"-s -w\": elimina la taula de símbols i la informació de depuració. Redueix la mida del binari un 20-30%.ca-certificates: necessari si la teva API fa crides HTTPS a serveis externs. Sense això, els certificats TLS no es validen.tzdata: necessari si treballes amb zones horàries. Sense això,time.LoadLocation(\"Europe/Madrid\")falla.USER nobody:nobody: el contenidor no s’executa com a root. Seguretat bàsica que massa gent oblida.
El resultat:
docker build -t la-meva-api:multi .
docker images la-meva-api:multi
# REPOSITORY TAG SIZE
# la-meva-api multi 18MBDe 387 MB a 18 MB. I la imatge final només conté el teu binari, els certificats i les dades de zona horària. Res més.
Scratch vs distroless vs Alpine
La imatge base de l’etapa final és una decisió important. Hi ha tres opcions habituals, cadascuna amb trade-offs diferents.
scratch
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD [\"/server\"]scratch és una imatge buida. Literalment no té res: ni shell, ni eines, ni libc, ni /tmp, ni /etc. El teu binari és l’únic que existeix dins del contenidor.
Avantatges: imatge mínima (només el teu binari, ~8-12 MB), superfície d’atac zero.
Desavantatges: no pots fer docker exec -it contenidor sh per depurar. No hi ha /etc/passwd, de manera que USER no funciona de la manera habitual. Si la teva aplicació necessita fitxers temporals, has de crear /tmp explícitament.
gcr.io/distroless/static-debian12
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
CMD [\"/server\"]Distroless de Google inclou certificats CA, tzdata i un usuari nonroot configurat, però sense shell ni gestor de paquets. És un bon punt intermedi entre scratch i Alpine.
Avantatges: certificats i zones horàries inclosos, usuari non-root preconfigurado, ~2 MB de base.
Desavantatges: sense shell per a depuració, dependència de les imatges de Google.
alpine
FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /app/server /server
CMD [\"/server\"]Alpine és una distribució Linux mínima (~5 MB) amb shell, gestor de paquets i eines bàsiques.
Avantatges: pots entrar al contenidor i depurar, instal·lar eines si les necessites, imatge petita (~8 MB de base).
Desavantatges: lleugerament més gran que scratch/distroless, usa musl libc en lloc de glibc (irrellevant si compiles amb CGO_ENABLED=0).
La meva recomanació
Per a la majoria d’equips i projectes, Alpine és l’opció pragmàtica. La diferència de mida amb scratch és de 5-8 MB, i a canvi tens un shell per quan alguna cosa falla en producció a les 3 de la matinada. Si la teva organització té requisits de seguretat estrictes i no necessita depuració interactiva, distroless és millor opció que scratch perquè inclou el mínim necessari sense que ho hagis de copiar manualment.
Binaris estàtics: CGO_ENABLED=0 i el que implica
CGO_ENABLED=0 és la peça clau perquè tot això funcioni. Per defecte, Go pot enllaçar dinàmicament contra libc per a certs paquets de la biblioteca estàndard (net, os/user). Amb CGO_ENABLED=0, Go usa implementacions pures en Go per a tot.
# Amb CGO habilitat (per defecte en alguns casos)
CGO_ENABLED=1 go build -o server ./cmd/api
ldd server
# linux-vdso.so.1 => ...
# libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
# Amb CGO deshabilitat
CGO_ENABLED=0 go build -o server ./cmd/api
ldd server
# not a dynamic executableAmb CGO_ENABLED=0, el binari és completament autocontingut. Pots copiar-lo a qualsevol sistema Linux amb l’arquitectura correcta i funcionarà. Sense biblioteques compartides, sense dependències.
Quan NO pots usar CGO_ENABLED=0:
- Si uses SQLite a través de
mattn/go-sqlite3(requereix CGO). - Si depens de biblioteques C natives que no tenen equivalent pur en Go.
- Si uses paquets que emboliquen codi C (certs drivers de bases de dades, biblioteques de criptografia especialitzades).
En aquests casos necessites una imatge base que inclogui libc (Alpine amb musl o Debian), i el teu Dockerfile es complica. Si pots evitar CGO, evita’l. Per a una API REST amb Go estàndard amb PostgreSQL (usant pgx, que és pur Go), no necessites CGO.
Les flags de ldflags també importen:
# Sense ldflags
go build -o server ./cmd/api
ls -lh server # 12.4 MB
# Amb -s -w (sense símbols ni informació de depuració)
go build -ldflags=\"-s -w\" -o server ./cmd/api
ls -lh server # 8.7 MB
# Injectant versió en temps de compilació
go build -ldflags=\"-s -w -X main.version=1.2.3 -X main.commitHash=$(git rev-parse --short HEAD)\" \
-o server ./cmd/apiInjectar la versió i el hash del commit al binari és pràctica estàndard. Et permet saber exactament quina versió s’està executant en producció sense dependre d’etiquetes Docker.
package main
var (
version = \"dev\"
commitHash = \"unknown\"
)
func main() {
log.Printf(\"Starting server version=%s commit=%s\", version, commitHash)
// ...
}Variables d’entorn i configuració
Una API en un contenidor Docker rep la seva configuració per variables d’entorn. És l’estàndard de les 12-Factor Apps i és el que esperen tots els orquestradors (Kubernetes, ECS, Docker Compose).
A Go, llegir variables d’entorn és trivial amb la biblioteca estàndard:
package config
import (
\"fmt\"
\"os\"
\"strconv\"
)
type Config struct {
Port int
DatabaseURL string
LogLevel string
Environment string
}
func Load() (*Config, error) {
port, err := strconv.Atoi(getEnv(\"PORT\", \"8080\"))
if err != nil {
return nil, fmt.Errorf(\"invalid PORT: %w\", err)
}
dbURL := os.Getenv(\"DATABASE_URL\")
if dbURL == \"\" {
return nil, fmt.Errorf(\"DATABASE_URL is required\")
}
return &Config{
Port: port,
DatabaseURL: dbURL,
LogLevel: getEnv(\"LOG_LEVEL\", \"info\"),
Environment: getEnv(\"ENVIRONMENT\", \"development\"),
}, nil
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}Al Dockerfile, pots declarar valors per defecte amb ENV:
FROM alpine:3.20
ENV PORT=8080
ENV LOG_LEVEL=info
ENV ENVIRONMENT=production
COPY --from=builder /app/server /app/server
CMD [\"/app/server\"]Però no posis secrets al Dockerfile. Mai. Ni la URL de la base de dades, ni API keys, ni tokens. Aquests valors es passen en temps d’execució:
docker run -d \
-e DATABASE_URL=\"postgres://user:pass@db:5432/mydb?sslmode=disable\" \
-e PORT=8080 \
-p 8080:8080 \
la-meva-api:latestSi la teva configuració es torna més complexa, hi ha biblioteques com caarlos0/env, kelseyhightower/envconfig o koanf que parsegen variables d’entorn en Go directament a structs amb validació inclosa. Però per a la majoria de serveis, os.Getenv amb una funció helper cobreix de sobra.
Health checks
Un contenidor que arrenca no és un contenidor que funciona. Docker i els orquestradors necessiten saber si la teva aplicació és viva i preparada per rebre tràfic.
Primer, exposa un endpoint /health a la teva API:
func main() {
mux := http.NewServeMux()
mux.HandleFunc(\"GET /health\", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(\"Content-Type\", \"application/json\")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{\"status\":\"ok\"}`)
})
// Si vols un check més complet que verifiqui la DB
mux.HandleFunc(\"GET /ready\", func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, `{\"status\":\"error\",\"detail\":\"%s\"}`, err.Error())
return
}
w.Header().Set(\"Content-Type\", \"application/json\")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{\"status\":\"ok\"}`)
})
// ... resta de rutes
}La distinció entre liveness (/health) i readiness (/ready) importa:
- Liveness: “El procés és viu i no penjat.” Si falla, Docker/Kubernetes reinicia el contenidor.
- Readiness: “L’aplicació està preparada per rebre tràfic.” Si falla, l’orquestrador deixa d’enviar-li peticions però no la reinicia.
Al Dockerfile, configura el health check:
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD [\"/app/server\", \"-health\"]Una alternativa més simple que no requereix modificar el teu binari:
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1Si uses Alpine, wget és disponible. Si uses scratch o distroless, necessites compilar un binari de health check o usar la primera opció (un flag al teu propi binari):
func main() {
if len(os.Args) > 1 && os.Args[1] == \"-health\" {
resp, err := http.Get(\"http://localhost:8080/health\")
if err != nil || resp.StatusCode != 200 {
os.Exit(1)
}
os.Exit(0)
}
// Arrencada normal del servidor...
}Aquest patró és elegant: el teu binari pot funcionar com a servidor i com a health checker. Sense dependències externes.
Docker Compose per a desenvolupament local
En desenvolupament necessites més que la teva API: base de dades, potser Redis, potser un servei de missatgeria. Docker Compose és l’estàndard per orquestrar això localment.
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- \"8080:8080\"
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/myapp?sslmode=disable
- PORT=8080
- LOG_LEVEL=debug
- ENVIRONMENT=development
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
ports:
- \"5432:5432\"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
- ./migrations/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: [\"CMD-SHELL\", \"pg_isready -U postgres\"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:Punts clau:
depends_onambcondition: service_healthy: l’API no arrenca fins que PostgreSQL estigui llest. Sense això, la teva API intentarà connectar a la base de dades abans que existeixi i fallarà.- Volum amb nom (
pgdata): les dades de PostgreSQL persisteixen entre reinicis de Docker Compose. Sense això, perds les dades cada vegada que fasdocker compose down. - Muntatge de
init.sql: PostgreSQL executa automàticament els scripts a/docker-entrypoint-initdb.d/la primera vegada que arrenca. Ideal per crear taules i dades inicials.
Per a desenvolupament amb hot-reload, pots muntar el teu codi font i usar Air:
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- \"8080:8080\"
volumes:
- .:/app
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/myapp?sslmode=disableAmb un Dockerfile.dev que instal·li Air:
FROM golang:1.23-alpine
RUN go install github.com/air-verse/air@latest
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
CMD [\"air\", \"-c\", \".air.toml\"]Així cada canvi al teu codi recompila i reinicia el servidor automàticament dins del contenidor.
Comparació de mida d’imatge: Go vs Java vs Python vs Node
Els números parlen per si sols. Tots els exemples són APIs HTTP mínimes amb un endpoint /health:
| Stack | Imatge base | Mida final |
|---|---|---|
| Go (multi-stage + scratch) | scratch | ~8 MB |
| Go (multi-stage + alpine) | alpine:3.20 | ~15 MB |
| Go (multi-stage + distroless) | distroless/static | ~10 MB |
| Java (Spring Boot + JRE) | eclipse-temurin:21-jre-alpine | ~250 MB |
| Python (FastAPI + uvicorn) | python:3.12-slim | ~180 MB |
| Node (Express) | node:20-alpine | ~180 MB |
| Rust (actix-web + scratch) | scratch | ~6 MB |
Go no és la més petita (Rust guanya aquí), però la diferència amb els ecosistemes de JVM, Python i Node és d’un ordre de magnitud. Això té conseqüències reals:
- Temps de pull: descarregar 10 MB tarda <1 segon. Descarregar 250 MB tarda 15-30 segons en una xarxa típica de CI.
- Emmagatzematge de registry: si mantens 50 imatges amb 20 tags cadascuna, la diferència entre 10 MB i 250 MB és 10 GB vs 250 GB.
- Cold start en serverless: Google Cloud Run, AWS Lambda amb containers. Arrencar un contenidor Go tarda 100-200 ms. Arrencar un contenidor Java pot trigar 5-15 segons.
- Superfície d’atac: menys programari a la imatge = menys CVEs possibles. Un escaneig de Trivy sobre una imatge Go+scratch retorna zero vulnerabilitats. Una imatge basada en Debian-slim pot retornar dotzenes.
Per a serveis cloud-native, on pots tenir desenes o centenars de contenidors, aquestes diferències s’acumulen ràpidament.
Consideracions per a CI/CD
Un Dockerfile multi-stage ben configurat s’integra directament en qualsevol pipeline de CI/CD. Però hi ha detalls que marquen la diferència entre un build de 30 segons i un de 5 minuts.
Cacheo de capes
L’ordre de les instruccions al teu Dockerfile importa. Les capes es cacheen de dalt a baix, i qualsevol canvi invalida totes les capes posteriors.
# BÉ: les dependències canvien menys que el codi
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server ./cmd/api
# MALAMENT: qualsevol canvi al codi invalida la caché de dependències
COPY . .
RUN go mod download
RUN go build -o server ./cmd/apiLa primera versió només torna a descarregar dependències quan go.mod o go.sum canvien. La segona torna a descarregar en cada canvi de codi.
Build cache en CI
A GitHub Actions, pots cachear les capes de Docker i els mòduls de Go:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/la-meva-org/la-meva-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxtype=gha usa la caché nativa de GitHub Actions. És la manera més senzilla de tenir builds incrementals en CI sense muntar infraestructura addicional.
Escaneig de vulnerabilitats
Incloure un pas d’escaneig d’imatge al teu pipeline és pràctica estàndard:
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/la-meva-org/la-meva-api:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGHAmb imatges Go basades en Alpine o scratch, els resultats de Trivy solen ser nets. Si uses una imatge base més pesada, prepara’t per gestionar CVEs que no tenen res a veure amb el teu codi.
Multi-arquitectura
Si desplegues en ARM (AWS Graviton, Apple Silicon), necessites builds multi-arquitectura:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/la-meva-org/la-meva-api:latest \
--push .Go fa això trivial perquè la cross-compilation és nativa. No necessites emuladors ni builders específics per a cada arquitectura. Només GOARCH=arm64 i ja està.
Dockerfile final de referència
Amb tot l’anterior, aquest és el Dockerfile que uso com a punt de partida per a qualsevol API en Go:
# === Build ===
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
ARG VERSION=dev
ARG COMMIT=unknown
RUN CGO_ENABLED=0 GOOS=linux \
go build \
-ldflags="-s -w -X main.version=${VERSION} -X main.commitHash=${COMMIT}" \
-o server ./cmd/api
# === Runtime ===
FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata \
&& addgroup -S appgroup \
&& adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
USER appuser:appgroup
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["./server"]Es construeix amb:
docker build \
--build-arg VERSION=1.0.0 \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
-t la-meva-api:1.0.0 .El resultat: una imatge de ~15 MB amb health check, usuari non-root, certificats TLS, zona horària, versió injectada i res més. Sense sorpreses, sense dependències ocultes, sense runtime que mantenir.
Un binari, una imatge, zero sorpreses
Go i Docker encaixen de manera natural. On altres llenguatges necessiten multi-stage builds elaborats, cacheo agressiu de dependències i optimització de capes només per arribar a imatges de 200 MB, Go et dóna imatges de 10-15 MB amb un Dockerfile directe. CGO_ENABLED=0 per a binaris estàtics, multi-stage builds per separar compilació de runtime, Alpine o distroless com a base, -ldflags="-s -w" per retallar el binari, health checks integrats i cacheo de capes amb go.mod/go.sum separats del codi. Tot encaixa sense forçar res.
No necessites arquitectures de desplegament sofisticades per començar. Un Dockerfile multi-stage, un docker compose up i la teva API està corrent amb PostgreSQL en local. Quan arribi el moment del CI/CD, el mateix Dockerfile funciona sense canvis.
La simplicitat de desplegament és una de les raons més pragmàtiques per triar Go en backend. No és el llenguatge més expressiu ni el que té més features. Però quan toca portar codi a producció, menys complexitat és exactament el que vols.


