Curso Docker #7: Multi-Stage Builds — Optimiza Tus Imagenes
Bienvenido al Curso de Docker - Parte 7 de 10. Una de las quejas mas comunes al empezar con Docker es: "mi imagen pesa 1 GB". Imagenes pesadas significan builds lentos, pulls lentos y mas costos de almacenamiento. La solucion son los multi-stage builds.

Fuente: Wikimedia Commons
En este articulo aprenderemos a reducir drasticamente el tamano de nuestras imagenes Docker usando builds de multiples etapas, con ejemplos reales para Java, Node.js, Python y Go.
Por que el tamano de la imagen importa
El tamano de una imagen Docker tiene impacto directo en:
- Velocidad de despliegue: imagenes mas pequenas se descargan y despliegan mas rapido
- Costos de almacenamiento: registries como Docker Hub, GHCR o ECR cobran por almacenamiento
- Seguridad: menos paquetes instalados = menor superficie de ataque
- Tiempo de CI/CD: builds y pushes mas rapidos en tu pipeline
- Consumo de red: menos ancho de banda al hacer pull en cada servidor
Veamos un ejemplo comun. Una aplicacion Node.js con un Dockerfile basico:
1# Dockerfile MALO - imagen pesada
2FROM node:20
3WORKDIR /app
4COPY package*.json ./
5RUN npm install
6COPY . .
7RUN npm run build
8EXPOSE 3000
9CMD ["node", "dist/index.js"]
Esta imagen puede pesar mas de 1 GB porque incluye todo el SDK de Node.js, herramientas de compilacion, dependencias de desarrollo, archivos fuente, y mas. Tu aplicacion compilada quizas pesa solo 10 MB, pero esta arrastrando 990 MB de cosas innecesarias.
node:20 pesa ~1.1 GB. La version node:20-alpine pesa ~140 MB. Y con multi-stage builds, tu imagen final puede pesar menos de 50 MB.
Que son los multi-stage builds
Un multi-stage build es un Dockerfile que usa multiples instrucciones FROM. Cada FROM inicia una nueva "etapa" (stage) de construccion. Puedes copiar artefactos de una etapa a otra con COPY --from, dejando atras todo lo que no necesitas.
El concepto es simple:
- Etapa de build: instala todas las herramientas, compila el codigo, genera los artefactos
- Etapa final: usa una imagen minima y solo copia los artefactos compilados
1# Etapa 1: BUILD
2FROM node:20 AS builder
3WORKDIR /app
4COPY package*.json ./
5RUN npm ci
6COPY . .
7RUN npm run build
8
9# Etapa 2: PRODUCCION (imagen final)
10FROM node:20-alpine AS production
11WORKDIR /app
12COPY --from=builder /app/dist ./dist
13COPY --from=builder /app/node_modules ./node_modules
14COPY --from=builder /app/package.json ./
15EXPOSE 3000
16USER node
17CMD ["node", "dist/index.js"]
La imagen final solo contiene Alpine Linux + Node.js runtime + tu codigo compilado. Todo lo demas (TypeScript compiler, devDependencies, archivos fuente) se queda en la etapa de build y no aparece en la imagen final.
Ejemplo practico: Node.js con TypeScript
Veamos un ejemplo completo para una API de Node.js con TypeScript, optimizando al maximo:
1# ---- Etapa 1: Dependencias ----
2FROM node:20-alpine AS deps
3WORKDIR /app
4COPY package.json package-lock.json ./
5# Solo instala dependencias de produccion
6RUN npm ci --omit=dev
7
8# ---- Etapa 2: Build ----
9FROM node:20-alpine AS builder
10WORKDIR /app
11COPY package.json package-lock.json ./
12RUN npm ci
13COPY tsconfig.json ./
14COPY src/ ./src/
15RUN npm run build
16
17# ---- Etapa 3: Produccion ----
18FROM node:20-alpine AS production
19WORKDIR /app
20
21# Crear usuario no-root
22RUN addgroup -S appgroup && adduser -S appuser -G appgroup
23
24# Solo copiar lo necesario
25COPY --from=deps /app/node_modules ./node_modules
26COPY --from=builder /app/dist ./dist
27COPY --from=builder /app/package.json ./
28
29# Cambiar a usuario no-root
30USER appuser
31
32EXPOSE 3000
33HEALTHCHECK --interval=30s --timeout=3s \
34 CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1
35
36CMD ["node", "dist/index.js"]
Usamos tres etapas: una para dependencias de produccion, otra para compilar, y la final que solo copia los artefactos necesarios. Esto es especialmente util porque las dependencias de produccion (etapa 1) se cachean independientemente del codigo fuente.
npm ci --omit=dev en lugar de npm install para instalaciones reproducibles que no incluyen devDependencies.
Ejemplo practico: Java con Spring Boot
Las imagenes de Java son notoriamente pesadas. Veamos como optimizarlas:
1# ---- Etapa 1: Build con Maven ----
2FROM eclipse-temurin:21-jdk AS builder
3WORKDIR /app
4
5# Copiar archivos de Maven para cachear dependencias
6COPY pom.xml ./
7COPY .mvn .mvn
8COPY mvnw ./
9RUN chmod +x mvnw
10RUN ./mvnw dependency:go-offline -B
11
12# Copiar codigo fuente y compilar
13COPY src ./src
14RUN ./mvnw package -DskipTests -B
15
16# Extraer las capas del JAR para optimizar cache
17RUN java -Djarmode=layertools -jar target/*.jar extract --destination /app/extracted
18
19# ---- Etapa 2: Runtime ----
20FROM eclipse-temurin:21-jre-alpine AS production
21WORKDIR /app
22
23# Crear usuario no-root
24RUN addgroup -S spring && adduser -S spring -G spring
25
26# Copiar capas del JAR (de menos a mas cambiante)
27COPY --from=builder /app/extracted/dependencies/ ./
28COPY --from=builder /app/extracted/spring-boot-loader/ ./
29COPY --from=builder /app/extracted/snapshot-dependencies/ ./
30COPY --from=builder /app/extracted/application/ ./
31
32USER spring
33EXPOSE 8080
34
35HEALTHCHECK --interval=30s --timeout=5s \
36 CMD wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1
37
38ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Observa que usamos eclipse-temurin:21-jdk para compilar (necesita el JDK completo) pero eclipse-temurin:21-jre-alpine para produccion (solo necesita el JRE). Ademas, las capas del JAR de Spring Boot se copian por separado para optimizar el cache de Docker.
| Etapa | Imagen base | Tamano aprox. |
|---|---|---|
| Build (JDK) | eclipse-temurin:21-jdk |
~470 MB |
| Produccion (JRE Alpine) | eclipse-temurin:21-jre-alpine |
~100 MB |
| Imagen final con app | - | ~130 MB |
Ejemplo practico: Python con FastAPI
Python no se "compila" como Java, pero igualmente podemos optimizar la imagen separando la instalacion de dependencias:
1# ---- Etapa 1: Build de dependencias ----
2FROM python:3.12-slim AS builder
3WORKDIR /app
4
5# Instalar dependencias de compilacion si son necesarias
6RUN apt-get update && apt-get install -y --no-install-recommends \
7 gcc \
8 && rm -rf /var/lib/apt/lists/*
9
10# Crear virtual environment
11RUN python -m venv /opt/venv
12ENV PATH="/opt/venv/bin:$PATH"
13
14COPY requirements.txt .
15RUN pip install --no-cache-dir -r requirements.txt
16
17# ---- Etapa 2: Produccion ----
18FROM python:3.12-slim AS production
19WORKDIR /app
20
21# Copiar solo el virtual environment
22COPY --from=builder /opt/venv /opt/venv
23ENV PATH="/opt/venv/bin:$PATH"
24
25# Crear usuario no-root
26RUN groupadd -r appuser && useradd -r -g appuser appuser
27
28# Copiar codigo de la aplicacion
29COPY app/ ./app/
30
31USER appuser
32EXPOSE 8000
33
34HEALTHCHECK --interval=30s --timeout=3s \
35 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
36
37CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
La clave aqui es el virtual environment (/opt/venv). Lo creamos en la etapa de build con todas las herramientas de compilacion, y luego lo copiamos completo a una imagen slim sin compiladores.
Ejemplo practico: Go (el rey de las imagenes pequenas)
Go compila a un binario estatico, lo que permite usar imagenes scratch (completamente vacias):
1# ---- Etapa 1: Build ----
2FROM golang:1.22-alpine AS builder
3WORKDIR /app
4
5# Cachear dependencias
6COPY go.mod go.sum ./
7RUN go mod download
8
9# Compilar binario estatico
10COPY . .
11RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
12
13# ---- Etapa 2: Imagen final (FROM scratch = 0 bytes) ----
14FROM scratch AS production
15
16# Certificados SSL para llamadas HTTPS
17COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
18
19# Copiar el binario
20COPY --from=builder /app/server /server
21
22EXPOSE 8080
23ENTRYPOINT ["/server"]
La imagen final pesa solo el tamano del binario de Go (tipicamente 10-20 MB). No hay sistema operativo, no hay shell, no hay nada mas. Es la imagen mas segura y ligera posible.
scratch no tienen shell ni herramientas de debug. Si necesitas depurar en produccion, usa gcr.io/distroless/static-debian12 como alternativa. Distroless incluye certificados SSL y timezone data pero sigue siendo extremadamente ligera.
Comparacion de tamanos: antes vs despues
Veamos el impacto real de los multi-stage builds:
| Lenguaje | Imagen sin optimizar | Con multi-stage | Reduccion |
|---|---|---|---|
| Node.js | ~1.1 GB | ~150 MB | ~86% |
| Java/Spring Boot | ~700 MB | ~130 MB | ~81% |
| Python/FastAPI | ~450 MB | ~120 MB | ~73% |
| Go | ~350 MB | ~15 MB | ~96% |
1# Comparar tamanos de imagenes
2docker images | grep mi-app
3
4# Ver el detalle de capas y tamanos
5docker history mi-app:latest
6
7# Analizar la imagen con dive (herramienta recomendada)
8docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive mi-app:latest
Alpine vs Slim vs Distroless: cual elegir
No todas las imagenes base son iguales. Aqui una guia rapida:
| Imagen base | Tamano | Pros | Contras |
|---|---|---|---|
ubuntu:22.04 |
~77 MB | Familiar, muchos paquetes | Pesada, mayor superficie de ataque |
debian:bookworm-slim |
~80 MB | Compatible con glibc | Mas pesada que Alpine |
alpine:3.19 |
~7 MB | Ultra ligera, segura | Usa musl (posibles incompatibilidades) |
distroless |
~2-20 MB | Sin shell, ultra segura | Dificil de depurar |
scratch |
0 bytes | Lo mas ligero posible | Solo para binarios estaticos |
BuildKit y optimizaciones avanzadas
Docker BuildKit es el motor de construccion moderno de Docker que ofrece varias ventajas:
1# Activar BuildKit (ya es default en Docker Desktop)
2export DOCKER_BUILDKIT=1
3
4# Build con cache de mount para paquetes
5docker build --progress=plain -t mi-app .
6
7# Build multi-plataforma (ARM + AMD64)
8docker buildx build --platform linux/amd64,linux/arm64 -t mi-app:latest .
9
10# Usar cache externo para CI/CD
11docker buildx build \
12 --cache-from type=registry,ref=ghcr.io/mi-usuario/mi-app:cache \
13 --cache-to type=registry,ref=ghcr.io/mi-usuario/mi-app:cache \
14 -t mi-app:latest .
BuildKit tambien permite cache mounts para acelerar instalaciones de dependencias:
1# Cache mount para npm
2RUN --mount=type=cache,target=/root/.npm npm ci
3
4# Cache mount para Maven
5RUN --mount=type=cache,target=/root/.m2 ./mvnw package -DskipTests
6
7# Cache mount para pip
8RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
9
10# Cache mount para Go
11RUN --mount=type=cache,target=/go/pkg/mod go build -o /app/server .
Resumen y siguiente articulo
En este articulo aprendimos como optimizar nuestras imagenes Docker:
- Por que importa el tamano: velocidad, costos, seguridad y eficiencia en CI/CD
- Multi-stage builds: usar multiples
FROMyCOPY --frompara separar build de runtime - Ejemplos reales para Node.js, Java/Spring Boot, Python y Go
- Comparacion de tamanos: reducciones del 73% al 96%
- Imagenes base: Alpine vs Slim vs Distroless vs scratch
- BuildKit: cache mounts y builds multi-plataforma
Comments
Sign in to leave a comment
No comments yet. Be the first!