Nota

Dockerfile de producción: distroless, caché de compilación y secretos

Una checklist de una decena de líneas: qué viaja a producción y qué se queda en la etapa builder. Bases medidas, montajes de caché de BuildKit, secretos que no llegan a ninguna capa y atestaciones que llegan solas.

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.

BaseTamañoCuándo elegirla
distroless/static-debian132,21 MBbinarios estáticos: Go con CGO_ENABLED=0, Rust
alpine:3.248,42 MBhace falta shell, busybox o gestor de paquetes
distroless/base-debian1324,3 MBenlazado dinámico contra glibc
debian:trixie-slim78,6 MBdependencias 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 ARG o ENV: sólo --mount=type=secret.
  • USER con UID numérico, ENTRYPOINT en forma exec.
  • .dockerignore cubre .git, .env, node_modules y el estado de Terraform.
  • --sbom=true activado 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.

© 2026 axyi.ru · CC BY 4.0