DevOps

Optimización de imágenes Docker para Go: compilación multietapa, distroless y superficie de ataque mínima

Ruslan Ismailov Publicado 14 min de lectura
O

Introducción: por qué el tamaño y la seguridad de las imágenes Docker son críticos en 2026

En 2026, la contenedorización no es solo una herramienta conveniente, sino el estándar de facto para el despliegue en producción. Los clústeres de Kubernetes gestionan miles de pods, los pipelines de CI/CD ejecutan cientos de compilaciones al día, y los requisitos de cumplimiento normativo (SOC 2, PCI DSS, ISO 27001) afectan directamente al contenido de las imágenes de contenedor. En este contexto, tres aspectos se vuelven críticos.

El primero es la velocidad de despliegue. Una imagen de 800 MB frente a una de 8 MB supone una diferencia de decenas de segundos al hacer pull desde el registry, lo que en una actualización progresiva de Kubernetes impacta directamente en el tiempo de degradación del servicio. El segundo es la superficie de ataque. Cada paquete innecesario en la imagen es una potencial vulnerabilidad CVE. Una imagen basada en ubuntu:22.04 contiene más de 200 paquetes, la mayoría de los cuales tu aplicación Go nunca necesitará. El tercero es el cumplimiento de las políticas de seguridad. Los escáneres de vulnerabilidades en el pipeline de CI/CD bloquean el despliegue al detectar CVE críticos, y cuantos menos componentes haya en la imagen, menor será la superficie de ataque.

En este artículo recorreremos el camino desde un Dockerfile ingenuo hasta una imagen lista para producción de menos de 10 MB con cero vulnerabilidades CVE.

Compilación multietapa básica de una aplicación Go

El error más común entre los principiantes es usar una sola imagen tanto para compilar como para ejecutar la aplicación. Esto produce imágenes de 300–900 MB que contienen todo el toolchain de Go, el código fuente y todas las dependencias de compilación.

La compilación multietapa (multi-stage build) resuelve este problema: en la primera etapa compilamos el binario, y en la segunda copiamos únicamente ese binario en una imagen runtime mínima.

El punto clave es el enlazado estático mediante CGO_ENABLED=0. Esto deshabilita CGO y crea un binario completamente enlazado de forma estática, que no requiere ninguna biblioteca del sistema en la imagen runtime. Sin esta configuración, el binario Go se enlazará dinámicamente con glibc, lo que genera una dependencia de una versión específica de libc en la imagen runtime.

# Dockerfile.basic — compilación multietapa básica

# ── Etapa 1: compilación ─────────────────────────────────────────
FROM golang:1.23-alpine AS builder

WORKDIR /app

# Primero copiamos solo los archivos de dependencias — importante para la caché
COPY go.mod go.sum ./
RUN go mod download

# Ahora copiamos el código fuente
COPY . .

# Enlazado estático: CGO_ENABLED=0 y GOOS=linux
# -ldflags="-w -s" elimina la información de depuración y la tabla de símbolos
# -trimpath elimina las rutas absolutas del binario (importante para builds reproducibles)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

# ── Etapa 2: runtime mínimo ──────────────────────────────────────
FROM alpine:3.20 AS runtime

# Actualizamos paquetes para reducir CVE
RUN apk update && apk upgrade --no-cache

# Copiamos solo el binario
COPY --from=builder /app/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

Este Dockerfile produce una imagen de ~12–15 MB en lugar de los ~300 MB con golang:1.23. Los flags -w (sin DWARF) y -s (sin tabla de símbolos) reducen el binario un 20–30% adicional.

Evolución de las imágenes base: scratch vs alpine vs distroless

La elección de la imagen base para el runtime es una decisión arquitectónica clave que afecta al tamaño, la seguridad y la facilidad de trabajo.

scratch — el mínimo absoluto

scratch es una imagen vacía sin ningún archivo. Tu binario es el único contenido del contenedor. Esto proporciona el tamaño mínimo y cero superficie de ataque a nivel de paquetes del sistema.

Problemas: no hay certificados CA (las solicitudes HTTPS fallarán), no hay datos de zona horaria, no hay /etc/passwd (no se puede ejecutar como usuario sin privilegios sin pasos adicionales), y absolutamente no hay herramientas de depuración.

alpine — el compromiso más popular

Alpine Linux pesa ~5 MB y contiene un conjunto mínimo de utilidades. Utiliza musl libc en lugar de glibc, lo que a veces causa problemas de compatibilidad. Incluye el gestor de paquetes apk, lo que facilita agregar dependencias. Número típico de CVE por imagen: 0–5 con actualizaciones regulares.

distroless — el estándar de oro para producción

El proyecto de Google distroless proporciona imágenes que contienen solo las dependencias runtime de la aplicación: sin shell, sin gestor de paquetes, sin utilidades innecesarias. Para Go se utiliza gcr.io/distroless/static-debian12 (para binarios estáticos) o gcr.io/distroless/base-debian12 (si se necesita glibc).

Comparativa por tamaño y CVE (datos de un servicio Go típico, 2024–2025):

  • golang:1.23 — ~600 MB, 50–150 CVE (incluyendo críticos)
  • ubuntu:22.04 — ~75 MB, 20–80 CVE
  • alpine:3.20 — ~8–12 MB, 0–5 CVE
  • gcr.io/distroless/static-debian12 — ~2–5 MB, 0–2 CVE
  • scratch — <1 MB de overhead, 0 CVE del SO (pero requiere certificados CA manualmente)

Para la mayoría de las aplicaciones Go en producción, distroless/static es la elección óptima: incluye certificados CA, datos de zona horaria, el archivo /etc/passwd con el usuario nonroot, pero no contiene shell ni gestor de paquetes.

Migración práctica a distroless

La transición de alpine a distroless requiere resolver varias tareas prácticas.

# Dockerfile.distroless — imagen lista para producción

# ── Etapa 1: compilación ─────────────────────────────────────────
FROM golang:1.23-bookworm AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

# ── Etapa 2: runtime distroless ──────────────────────────────────
# La etiqueta :nonroot ejecuta la imagen como usuario nonroot (uid=65532)
# La variante :debug contiene shell busybox para depuración
FROM gcr.io/distroless/static-debian12:nonroot AS runtime

# distroless/static ya incluye:
# - /etc/ssl/certs/ca-certificates.crt (certificados CA)
# - /usr/share/zoneinfo (datos de zona horaria)
# - /etc/passwd con el usuario nonroot
# - directorio /tmp

COPY --from=builder /app/server /server

# USER ya es nonroot gracias a la etiqueta :nonroot
# pero lo indicamos explícitamente por claridad
USER nonroot:nonroot

EXPOSE 8080
ENTRYPOINT ["/server"]

Depuración sin shell

La ausencia de shell en distroless es una característica, no un error. Para depurar existen varios enfoques.

El primero es usar la etiqueta :debug de la imagen, que incluye busybox. Importante: :debug es solo para depuración local, nunca en producción.

El segundo es kubectl debug en Kubernetes: permite adjuntar un contenedor efímero con las herramientas necesarias a un pod en ejecución sin reiniciarlo.

# Depuración de un pod en ejecución en Kubernetes
kubectl debug -it my-pod \
  --image=busybox \
  --target=my-container \
  -- sh

# O usar la variante debug de la imagen localmente
docker run --rm -it \
  --entrypoint sh \
  gcr.io/distroless/static-debian12:debug

Si necesitas datos de zona horaria desde el código

La imagen distroless/static-debian12 ya incluye /usr/share/zoneinfo. Si usas distroless/base o scratch, debes copiar los datos de zona horaria explícitamente:

# Copia de datos de zona horaria para imagen scratch
FROM builder AS tz-builder
RUN apt-get install -y tzdata

FROM scratch AS runtime
# Certificados CA
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Datos de zona horaria
COPY --from=tz-builder /usr/share/zoneinfo /usr/share/zoneinfo
# /etc/passwd para usuario non-root
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /app/server /server
USER nobody
ENTRYPOINT ["/server"]

Optimización del caché de capas

El orden correcto de las instrucciones en el Dockerfile influye de forma crítica en el tiempo de recompilación. Docker y BuildKit cachean las capas: al modificar una capa, todas las siguientes quedan invalidadas.

La regla de oro: de lo menos cambiante a lo más cambiante. Las dependencias (go.mod/go.sum) cambian raramente, el código fuente cambia con frecuencia.

# Dockerfile.optimized — optimización de caché con BuildKit
# syntax=docker/dockerfile:1.9

FROM golang:1.23-bookworm AS builder

WORKDIR /app

# PASO 1: Primero solo los archivos de dependencias
# Esta capa se cachea hasta que cambie go.mod/go.sum
COPY go.mod go.sum ./

# BuildKit cache mount: cachea el módulo de caché de Go entre compilaciones
# Muy útil en CI/CD — go mod download no vuelve a descargar paquetes
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

# PASO 2: Solo después copiamos el código fuente
COPY . .

# Compilación también con cache mount para el build cache
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot AS runtime
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

La directiva # syntax=docker/dockerfile:1.9 activa la última versión del frontend de BuildKit con soporte para cache mounts. El flag --mount=type=cache crea una caché persistente entre compilaciones: go mod download y la compilación usan la caché local sin volver a descargar paquetes.

Mejora con BuildKit cache mounts en un proyecto típico: primera compilación sin cambios, recompilación al modificar solo el código fuente — aceleración de 3–10 veces (de 2–3 minutos a 15–30 segundos).

Activar BuildKit en compilación local:

DOCKER_BUILDKIT=1 docker build -t myapp:latest .
# O con el moderno docker buildx:
docker buildx build -t myapp:latest .

Escaneo de vulnerabilidades: Trivy y Docker Scout

El escaneo de imágenes en busca de vulnerabilidades es un paso obligatorio en el proceso de producción. Las dos herramientas más utilizadas son Trivy de Aqua Security y Docker Scout.

Trivy — escaneo rápido local y en CI

# Instalación de Trivy
brew install aquasecurity/trivy/trivy  # macOS
# o
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh

# Escaneo de imagen
trivy image myapp:latest

# Solo vulnerabilidades HIGH y CRITICAL
trivy image --severity HIGH,CRITICAL myapp:latest

# Salida en formato JSON para CI
trivy image --format json --output trivy-report.json myapp:latest

# Terminar con código no cero si hay CRITICAL (bloquea el CI)
trivy image --exit-code 1 --severity CRITICAL myapp:latest

Integración de Trivy en GitHub Actions

# .github/workflows/build.yml
name: Build and Scan

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build-and-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build image
        uses: docker/build-push-action@v6
        with:
          context: .
          file: Dockerfile.optimized
          push: false
          load: true
          tags: myapp:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: HIGH,CRITICAL
          exit-code: 1

      - name: Upload Trivy results to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif

Docker Scout

Docker Scout está integrado en Docker Desktop y Docker Hub. Para una verificación rápida:

# Análisis de imagen local
docker scout cves myapp:latest

# Comparación con imagen base
docker scout compare myapp:latest --to myapp:previous

# Recomendaciones para actualizar la imagen base
docker scout recommendations myapp:latest

Resultados típicos del escaneo tras la optimización: builder golang:1.23 — 87 CVE (incluyendo 12 HIGH, 3 CRITICAL); imagen final distroless/static — 0 CVE.

Configuración de usuario non-root, sistema de archivos de solo lectura y capabilities

El tamaño mínimo de la imagen es solo la mitad de la tarea. Una configuración correcta de seguridad en runtime es igualmente importante.

# Dockerfile.secure — ejemplo completo listo para producción con seguridad
# syntax=docker/dockerfile:1.9

FROM golang:1.23-bookworm AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

COPY . .

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

# ── Runtime: distroless con nonroot ─────────────────────────────
FROM gcr.io/distroless/static-debian12:nonroot

# distroless:nonroot se ejecuta como uid=65532 gid=65532
# No es necesario crear el usuario manualmente

COPY --from=builder --chown=nonroot:nonroot /app/server /server

USER nonroot:nonroot

# Metadatos de la imagen (OCI Labels)
LABEL org.opencontainers.image.source="https://github.com/myorg/myapp" \
      org.opencontainers.image.revision="${GIT_COMMIT}" \
      org.opencontainers.image.created="${BUILD_DATE}"

EXPOSE 8080
ENTRYPOINT ["/server"]

En el manifiesto de Kubernetes configuramos adicionalmente el securityContext:

# kubernetes-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      # Prohibición de ejecución como root a nivel de pod
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
        fsGroup: 65532
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: myapp
          image: myregistry/myapp:latest
          ports:
            - containerPort: 8080
          securityContext:
            # Sistema de archivos raíz de solo lectura
            readOnlyRootFilesystem: true
            # Prohibición de escalada de privilegios
            allowPrivilegeEscalation: false
            # Eliminamos todas las capabilities
            capabilities:
              drop:
                - ALL
          # Si la aplicación necesita escritura — montamos tmpfs
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir:
            medium: Memory
            sizeLimit: 64Mi

La combinación de readOnlyRootFilesystem: true, allowPrivilegeEscalation: false y capabilities.drop: ALL limita al máximo las posibilidades del atacante incluso en caso de compromiso de la aplicación.

Compilación de imágenes multi-plataforma con Docker Buildx

Los clústeres de Kubernetes utilizan cada vez más nodos ARM (AWS Graviton, Azure Ampere) junto con x86_64. Las imágenes multi-plataforma permiten usar una sola etiqueta para ambas arquitecturas.

# Configuración de buildx con soporte multi-plataforma
docker buildx create \
  --name multiplatform-builder \
  --driver docker-container \
  --platform linux/amd64,linux/arm64 \
  --use

# Verificación de plataformas disponibles
docker buildx inspect --bootstrap

# Compilación y push de imagen multi-plataforma
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --file Dockerfile.secure \
  --tag myregistry/myapp:latest \
  --tag myregistry/myapp:1.2.3 \
  --push \
  .

# Verificación del manifiesto
docker buildx imagetools inspect myregistry/myapp:latest

En el Dockerfile para compilación multi-plataforma usamos TARGETPLATFORM y BUILDPLATFORM para la compilación cruzada de Go:

# Dockerfile.multiplatform
# syntax=docker/dockerfile:1.9

FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS builder

# Variables para compilación cruzada
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH

WORKDIR /app

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .

# Compilación cruzada de Go mediante GOOS/GOARCH desde TARGETPLATFORM
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
    go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

Importante: usamos --platform=$BUILDPLATFORM para la etapa builder, de modo que la compilación siempre ocurra de forma nativa en la máquina de build (sin emulación QEMU), y la compilación cruzada se realiza mediante el toolchain de Go. Esto proporciona una mejora de velocidad de 5–20 veces en comparación con la emulación QEMU.

Medición de resultados: antes y después

Veamos cifras concretas para un servicio HTTP Go típico (~5000 líneas de código, varias dependencias).

Tamaño de imágenes

  • golang:1.23 (sin multi-stage) — 862 MB
  • multi-stage + ubuntu:22.04 — 78 MB
  • multi-stage + alpine:3.20 — 13.2 MB
  • multi-stage + distroless/static — 7.8 MB
  • multi-stage + scratch (con certificados CA) — 6.1 MB

Número de CVE (Trivy, HIGH + CRITICAL)

  • golang:1.23 (sin multi-stage) — 15 HIGH, 3 CRITICAL
  • multi-stage + ubuntu:22.04 — 8 HIGH, 1 CRITICAL
  • multi-stage + alpine:3.20 — 1 HIGH, 0 CRITICAL
  • multi-stage + distroless/static — 0 HIGH, 0 CRITICAL
  • multi-stage + scratch — 0 HIGH, 0 CRITICAL

Tiempo de compilación (CI, recompilación al cambiar un archivo)

  • Sin BuildKit cache mounts — 3 min 20 seg
  • Con BuildKit cache mounts (GHA cache) — 28 seg

Comandos para verificar los resultados

# Tamaño de imagen
docker image ls myapp:latest --format "{{.Size}}"

# Análisis detallado de capas
docker history myapp:latest

# Inspección detallada con dive (utilidad para análisis de capas)
dive myapp:latest

# Tiempo de compilación
time docker buildx build -t myapp:latest .

# Informe completo de Trivy
trivy image --format table myapp:latest

# Verificar que el binario está enlazado estáticamente
docker run --rm --entrypoint sh \
  gcr.io/distroless/static-debian12:debug \
  -c "file /server" \
  myapp:latest
# Salida esperada: /server: ELF 64-bit LSB executable, statically linked

# Verificar el usuario
docker run --rm myapp:latest id
# Salida esperada: uid=65532(nonroot) gid=65532(nonroot)

Conclusión y checklist listo para producción

La optimización de imágenes Docker para Go no es una tarea puntual, sino un conjunto de prácticas que deben establecerse desde el primer Dockerfile. La transición de una imagen ingenua a una solución lista para producción reduce el tamaño de la imagen más de 100 veces, elimina por completo las CVE a nivel de SO y acelera el pipeline de CI/CD significativamente.

Estas optimizaciones son especialmente importantes en entornos Kubernetes, donde la velocidad de pull de la imagen afecta directamente al tiempo de inicio de los pods, y las políticas de seguridad bloquean cada vez más los despliegues de imágenes con vulnerabilidades críticas.

Checklist de imagen Docker lista para producción en Go

  1. Se utiliza compilación multietapa (builder + runtime)
  2. CGO_ENABLED=0 — enlazado estático del binario
  3. Flags -ldflags="-w -s" y -trimpath para reducir el binario
  4. Imagen base — distroless/static-debian12:nonroot o scratch
  5. go.mod/go.sum se copian por separado antes del código fuente para el caché
  6. BuildKit cache mounts para /go/pkg/mod y /root/.cache/go-build
  7. Escaneo con Trivy en CI con bloqueo ante CVE CRITICAL
  8. La aplicación se ejecuta como usuario non-root (USER nonroot)
  9. Kubernetes securityContext: readOnlyRootFilesystem, allowPrivilegeEscalation: false, capabilities.drop: ALL
  10. Compilación multi-plataforma (amd64 + arm64) mediante docker buildx
  11. Etiquetas OCI (LABEL org.opencontainers.image.*) para trazabilidad
  12. Actualización periódica de la imagen base (PRs automáticos mediante Dependabot o Renovate)

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →