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.

Compilación multietapa: la primera etapa contiene las herramientas de compilación y el código fuente; solo se copia el resultado compilado a la pequeña imagen final
Solo los archivos copiados con COPY a la última etapa se incluyen en la imagen

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 builder asigna un nombre a la primera etapa.
  • COPY --from=builder copia archivos desde esa etapa.
  • gcr.io/distroless/static-debian13 es 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 FROM inicia una etapa nueva. Asígnale un nombre con AS 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.
  • --target compila 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