La imagen base en la que se compila un servicio Go pesa 241 MB. La imagen base en la que debe ejecutarse, 2,21 MB. Entre esas dos cifras está todo el sentido de un Dockerfile de producción: a producción no debe viajar nada que sólo hiciera falta al compilar. Todos los tamaños de abajo son una medición de docker images en linux/amd64.
Multi-stage: compilar aquí, ejecutar allí
El multi-stage suele venderse como forma de reducir la imagen. La reducción es un efecto secundario. Lo importante es que la capa final arranca de una base limpia y contiene exactamente lo que se copió a mano: el compilador, el gestor de paquetes, las cabeceras y la shell no entran, porque nadie los copió.
# syntax=docker/dockerfile:1
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /bin/app ./cmd/app
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /bin/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
La primera línea no es adorno: trae el frontend de Dockerfile actual en lugar del incrustado en el daemon, y de su versión depende que --mount y --link de las secciones siguientes estén disponibles.
| Base | Tamaño | Cuándo elegirla |
|---|---|---|
distroless/static-debian13 | 2,21 MB | binarios estáticos: Go con CGO_ENABLED=0, Rust |
alpine:3.24 | 8,42 MB | hace falta shell, busybox o gestor de paquetes |
distroless/base-debian13 | 24,3 MB | enlazado dinámico contra glibc |
debian:trixie-slim | 78,6 MB | dependencias de sistema que no existen bajo musl |
Una etiqueta sin sufijo de distribución apunta hoy a -debian13, pero upstream avisa de que con el tiempo pasará al siguiente Debian. Conviene escribir el sufijo.
Caché: el orden de las capas pesa más que los flags
Una regla: lo que cambia poco se copia antes. El manifiesto de dependencias en su propio COPY, luego la instalación y sólo después las fuentes. Al revés, tocar una línea de código invalida la instalación de paquetes.
Encima de eso BuildKit ofrece montajes de caché que sobreviven a la invalidación de la capa: el directorio vive en el host y se monta dentro de la compilación.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install --no-install-recommends -y gcc
sharing=locked es obligatorio aquí: apt no sobrevive a dos compilaciones paralelas en el mismo directorio. El segundo flag infravalorado: COPY --link deja lo copiado en su propia capa, independiente de las anteriores, y esa capa se reutiliza incluso tras recompilar la base.
El secreto que entra en una capa se queda en la capa
Una imagen no es un archivo comprimido, es una pila de capas inmutables. Un token escrito en un RUN y borrado en el siguiente no ha ido a ninguna parte: rm sólo lo quitó de la vista superior del sistema de ficheros.
docker history --no-trunc my-app:latest
docker save my-app:latest | tar -xO | strings | grep -i 'token\|secret'
Sacarlo puede hacerlo cualquiera con acceso de pull, sin un solo exploit. Hay exactamente una vía que funciona: --mount=type=secret monta el fichero durante la instrucción y no acaba ni en una capa ni en el manifiesto.
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
ARG no sirve ni siquiera en la etapa builder: el valor queda en sus capas y en la caché de compilación del host, y el multi-stage sólo protege la imagen final. Los secretos de runtime son otra tarea; ahí trabajan External Secrets Operator y workload identity, no la compilación.
Non-root y señales
USER con UID numérico no es cosmética. Kubernetes con runAsNonRoot: true no levanta un contenedor cuya configuración de imagen lleva un nombre de usuario en vez de un número: el kubelet no puede comprobar que ese nombre no se resuelva a root. Las etiquetas nonroot de distroless usan 65532.
La creencia extendida de que USER debe ir antes de CMD o el contenedor arranca como root es falsa. Dos imágenes con esas instrucciones en orden inverso dan el mismo uid=1001: el valor sale de la última instrucción USER del fichero y su posición respecto a CMD no interviene. La comprobación lleva un minuto y vale la pena hacerla antes de llevarse esa regla de una checklist ajena a la propia.
Lo que sí se rompe es la forma de escribir el punto de entrada. La forma shell ENTRYPOINT app arranca /bin/sh -c y el PID 1 se lo queda la shell: la señal no llega al proceso, docker stop agota el tiempo de espera y remata el contenedor. La documentación de Docker mide la diferencia: 10,19 segundos frente a 0,20. La forma exec ENTRYPOINT ["/app"] no tiene el problema.
Las atestaciones llegan solas, el SBOM no
BuildKit añade por defecto una atestación de procedencia de nivel mode=min: la constancia de quién compiló y a partir de qué está ahí aunque nadie la pida. El SBOM no se genera por defecto.
docker buildx build --provenance=mode=max --sbom=true --push -t app:1.0 .
Para un servicio interno eso es higiene; para un producto en el mercado de la UE es un requisito del Cyber Resilience Act con plazo en septiembre de 2026. La procedencia de BuildKit es trabajo previo para los niveles SLSA, no un sustituto: la firma y la verificación siguen siendo del pipeline.
Lo que cuesta distroless
En la imagen no hay shell, así que no funcionan ni un HEALTHCHECK con curl ni el habitual docker exec sh. La comprobación de salud se va al kubelet y la depuración a un contenedor efímero (kubectl debug --target) o a la etiqueta :debug, que añade busybox. Si la aplicación necesita shell en runtime, distroless sencillamente no encaja, y es un desenlace normal: alpine con 8,42 MB tampoco lleva un compilador a producción.
Checklist
- Multi-stage; a la capa final sólo se copian artefactos.
- La base va fijada por digest
@sha256:…, no sólo por etiqueta. - Las dependencias se copian e instalan antes que las fuentes; las cachés pesadas van por
--mount=type=cache. - Ni un secreto por
ARGoENV: sólo--mount=type=secret. USERcon UID numérico,ENTRYPOINTen forma exec..dockerignorecubre.git,.env,node_modulesy el estado de Terraform.--sbom=trueactivado a conciencia, no «cuando alguien lo pida».
Ningún punto exige una herramienta nueva: todo esto es BuildKit, activo por defecto desde Docker Engine 23.0. Queda escribir una decena de líneas en el orden correcto.