Optimiza imágenes Docker con builds multi-etapa: Una guía completa
Domina los builds multi-etapa de Docker para reducir drásticamente el tamaño de tus imágenes, acelerar despliegues y mejorar la seguridad. Esta guía completa proporciona instrucciones paso a paso, ejemplos prácticos para Go y Node.js, y las mejores prácticas esenciales. Aprende a optimizar tus Dockerfiles separando las dependencias de compilación, asegurando que solo los componentes necesarios lleguen a tu imagen de ejecución final. Lectura esencial para cualquiera que busque construir aplicaciones contenerizadas eficientes y seguras.
Optimiza imágenes Docker con builds multi-etapa: Una guía completa
Los builds multi-etapa resuelven un problema muy común de Docker: las herramientas que necesitas para construir una aplicación generalmente no son las herramientas que necesitas para ejecutarla.
Un compilador de Go, caché de paquetes de Node, repositorio de Maven, framework de pruebas y encabezados de compilación son útiles durante la construcción de la imagen. Son peso muerto en la imagen de ejecución. Hacen que las descargas sean más lentas, aumentan la cantidad de software que tienes que parchear y dificultan entender qué está realmente ejecutándose en producción.
Con un Dockerfile multi-etapa, construyes en una etapa y copias solo el artefacto terminado a una etapa de ejecución más pequeña. La imagen final no hereda la etapa de construcción a menos que copies explícitamente archivos de ella.
El problema con las imágenes de una sola etapa
Considera una aplicación típica de Go. Necesitas el conjunto de herramientas de Go para compilarla. Una vez que tienes un binario de Linux, el compilador ya no es necesario. Una imagen de una sola etapa lo mantiene de todas formas:
FROM golang:1.21-alpine
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp
EXPOSE 8080
CMD ["./myapp"]
Esto funciona, pero la imagen final aún contiene el conjunto de herramientas de Go y el caché de compilación. El mismo patrón aparece con Node, Java, Rust, paquetes de Python con extensiones nativas y compilaciones de frontend.
El costo es práctico:
- Aumenta el tamaño de la imagen: Más capas, más datos que descargar y almacenar.
- Extiende los tiempos de despliegue: Las imágenes más grandes tardan más en transferirse.
- Introduce riesgos de seguridad: Una superficie de ataque más grande con software innecesario.
- Oscurece el entorno de ejecución: Dificulta entender qué es realmente necesario.
Las imágenes más pequeñas no son automáticamente más rápidas en tiempo de ejecución, pero son más rápidas de mover a través de CI, registros y sistemas de despliegue. También hacen que la revisión de seguridad sea menos ruidosa.
Qué hacen los builds multi-etapa
Cada instrucción FROM inicia una nueva etapa. Puedes nombrar una etapa y copiar archivos de ella más tarde:
FROM golang:1.21-alpine AS builder
# construir archivos aquí
FROM alpine:3.20
COPY --from=builder /app/myapp /app/myapp
La segunda etapa comienza desde cero. No contiene /usr/local/go, archivos fuente, cachés de paquetes o herramientas de compilación de la primera etapa a menos que los copies.
Un ejemplo limpio de Go
Aquí hay una pequeña aplicación:
package main
import (
"fmt"
"log"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "¡Hola desde una imagen Docker optimizada!")
}
func main() {
http.HandleFunc("/", handler)
log.Println("Servidor iniciando en :8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
El Dockerfile multi-etapa:
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o myapp
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/myapp /app/myapp
EXPOSE 8080
CMD ["/app/myapp"]
Los archivos go.mod y go.sum se copian antes del árbol fuente completo para que Docker pueda reutilizar la capa de descarga de dependencias cuando solo cambia el código de la aplicación. CGO_ENABLED=0 es útil cuando quieres un binario estático. Si tu aplicación depende de bibliotecas C, es posible que necesites una imagen de ejecución que incluya esas bibliotecas en lugar de forzar compilaciones estáticas.
Construye y compara:
docker build -t go-app:multi-stage .
docker images go-app:multi-stage
docker history go-app:multi-stage
No te fíes del tamaño de ejemplo de un blog. Revisa tu propia imagen. Las elecciones de dependencias, las versiones de la imagen base, los símbolos de depuración, los certificados, los datos de zona horaria y las bibliotecas nativas afectan el resultado.
Elecciones de imagen base de ejecución
alpine es popular porque es pequeña, pero pequeña no siempre es sinónimo de compatible. Alpine usa musl libc, mientras que muchas distribuciones comunes de Linux usan glibc. La mayoría de los binarios estáticos de Go funcionan bien. Algunos paquetes de Python, Node, Java o nativos se comportan de manera diferente.
Opciones comunes de ejecución:
| Base de ejecución | Buen ajuste | Compensación |
|---|---|---|
alpine |
Imágenes pequeñas, binarios simples | Diferencias de compatibilidad con musl |
debian:bookworm-slim |
Amplia compatibilidad con Linux | Más grande que Alpine |
| Imágenes Distroless | Entornos de producción con menos herramientas | Más difícil de depurar dentro del contenedor |
scratch |
Solo binarios estáticos | Sin shell, certificados CA o gestor de paquetes a menos que se copien |
Si la aplicación llama a endpoints HTTPS, asegúrate de que la imagen final incluya certificados CA. Una imagen scratch sin certificados puede fallar de una manera que parezca un problema de red.
FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates
FROM scratch
COPY --from=builder /app/myapp /myapp
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/myapp"]
Builds multi-etapa para otros lenguajes/frameworks
La misma idea funciona en cualquier lugar donde haya un paso de compilación.
Para un frontend de Node:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
Para una API de Node, no copies node_modules de una instalación de desarrollo si incluye dependencias de desarrollo:
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]
Para Java:
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /src
COPY pom.xml .
RUN mvn -q -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -DskipTests package
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /src/target/app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]
El caché de compilación también importa
Los builds multi-etapa reducen el tamaño final de la imagen, pero el orden del Dockerfile aún controla el comportamiento del caché. Coloca los archivos de dependencias estables antes que los archivos fuente volátiles. Usa npm ci en lugar de npm install en compilaciones reproducibles. Fija las versiones de la imagen base en lugar de depender de latest en producción.
Con BuildKit, los montajes de caché pueden acelerar las descargas de paquetes sin incluir los cachés en la imagen final:
# syntax=docker/dockerfile:1.7
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -o myapp
Ese caché es para la máquina de compilación, no para la imagen de ejecución.
Qué copiar y qué no copiar
Copia el conjunto de ejecución completo más pequeño. Para un servicio compilado, puede ser un binario más plantillas de configuración y certificados CA. Para un frontend, puede ser un directorio dist. Para Java, puede ser un jar más un JRE.
No copies código fuente, cachés del gestor de paquetes, fixtures de prueba, archivos .env locales, claves SSH o resultados de compilación que no ejecutes. Usa un archivo .dockerignore para que estos archivos no entren en el contexto de compilación en primer lugar:
.git
node_modules
coverage
dist
*.log
.env
El archivo .dockerignore no reemplaza las instrucciones COPY cuidadosas, pero evita la hinchazón accidental del contexto y las fugas de secretos.
Depuración de builds multi-etapa
Nombra tus etapas. Una etapa nombrada es más fácil de apuntar:
docker build --target builder -t app-builder .
docker run --rm -it app-builder sh
Esto es útil cuando la compilación tiene éxito pero la imagen final falla porque un archivo se copió en la ruta incorrecta o falta una biblioteca de ejecución.
También puedes inspeccionar los archivos copiados en la imagen final:
docker run --rm -it --entrypoint sh my-image
Si la imagen no tiene shell, cambia temporalmente la etapa final a una base amigable para la depuración mientras diagnosticas, luego vuelve a poner la base de producción.
Una regla práctica
Usa una etapa para cada trabajo distinto: dependencias, compilación, pruebas, ejecución. Mantén la etapa de ejecución aburrida. Si alguien abre la etapa final del Dockerfile, debería poder responder rápidamente una pregunta: ¿qué archivos necesita realmente este contenedor para ejecutarse?
Ese es el verdadero valor de los builds multi-etapa. Las imágenes más pequeñas son agradables. Los límites claros de ejecución son mejores.