Cristhian Villegas
DevOps11 min read0 views

Curso Docker #7: Multi-Stage Builds — Optimiza Tus Imagenes

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.

Logo de Docker

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:

dockerfile
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.

Dato: La imagen base 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:

  1. Etapa de build: instala todas las herramientas, compila el codigo, genera los artefactos
  2. Etapa final: usa una imagen minima y solo copia los artefactos compilados
dockerfile
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:

dockerfile
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.

Tip: Usa 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:

dockerfile
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:

dockerfile
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):

dockerfile
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.

Nota: Las imagenes 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%
bash
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
Recomendacion general: Usa Alpine para Node.js y Python. Usa Distroless para Java. Usa scratch o Distroless para Go. Usa slim si tienes problemas de compatibilidad con Alpine (musl vs glibc).

BuildKit y optimizaciones avanzadas

Docker BuildKit es el motor de construccion moderno de Docker que ofrece varias ventajas:

bash
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:

dockerfile
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 FROM y COPY --from para 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
Siguiente articulo: En la parte 8 aprenderemos sobre Seguridad en Docker — mejores practicas para proteger tus contenedores, escaneo de vulnerabilidades y hardening de Dockerfiles. Referencia oficial: Multi-Stage Builds Documentation.
Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles