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 anteponersudoa los comandosdocker.
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=0genera un binario estático que no depende de la libc del sistema, así que puede ejecutarse en una imagen sin sistema operativo.-trimpathy-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:nonrootes 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
NotaSi prefieres una imagen aún más pequeña, puedes usar
FROM scratchcomo etapa final. Ten en cuenta quescratchestá totalmente vacía: no incluye certificados CA (las llamadas HTTPS salientes fallarán), ni datos de zona horaria, ni un usuario no root. La imagendistroless/staticañade esos tres elementos por unos 2 MB.
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 directoryal arrancar: el binario está enlazado dinámicamente y la imagen final no tiene la libc que espera. Compila conCGO_ENABLED=0o usagcr.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 plugindocker-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 etiquetadebug-nonroot(por ejemplogcr.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.
