--- title: "Compilaciones multi-etapa en Docker para imágenes pequeñas" description: "Reduce el tamaño de tus imágenes Docker con compilaciones multi-etapa: separa compilación y ejecución, usa distroless, caché de BuildKit y --target." category: "Docker Avanzado" language: es source: https://cubepath.com/es/docs/docker-avanzado/optimizacion-de-compilaciones-multi-etapa-de-docker updated: 2026-09-25 --- # Compilaciones multi-etapa en Docker para imágenes pequeñas 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: ```bash mkdir -p ~/hola-go cd ~/hola-go ``` Crea el fichero del módulo de Go: ```bash nano go.mod ``` ```text module example.com/hola go 1.25 ``` Crea un servidor HTTP mínimo que responda en el puerto 8080: ```bash nano main.go ``` ```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`: ```bash nano Dockerfile.single ``` ```dockerfile FROM golang:1.25 WORKDIR /src COPY . . RUN go build -o /usr/local/bin/hola . EXPOSE 8080 CMD ["hola"] ``` Construye la imagen: ```bash docker build -f Dockerfile.single -t hola:single . ``` Comprueba su tamaño: ```bash docker image ls hola ``` ```output 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=` copia ficheros de una etapa anterior. Solo la última etapa forma la imagen final; las demás se descartan. Crea el `Dockerfile`: ```bash nano Dockerfile ``` ```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: ```bash docker build -t hola:multi . ``` Compara los tamaños: ```bash docker image ls hola ``` ```output 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: ```bash docker run -d --name hola -p 8080:8080 hola:multi curl http://localhost:8080 ``` ```output Hola desde una imagen multi-etapa ``` Confirma que el proceso no corre como root: ```bash docker inspect --format '{{.Config.User}}' hola:multi ``` ```output 65532 ``` Elimina el contenedor de prueba: ```bash docker rm -f hola ``` > Nota: Si prefieres una imagen aún más pequeña, puedes usar `FROM scratch` como etapa final. Ten en cuenta que `scratch` está totalmente vacía: no incluye certificados CA (las llamadas HTTPS salientes fallarán), ni datos de zona horaria, ni un usuario no root. La imagen `distroless/static` añ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: ```dockerfile 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 . .`: ```bash nano .dockerignore ``` ```text .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: ```bash 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: ```dockerfile 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`: ```bash 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: ```bash mkdir -p ~/hola-py cd ~/hola-py ``` Crea el fichero de dependencias: ```bash nano requirements.txt ``` ```text flask==3.1.* gunicorn==23.* ``` Crea la aplicación: ```bash nano app.py ``` ```python from flask import Flask app = Flask(__name__) @app.get("/") def index(): return "Hola desde Python multi-etapa\n" ``` Crea el `Dockerfile`: ```bash nano Dockerfile ``` ```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: ```bash docker build -t hola-py . docker run -d --name hola-py -p 8000:8000 hola-py curl http://localhost:8000 ``` ```output Hola desde Python multi-etapa ``` Elimina el contenedor de prueba: ```bash 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.