Una imagen Docker construida con un único FROM arrastra todo lo que hizo falta para compilar la aplicación: compiladores, cabeceras, cachés de dependencias y herramientas que nunca se usan en producción. Las compilaciones multi-etapa resuelven esto usando varias etapas en el mismo Dockerfile: una compila y la última solo recibe el resultado. En este tutorial convertirás el Dockerfile de una aplicación Go de casi 1 GB en una imagen de menos de 10 MB, acelerarás las recompilaciones con la caché de BuildKit, añadirás una etapa de test y aplicarás el mismo patrón a una aplicación Python.

Requisitos previos

Para seguir esta guía necesitas:

  • Un servidor o equipo con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath.
  • Docker Engine 23 o superior instalado desde el repositorio oficial de Docker. Desde esa versión BuildKit es el motor de construcción por defecto, necesario para las cachés del paso 4.
  • Tu usuario en el grupo docker, o anteponer sudo a los comandos docker.

No necesitas tener Go ni Python instalados en el servidor: todo se compila dentro de los contenedores.

Paso 1: Crear una aplicación de ejemplo

Crea un directorio para el proyecto:

mkdir -p ~/hola-go
cd ~/hola-go

Crea el fichero del módulo de Go:

nano go.mod
module example.com/hola

go 1.25

Crea un servidor HTTP mínimo que responda en el puerto 8080:

nano main.go
package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hola desde una imagen multi-etapa")
	})
	log.Println("escuchando en :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Paso 2: Medir la imagen de una sola etapa

Para tener una referencia, construye primero la imagen de la forma más directa: partir de la imagen de Go, copiar el código y compilar. Crea Dockerfile.single:

nano Dockerfile.single
FROM golang:1.25
WORKDIR /src
COPY . .
RUN go build -o /usr/local/bin/hola .
EXPOSE 8080
CMD ["hola"]

Construye la imagen:

docker build -f Dockerfile.single -t hola:single .

Comprueba su tamaño:

docker image ls hola
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
hola         single    3b7f1c2d9e0a   10 seconds ago   880MB

Casi 900 MB para un binario de pocos megas: la imagen contiene el compilador de Go, la biblioteca estándar, Git y un Debian completo. Todo eso aumenta el tiempo de descarga en cada despliegue y la superficie de ataque, ya que cada paquete puede tener vulnerabilidades que aparecerán en los escaneos.

Paso 3: Separar compilación y ejecución

Un Dockerfile multi-etapa tiene varios FROM. Cada uno inicia una etapa nueva, y COPY --from=<etapa> copia ficheros de una etapa anterior. Solo la última etapa forma la imagen final; las demás se descartan.

Crea el Dockerfile:

nano Dockerfile
# syntax=docker/dockerfile:1

# Etapa 1: compilar
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hola .

# Etapa 2: ejecutar
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/hola /hola
EXPOSE 8080
ENTRYPOINT ["/hola"]

Qué hace cada decisión:

  • CGO_ENABLED=0 genera un binario estático que no depende de la libc del sistema, así que puede ejecutarse en una imagen sin sistema operativo.
  • -trimpath y -ldflags="-s -w" quitan del binario las rutas locales y la información de depuración, lo que reduce su tamaño en torno a un 30 %.
  • gcr.io/distroless/static-debian12:nonroot es una imagen de Google que solo contiene certificados CA, datos de zona horaria y un usuario sin privilegios (UID 65532), con el que arranca el proceso. No tiene shell ni gestor de paquetes.

Construye la imagen:

docker build -t hola:multi .

Compara los tamaños:

docker image ls hola
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
hola         multi     9c4e2a7b1f3d   5 seconds ago    7.6MB
hola         single    3b7f1c2d9e0a   2 minutes ago    880MB

Ejecuta un contenedor para comprobar que funciona:

docker run -d --name hola -p 8080:8080 hola:multi
curl http://localhost:8080
Hola desde una imagen multi-etapa

Confirma que el proceso no corre como root:

docker inspect --format '{{.Config.User}}' hola:multi
65532

Elimina el contenedor de prueba:

docker rm -f hola

Paso 4: Acelerar las recompilaciones con la caché

Docker reutiliza cada capa mientras no cambien sus instrucciones ni los ficheros que copia. Por eso el Dockerfile copia primero go.mod y descarga las dependencias, y solo después copia el código: cambiar main.go no invalida la capa de go mod download. Cuando el proyecto tenga dependencias, copia también go.sum en esa misma instrucción.

Aun así, cuando cambias el código, go build recompila desde cero porque su caché no se conserva entre construcciones. Los montajes de caché de BuildKit (RUN --mount=type=cache) mantienen un directorio persistente entre construcciones que no forma parte de la imagen. Sustituye la etapa build del Dockerfile por esta:

FROM golang:1.25 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 \
    CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hola .

/go/pkg/mod guarda los módulos descargados y /root/.cache/go-build los objetos compilados. La línea # syntax=docker/dockerfile:1 del principio garantiza que BuildKit usa una versión del frontend que soporta estos montajes.

Crea también un fichero .dockerignore para que Docker no envíe al motor de construcción ficheros que no necesita. Cualquier fichero del contexto que cambie invalida la capa COPY . .:

nano .dockerignore
.git
Dockerfile*
.dockerignore
*.md

Construye dos veces seguidas, modificando el mensaje de main.go entre ambas, y observa que la segunda vez solo se repite la compilación:

docker build -t hola:multi .

En la salida, los pasos que no han cambiado aparecen marcados como CACHED.

Paso 5: Añadir una etapa de test con --target

Las etapas intermedias también sirven para ejecutar pruebas con el mismo entorno de compilación. Añade esta etapa en el Dockerfile, justo después de la etapa build y antes de la etapa final:

FROM build AS test
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go vet ./... && go test ./...

BuildKit solo construye las etapas de las que depende el objetivo. Como la etapa final no depende de test, un docker build normal la omite. Para ejecutar las pruebas, elige esa etapa como objetivo con --target:

docker build --target test -t hola:test .

Si go vet o algún test fallan, la construcción termina con error, lo que la hace útil como paso de un pipeline de CI antes de construir la imagen final. El mismo mecanismo sirve para tener una etapa dev con herramientas de depuración que nunca llegan a producción.

Paso 6: Aplicar el patrón a una aplicación Python

En lenguajes interpretados no hay un binario que copiar, pero el patrón sigue siendo útil: la etapa de compilación instala dependencias (que a veces necesitan compilador) en un entorno virtual, y la etapa final solo copia ese entorno. Crea un proyecto nuevo:

mkdir -p ~/hola-py
cd ~/hola-py

Crea el fichero de dependencias:

nano requirements.txt
flask==3.1.*
gunicorn==23.*

Crea la aplicación:

nano app.py
from flask import Flask

app = Flask(__name__)


@app.get("/")
def index():
    return "Hola desde Python multi-etapa\n"

Crea el Dockerfile:

nano Dockerfile
# syntax=docker/dockerfile:1

FROM python:3.12-slim AS build
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

FROM python:3.12-slim
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1
COPY --from=build /opt/venv /opt/venv
WORKDIR /app
COPY app.py .
USER nobody
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]

Las dos etapas usan la misma imagen base para que las rutas del intérprete dentro del entorno virtual coincidan. Si alguna dependencia necesita compilar extensiones en C, instala build-essential solo en la etapa build: el compilador no llegará a la imagen final.

Construye y prueba la imagen:

docker build -t hola-py .
docker run -d --name hola-py -p 8000:8000 hola-py
curl http://localhost:8000
Hola desde Python multi-etapa

Elimina el contenedor de prueba:

docker rm -f hola-py

Solución de problemas

  • exec /hola: no such file or directory al arrancar: el binario está enlazado dinámicamente y la imagen final no tiene la libc que espera. Compila con CGO_ENABLED=0 o usa gcr.io/distroless/base-debian12, que incluye glibc.
  • the --mount option requires BuildKit: estás usando el constructor antiguo. Actualiza Docker Engine a la versión 23 o superior, o instala el plugin docker-buildx-plugin.
  • No puedo entrar en el contenedor con docker exec -it ... sh: las imágenes distroless no tienen shell a propósito. Para depurar, construye temporalmente con la etiqueta debug-nonroot (por ejemplo gcr.io/distroless/static-debian12:debug-nonroot), que incluye una shell de BusyBox.
  • La caché se invalida en cada construcción: algún fichero del contexto cambia siempre (logs, .git, artefactos locales). Añádelo a .dockerignore.

Conclusión

Has reducido una imagen de Go de 880 MB a menos de 10 MB separando compilación y ejecución, has acelerado las recompilaciones con los montajes de caché de BuildKit y has usado --target para ejecutar pruebas en el mismo Dockerfile. Como siguientes pasos, escanea tus imágenes con una herramienta como Trivy para comparar las vulnerabilidades antes y después, construye imágenes multiarquitectura con docker buildx build --platform linux/amd64,linux/arm64, y sube el resultado a un registro privado.