Un Dockerfile tradicional puede terminar incluyendo el SDK, compiladores, código fuente, cachés y herramientas de diagnóstico que solo se necesitan durante la compilación.

Los builds multi-stage separan esas responsabilidades:
Etapa build Imagen final
SDK + código + compilador → binario ejecutableCada instrucción FROM comienza una etapa. COPY --from permite trasladar únicamente los artefactos necesarios a una imagen final limpia, dejando fuera las herramientas de compilación. Docker Docs.
Qué se va a construir
Se compararán dos imágenes de una aplicación Go:
app:single: incluye Go, código fuente, cachés y binario.app:multi: contiene únicamente el binario estático.
La imagen multi-stage utilizará scratch, no tendrá shell ni gestor de paquetes y ejecutará el proceso con UID 65532.
Multi-stage reduce tamaño y superficie de ataque, pero no elimina automáticamente todas las vulnerabilidades. El binario, sus dependencias y cualquier base utilizada en runtime deben seguir analizándose y actualizándose.
Requisitos previos
- Docker Engine actualizado.
- Docker Buildx con BuildKit.
- Acceso a las imágenes base.
- Un proyecto de prueba aislado.
- Registro donde publicar imágenes inmutables.
- Herramientas para probar la aplicación, como
curl.
Comprueba las versiones:
docker version
docker buildx version
docker buildx lsLos checks mediante docker build --check requieren Docker Buildx 0.15 o posterior. Build checks.
Paso 1: crear una aplicación de ejemplo
Crea go.mod:
module ejemplo.com/multistage
go 1.26Crea main.go:
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "aplicacion multi-stage operativa")
})
log.Println("escuchando en :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}La aplicación utiliza únicamente la biblioteca estándar y puede compilarse como binario estático.
Paso 2: reducir el contexto con .dockerignore
Crea .dockerignore:
.git
.gitignore
Dockerfile*
.env
.env.*
!.env.example
bin/
dist/
coverage/
tmp/
*.logEl contexto es el conjunto de archivos enviado al builder. Excluir repositorios, credenciales, logs y artefactos reduce transferencias y evita invalidaciones innecesarias de caché.
COPY . . no debe enviar automáticamente todo el directorio de trabajo. Optimización de caché.
Comprueba que .env.example no contenga credenciales reales antes de exceptuarlo.
Paso 3: crear un Dockerfile single-stage
Guarda como Dockerfile.single:
# syntax=docker/dockerfile:1
FROM golang:1.26
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 GOOS=linux \
go build \
-trimpath \
-ldflags="-s -w" \
-o /usr/local/bin/app \
.
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/app"]Este Dockerfile funciona, pero su imagen final conserva:
- La distribución base.
- El SDK de Go.
- El compilador.
- El código fuente.
- La caché de compilación.
- El binario.
Eliminar archivos en una capa posterior no siempre recupera el espacio ocupado en capas anteriores.
Paso 4: crear el Dockerfile multi-stage
Guarda como Dockerfile.multi:
# syntax=docker/dockerfile:1
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go test ./...
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux \
go build \
-trimpath \
-ldflags="-s -w" \
-o /out/app \
.
FROM scratch AS runtime
COPY --from=build /out/app /app
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/app"]La primera etapa contiene todo lo necesario para compilar. La etapa runtime comienza desde cero y recibe únicamente /out/app.
Nombrar las etapas mediante AS build es más seguro que referenciarlas por índice. Si alguien reordena los bloques FROM, COPY --from=build continúa apuntando a la etapa correcta.
Qué hace cada optimización
Separar dependencias del código
COPY go.mod ./
RUN go mod download
COPY . .Docker puede reutilizar la descarga de dependencias mientras go.mod no cambie. Si se ejecutara primero COPY . ., cualquier modificación del código invalidaría esa capa.
En proyectos con dependencias y archivo go.sum, copia ambos antes del código:
COPY go.mod go.sum ./Utilizar cache mounts
RUN --mount=type=cache,target=/root/.cache/go-build ...Los cache mounts persisten entre builds y aceleran nuevas compilaciones. No se incorporan a la imagen final.
La caché del builder puede seguir ocupando espacio local aunque la imagen distribuida sea pequeña. Revísala por separado:
docker builder duCompilar un binario estático
CGO_ENABLED=0 GOOS=linux go buildDesactivar CGO evita depender de bibliotecas dinámicas que no existen en scratch. Esta decisión debe validarse si la aplicación utiliza paquetes que requieren CGO.
Eliminar rutas locales
-trimpathReduce información sobre rutas del sistema de compilación incluida en el binario.
Eliminar símbolos
-ldflags="-s -w"Reduce el tamaño al retirar información de símbolos y depuración. Esto puede dificultar determinados análisis de fallos; conserva artefactos con símbolos cuando tu proceso operativo los necesite.
Ejecutar sin root
USER 65532:65532Linux acepta UID y GID numéricos aunque scratch no tenga /etc/passwd. El proceso no necesita privilegios para escuchar en el puerto 8080.
Paso 5: validar el Dockerfile
docker build \
--check \
-f Dockerfile.multi \
.Los checks analizan el Dockerfile sin ejecutar el build completo. Detectan, entre otros problemas, nombres de etapas duplicados, casing inconsistente y plataformas base incorrectas. Referencia de build checks.
No ocultes los resultados con || true. Revisa cada advertencia y configura cuáles deben bloquear la integración continua.
Paso 6: construir ambas imágenes
Construye la versión single-stage:
docker build \
--progress=plain \
-f Dockerfile.single \
-t app:single \
.Construye la multi-stage:
docker build \
--progress=plain \
-f Dockerfile.multi \
-t app:multi \
.BuildKit procesa únicamente las etapas necesarias para el target solicitado.
Paso 7: comparar los tamaños
docker image ls appObtén el tamaño exacto en bytes:
docker image inspect \
app:single \
app:multi \
--format '{{index .RepoTags 0}}: {{.Size}} bytes'Revisa las capas:
docker history app:single
docker history app:multiLa diferencia concreta depende de la arquitectura, la versión de Go, el código y las imágenes almacenadas localmente. Registra las mediciones reales en lugar de prometer un porcentaje fijo.
Una reducción de tamaño no demuestra por sí sola que la aplicación funcione o que la imagen sea segura.
Paso 8: probar la imagen final
docker run -d \
--rm \
--name app-multi \
-p 127.0.0.1:8080:8080 \
app:multiComprueba el endpoint:
curl -fsS http://127.0.0.1:8080/Resultado esperado:
aplicacion multi-stage operativaRevisa los logs:
docker logs app-multiComprueba el usuario:
docker inspect app-multi \
--format 'User={{.Config.User}}'Resultado esperado:
User=65532:65532Detén la prueba:
docker stop app-multiPaso 9: inspeccionar una etapa intermedia
Puedes detener el build en la etapa build:
docker build \
--target build \
-f Dockerfile.multi \
-t app-build:debug \
.Como esa etapa sí tiene shell y herramientas:
docker run --rm -it \
--entrypoint /bin/sh \
app-build:debugEsto permite diagnosticar la compilación sin agregar herramientas a la imagen de producción.
No publiques accidentalmente app-build:debug como artefacto final.
Paso 10: comprender las limitaciones de scratch
scratch no contiene:
- Shell.
- Gestor de paquetes.
- Certificados CA.
- Base de usuarios.
- Información de zona horaria.
- Herramientas como
curl,psols.
Por eso, este comando debe fallar:
docker run --rm \
--entrypoint /bin/sh \
app:multiSi la aplicación realiza conexiones HTTPS salientes, necesitará certificados CA. Puedes copiarlos desde una etapa preparada para ello o utilizar una imagen runtime mínima que ya los incluya.
Si necesita datos de zonas horarias, librerías dinámicas o herramientas operativas, scratch puede no ser la base adecuada. Considera una imagen distroless o una distribución mínima.
La imagen final debe ser tan pequeña como sea práctico, no tan pequeña como sea posible a cualquier costo.
Cómo manejar secretos durante el build
No pases tokens mediante ARG o ENV:
ARG TOKEN
ENV TOKEN=$TOKENLos argumentos y variables pueden quedar expuestos en metadatos, cachés o la imagen.
Utiliza un secret mount:
RUN --mount=type=secret,id=repo_token \
TOKEN="$(cat /run/secrets/repo_token)" \
comando-que-necesita-el-tokenEntrega el secreto al builder:
docker build \
--secret id=repo_token,src=/ruta/segura/token \
-f Dockerfile.multi \
-t app:multi \
.El secreto existe únicamente durante esa instrucción y no se copia a la imagen final. Docker build secrets.
No ejecutes comandos que impriman el secreto en la salida del build.
Fijar imágenes base por digest
Las etiquetas como golang:1.26 pueden cambiar. En producción, registra el digest aprobado:
FROM golang:1.26@sha256:DIGEST_VERIFICADO AS buildConsulta los manifiestos disponibles:
docker buildx imagetools inspect golang:1.26No inventes ni reutilices un digest de otra arquitectura. Automatiza la revisión de actualizaciones para no quedar fijado indefinidamente a una imagen vulnerable.
Evitar copias excesivas entre etapas
Esta instrucción es precisa:
COPY --from=build /out/app /appEstas variantes pueden reintroducir archivos innecesarios:
COPY --from=build / /
COPY --from=build /src /appCopia artefactos concretos desde un directorio de salida conocido. Inspecciona qué produce el compilador antes de definir el COPY.
Problemas frecuentes
COPY --from no encuentra el archivo
Comprueba el nombre de la etapa y la ruta generada:
docker build \
--target build \
-f Dockerfile.multi \
-t app-build:debug \
.Después inspecciona /out dentro de esa etapa.
La imagen multi-stage no es mucho más pequeña
Revisa:
- Imagen base del runtime.
- Archivos copiados desde la etapa build.
- Binarios con símbolos.
- Dependencias dinámicas.
- Artefactos duplicados.
- Uso accidental de la etapa build como etapa final.
docker history app:multiAparece exec format error
El binario fue compilado para otra arquitectura o sistema operativo. Comprueba:
docker image inspect app:multi \
--format '{{.Os}}/{{.Architecture}}'Para builds multiplataforma, utiliza --platform y los argumentos proporcionados por BuildKit.
Aparece x509: certificate signed by unknown authority
La imagen scratch no contiene certificados CA. Incorpora el bundle necesario o cambia a una imagen runtime mínima con certificados actualizables.
La compilación no reutiliza caché
Revisa el orden de COPY, el contenido de .dockerignore y qué archivos cambian antes de las instrucciones costosas.
Los cache mounts aceleran descargas y compilación, pero no corrigen una invalidación causada por copiar todo el proyecto demasiado pronto.
La aplicación requiere CGO
Un binario creado con CGO puede depender del cargador y de librerías que no existen en scratch. Utiliza una base compatible o copia conscientemente las dependencias dinámicas requeridas.
El contenedor no tiene shell
Es el comportamiento esperado de scratch. Usa una etapa debug, contenedores efímeros de depuración o las herramientas del orquestador, sin modificar la imagen de producción.
Se filtró un secreto durante el build
Revoca inmediatamente la credencial. Elimina su uso en ARG, ENV, COPY o comandos que la impriman, limpia los caches afectados según tu entorno y reconstruye desde una fuente segura.
Seguridad y operación
Multi-stage ayuda a:
- Reducir paquetes distribuibles.
- Eliminar compiladores y shells de producción.
- Disminuir tiempo de transferencia.
- Acelerar despliegues y escalado.
- Reducir componentes que requieren parches.
No reemplaza:
- Análisis de vulnerabilidades.
- Firma y verificación de imágenes.
- SBOM y procedencia.
- Ejecución no root.
- Filesystem de solo lectura.
- Límites de recursos.
- Seccomp, AppArmor o capabilities.
- Actualización periódica de dependencias.
Analiza la imagen final, no solo la etapa build.
Cómo revertir un despliegue
Cambiar el Dockerfile no modifica contenedores que ya están ejecutándose. Publica la imagen multi-stage con una etiqueta o digest nuevo y conserva la referencia anterior.
Para revertir, despliega el digest previamente validado:
registro.ejemplo.com/app@sha256:DIGEST_ANTERIORDespués verifica salud, logs y tráfico.
No utilices una etiqueta mutable como latest como único mecanismo de rollback. Tampoco uses docker image prune durante la validación: eliminar cachés o imágenes locales no restaura una versión anterior.
Buenas prácticas
- Nombra todas las etapas.
- Copia únicamente artefactos concretos.
- Usa
.dockerignore. - Ordena capas de estable a variable.
- Ejecuta tests antes de generar el artefacto final.
- Utiliza cache mounts para dependencias.
- Usa secret mounts para credenciales.
- Ejecuta runtime con un UID no root.
- Fija bases por digest y actualízalas de forma controlada.
- Mide tamaño, funcionalidad y vulnerabilidades por separado.
- Conserva una etapa debug fuera de producción.
- Despliega y revierte mediante digests inmutables.
Conclusión
Un Dockerfile multi-stage reduce una imagen porque separa el entorno de compilación del entorno de ejecución. La optimización no consiste en acumular comandos de limpieza, sino en comenzar la imagen final desde una nueva base y copiar únicamente el artefacto necesario.
La implementación queda validada cuando la imagen multi-stage es menor que la monolítica, ejecuta el mismo comportamiento, no contiene herramientas de build ni secretos y puede desplegarse y revertirse mediante referencias inmutables.
