Contenido
Tutorial de compilaciones multietapa en Docker: crea imágenes más pequeñas y eficientes
Muchos programas necesitan compiladores, paquetes de desarrollo y herramientas de compilación para compilarse, pero solo necesitan el resultado compilado para ejecutarse. Si lo incluyes todo en una misma imagen, la imagen final arrastra muchas cosas que no necesita.
Las compilaciones multietapa permiten incluir varios FROM en un Dockerfile, y cada FROM inicia una etapa. Al final solo se conserva el contenido de la última etapa; las anteriores solo sirven para generar archivos.
Experimento: una etapa frente a varias
Vamos a comparar con un programa web sencillo en Go (main.go):
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from a distroless container!")
})
fmt.Println("Listening on :8080")
http.ListenAndServe(":8080", nil)
}
Una etapa (Dockerfile.single): compila en la imagen oficial de Go y úsala también para ejecutar el programa:
FROM golang:1.27
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /server .
CMD ["/server"]
Varias etapas (Dockerfile):
# Etapa 1: compilación
FROM golang:1.27 AS builder
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server .
# Etapa 2: ejecución
FROM gcr.io/distroless/static-debian13
COPY --from=builder /out/server /server
USER nonroot
ENTRYPOINT ["/server"]
AS builderasigna un nombre a la primera etapa.COPY --from=buildercopia archivos desde esa etapa.gcr.io/distroless/static-debian13es una imagen mínima mantenida por Google: no tiene shell ni gestor de paquetes, solo los archivos mínimos necesarios para ejecutar programas estáticos.
Compila la versión multietapa:
$ docker build -t go-multi .
[+] Building 51.7s (14/14) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 326B 0.0s
=> [internal] load metadata for gcr.io/distroless/static-debian13:latest 2.1s
=> [internal] load metadata for docker.io/library/golang:1.27 0.4s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [builder 1/6] FROM docker.io/library/golang:1.27@sha256:e432b43af23a9328d56a7c499be047681 0.0s
=> => resolve docker.io/library/golang:1.27@sha256:e432b43af23a9328d56a7c499be0476810aa344ac 0.0s
=> [stage-1 1/2] FROM gcr.io/distroless/static-debian13:latest@sha256:58133991db06659feaabe0 3.4s
=> => resolve gcr.io/distroless/static-debian13:latest@sha256:58133991db06659feaabe0f4e97a35 0.0s
=> => … (descarga y extracción de las capas, salida recortada)
=> [internal] load build context 0.0s
=> => transferring context: 120B 0.0s
=> CACHED [builder 2/6] WORKDIR /src 0.0s
=> [builder 3/6] COPY go.mod ./ 0.0s
=> [builder 4/6] RUN go mod download 0.5s
=> [builder 5/6] COPY . . 0.0s
=> [builder 6/6] RUN CGO_ENABLED=0 go build -o /out/server . 47.6s
=> [stage-1 2/2] COPY --from=builder /out/server /server 0.0s
=> exporting to image 1.1s
=> => exporting layers 0.6s
=> => exporting manifest sha256:8b506df2e51c2cbf7df7b452669cde4652ac2d83d924e8e5b67ed8f37b6a 0.0s
=> => exporting config sha256:f1efccd3fdd70ffd1054b9ed0ff4154e3be0d93324c196bd46438e12a4b372 0.0s
=> => exporting attestation manifest sha256:4cf5049d1a21697ca953ccd67da3874da40c466c9db43ac9 0.0s
=> => exporting manifest list sha256:463874676a48cc064e399fd1968bcd5e301d77f20909360c40fff1c 0.0s
=> => naming to docker.io/library/go-multi:latest 0.0s
=> => unpacking to docker.io/library/go-multi:latest 0.4s
Puedes ver los pasos de ambas etapas con las etiquetas [builder …] y [stage-1 …]. BuildKit descarga al mismo tiempo las imágenes base necesarias para las dos etapas. Compara el tamaño de las imágenes:
$ docker images --filter reference=go-single --filter reference=go-multi
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
go-multi:latest 463874676a48 20.2MB 5.77MB
go-single:latest c6c844f9076e 1.43GB 343MB
Es el mismo programa: 1,43 GB en una etapa y 20,2 MB en varias, una diferencia de 70 veces. La imagen de una etapa incluye todo el compilador de Go y el sistema Debian; la imagen final multietapa solo contiene el ejecutable compilado y los archivos básicos de distroless.
Cuanto más pequeña sea la imagen, más rápido se descarga y se despliega, y menos paquetes potencialmente vulnerables incluye. Pruébalo:
$ docker run -d --name goapp -p 8080:8080 go-multi
d474e2de30d65e21a1f6395aac76c185fcfc8d4d6caa1240ab6ef3ebd7406e25
$ curl -s http://localhost:8080
Hello from a distroless container!
$ docker exec goapp sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
$ docker rm -f goapp
goapp
La imagen distroless ni siquiera incluye sh (executable file not found). Aunque un atacante comprometa el programa, le resultará difícil hacer algo dentro del contenedor. La contrapartida es que depurar resulta menos cómodo. Cuando lo necesites, puedes usar la versión :debug, que incluye herramientas de depuración.
Ejemplos multietapa para otros lenguajes
Frontend de Node.js: entrega el resultado de la compilación a Nginx
Después de compilarse, los proyectos frontend de React, Vue, Astro y otros solo contienen archivos estáticos, así que no necesitan el entorno de ejecución de Node.js:
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.30-alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
Las herramientas de compilación y todo node_modules quedan fuera de la imagen final.
Backend de Node.js: conserva solo los paquetes de producción
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json .
USER node
CMD ["node", "dist/index.js"]
Las etapas deps y build son independientes, así que BuildKit las compila en paralelo.
Java: compila con JDK y ejecuta con JRE
FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -q dependency:go-offline
COPY src ./src
RUN mvn -q package -DskipTests
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
Compilar una etapa concreta: --target
Puedes aprovechar las etapas para crear versiones de desarrollo y producción en un mismo Dockerfile:
FROM node:24-alpine AS base
WORKDIR /app
COPY package.json ./
FROM base AS dev
ENV NODE_ENV=development
COPY . .
CMD ["npm", "run", "dev"]
FROM base AS prod
ENV NODE_ENV=production
COPY . .
CMD ["node", "server.js"]
$ docker build -q --target dev -t target-demo:dev .
sha256:3d0fdd2a77233426c90dfbc575aafaef652a1393cb2730d066e627362860ec09
$ docker build -q --target prod -t target-demo:prod .
sha256:9e14e3363d26d46ed628e49b4cd806a29e9699a5be2e6e4078976bd380632568
$ docker inspect --format '{{.Config.Env}} {{.Config.Cmd}}' target-demo:dev
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NODE_VERSION=24.21.0 YARN_VERSION=1.22.22 NODE_ENV=development] [npm run dev]
$ docker inspect --format '{{.Config.Env}} {{.Config.Cmd}}' target-demo:prod
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NODE_VERSION=24.21.0 YARN_VERSION=1.22.22 NODE_ENV=production] [node server.js]
--target dev solo compila hasta la etapa dev, mientras que --target prod omite dev. Las dos imágenes tienen variables de entorno y comandos de inicio distintos. -q hace que docker build muestre solo el ID de la imagen.
Copiar desde una imagen externa
COPY --from también permite especificar directamente cualquier imagen, no tiene que ser una etapa de este Dockerfile:
COPY --from=nginx:1.30-alpine /etc/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
La segunda línea es un uso habitual: copia directamente el ejecutable desde la imagen oficial de uv, sin instalarlo por separado. Consulta aplicaciones Python.
Resumen
- Cada
FROMinicia una etapa nueva. Asígnale un nombre conAS nombre. - La última etapa es la imagen resultante e incluye solo lo que se haya copiado con
COPY --from. - Las herramientas de compilación, el código fuente y los paquetes de desarrollo quedan en las etapas anteriores. Así, la imagen puede reducirse decenas de veces.
--targetcompila solo hasta la etapa indicada, por ejemplo, una etapa de pruebas.
Ejercicios
Ejercicio 1: Añade una etapa FROM base AS test al Dockerfile multietapa para ejecutar las pruebas. ¿Cómo puedes ejecutar solo las pruebas en CI sin compilar la imagen final?
Respuesta
docker build --target test .
Consulta la sección --target de docker build para ver un ejemplo práctico.
Ejercicio 2: Compara con docker history las imágenes Go de una y varias etapas de este capítulo. ¿Está la capa del compilador de Go en la imagen final?
Pista
La imagen final multietapa solo tiene la capa base de distroless y la capa COPY --from=build; no aparece ninguna capa de la cadena de herramientas de Go.
Preguntas frecuentes
¿Las compilaciones multietapa hacen que la compilación sea más lenta?
No. BuildKit compila en paralelo las etapas independientes y solo compila las necesarias para la etapa final. Puedes ver una prueba con TypeScript en el ejemplo de [aplicación Node.js](/es/docker/nodejs/).
¿Las imágenes de las etapas intermedias se quedan en el equipo?
Las etapas intermedias no generan imágenes con nombre, pero sus capas permanecen en la caché de compilación. Puedes eliminarlas con docker builder prune.
¿Se puede usar otra imagen con COPY --from?
Sí. Por ejemplo, COPY --from=nginx:1.30-alpine /etc/nginx/nginx.conf . copia archivos directamente desde una imagen externa. En este capítulo hay un ejemplo.
Actualizado 14 de octubre de 2026