La primera imagen Docker del servicio pesaba 1.1 GB: golang:1.22 completo, código fuente, caché de módulos y el binario. Cada deploy movía un gigabyte por la red para ejecutar un archivo de 18 MB. La versión final pesa 12 MB y arranca en frío notablemente más rápido en ECS.
Multi-stage: compilar en un sitio, ejecutar en otro
Go compila a un binario estático: el contenedor de ejecución no necesita compilador, ni fuente, ni distribución Linux completa. El Dockerfile multi-stage usa la imagen pesada solo para compilar y copia el binario a una base mínima.
# ── Etapa 1: compilación ───────────────────────────────
FROM golang:1.22-alpine AS build
WORKDIR /src
# Cachear módulos: solo se reejecuta si cambian go.mod/go.sum
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux \
go build -ldflags="-s -w" -o /out/api ./cmd/api
# ── Etapa 2: ejecución ─────────────────────────────────
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /out/api /api
USER 65534
ENTRYPOINT ["/api"]Las tres trampas de scratch
scratch es literalmente nada: ni shell, ni libc, ni certificados. Las tres líneas extra del Dockerfile existen porque cada una me costó una tarde de depuración.
- Certificados CA: sin ca-certificates.crt, toda llamada HTTPS saliente falla con "x509: certificate signed by unknown authority". El error aparece en runtime, nunca en el build.
- Zona horaria: sin zoneinfo, time.LoadLocation devuelve error y cualquier lógica de cortes diarios se rompe en silencio hacia UTC.
- Usuario: scratch no tiene /etc/passwd; USER 65534 (nobody) evita correr como root sin necesitar crear usuarios.
CGO_ENABLED=0 no es decorativo: con cgo activo el binario enlaza dinámicamente contra libc y simplemente no arranca en scratch ("no such file or directory" — el archivo que falta es el intérprete ELF, no tu binario). Y -ldflags="-s -w" recorta símbolos y DWARF: ~30% menos de binario a cambio de stack traces igual de útiles.
Resultado
- Imagen: 1.1 GB → 12 MB (94× menos).
- Superficie de ataque: cero paquetes del sistema, cero shell — el escáner de vulnerabilidades pasó de 40+ CVEs a 0.
- Pull en cold start de ECS: de ~25 s a <2 s.
Si necesitas depurar dentro del contenedor alguna vez, gcr.io/distroless/static es el término medio: sigue sin shell, pero existe una variante :debug con busybox para emergencias. Para este servicio, scratch + buenas métricas externas ha sido suficiente.