Docker multietapa en producción: de 1 GB a 10 MB

“Falló el push de la imagen, timeout.”
Era un viernes por la tarde del año pasado y el pipeline de CI/CD estaba en rojo. Miraba en pantalla esa imagen de una aplicación Go de 980 MB y se me encogió el corazón. Un compañero de operaciones se acercó y suspiró: “Tu imagen es más grande que la película que descargué al mediodía.”
Después usé construcción multietapa.
10 MB. La misma aplicación, la misma funcionalidad: el volumen pasó de 980 MB a 10 MB. Desapareció el 99 % del tamaño y el push en CI/CD bajó de 3 minutos a 3 segundos.
En este artículo comparto trucos prácticos de construcción multietapa, incluidas plantillas completas de Dockerfile para Go, Node.js y Python, y 5 errores habituales que resumí tras tropezar con ellos. Si quieres que tus imágenes de producción pasen de “hinchadas” a “ligeras”, sigue leyendo.
¿Por qué tu imagen es tan voluminosa?
La verdad es que la mayoría de las imágenes Docker hinchadas tienen las mismas causas.
Yo escribí un Dockerfile así:
FROM ubuntu:20.04
RUN apt-get update && apt-get install -y golang
COPY . /app
WORKDIR /app
RUN go build -o myapp
CMD ["./myapp"]
Parece normal, ¿no? Pero un docker images y ahí estaba: 980 MB.
¿Dónde está el problema? En cuatro palabras: lo que debe quedarse no queda, y lo que debe irse no se va.
En concreto:
- Imagen base demasiado grande:
ubuntu:20.04ya pesa 77 MB; con la cadena de herramientas Go supera los 900 MB - Herramientas de compilación residuales: gcc, make, git… en producción no sirven
- Caché sin limpiar: la caché de apt/apk se queda en las capas de la imagen
- Dependencias redundantes: dependencias de desarrollo y frameworks de test también van dentro
Es como ir de viaje con maleta, saco de dormir, tienda y utensilios de cocina… cuando solo vas a un hotel. La construcción multietapa te hace llevar solo lo necesario — ropa y artículos de aseo — y dejar el resto en casa.
Según la documentación oficial de Docker, una aplicación Go típica sin optimizar ronda entre 800 MB y 1 GB; optimizada puede comprimirse a 10-20 MB. La diferencia es esa de grande.
El principio central de la construcción multietapa
La idea es muy simple: separar el entorno de compilación del de ejecución.
Un Dockerfile tradicional mete compilación, empaquetado y ejecución en una sola imagen. La construcción multietapa permite definir varias instrucciones FROM; cada FROM abre una nueva fase.
Un ejemplo mínimo:
# Primera fase: construcción
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp
# Segunda fase: ejecución
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]
La sintaxis clave son dos líneas:
FROM ... AS builder: nombra la faseCOPY --from=builder: copia archivos desde la fase builder
En la práctica, Docker ejecuta cada fase en orden, pero la imagen final solo incluye la última. Las herramientas de compilación voluminosas y la caché de dependencias se descartan.
Según el tutorial de iximiuz Labs de 2026, la esencia de la construcción multietapa es aprovechar el mecanismo de capas de Docker: cada FROM inicia un contexto de build independiente; puedes copiar archivos de cualquier fase a las siguientes, pero lo irrelevante nunca entra en la imagen final.
Es como reformar una casa: en la primera fase viene la cuadrilla con taladro, martillo y sierra; en la segunda te mudas con muebles y electrodomésticos. La cuadrilla se va y las herramientas también; en casa solo queda lo que necesitas.
Caso práctico: plantillas multietapa en tres lenguajes
Go: de 980 MB a 10 MB
Go es el lenguaje que mejor encaja con multietapa porque compila a un binario estático.
Dockerfile completo:
# Fase de construcción
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Copiar primero go.mod y go.sum para aprovechar la caché
COPY go.mod go.sum ./
RUN go mod download
# Copiar código fuente y compilar
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o myapp .
# Fase de ejecución
FROM scratch
# Copiar el binario desde builder
COPY --from=builder /app/myapp /myapp
# Copiar certificados CA (si necesitas llamadas HTTPS)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/myapp"]
Algunos trucos:
FROM scratch: imagen vacía, punto de partida de 0 bytes, solo tu binarioCGO_ENABLED=0: desactiva CGO y genera un binario estático puro- Certificados CA: si la app llama APIs HTTPS, hay que copiar el archivo de certificados
- Optimización de caché de dependencias: copia go.mod/go.sum antes y luego go mod download; así un cambio en el código no fuerza a redescargar dependencias
Al terminar el build, la imagen ronda 10 MB. Frente a los 980 MB originales, una reducción del 99 %.
Si scratch te parece demasiado extremo (sin shell, difícil de depurar), puedes usar alpine:
FROM alpine:3.18
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
La imagen será un poco mayor, unos 15 MB, pero tendrás un entorno donde puedes entrar con docker exec para depurar.
Node.js: de 900 MB a 120 MB
En Node.js la construcción multietapa es algo más compleja porque hay que gestionar node_modules.
Dockerfile completo:
# Fase de construcción
FROM node:18-alpine AS builder
WORKDIR /app
# Copiar package.json
COPY package*.json ./
# Instalar todas las dependencias (incluidas devDependencies)
RUN npm ci
# Copiar código fuente
COPY . .
# Si hay paso de build (p. ej. compilación TypeScript)
RUN npm run build
# Fase de producción
FROM node:18-alpine
WORKDIR /app
# Configurar variable de entorno Node
NODE_ENV=production
# Instalar solo dependencias de producción
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copiar artefactos de build
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
Puntos clave:
npm ci --only=production: instala solodependencies, omitedevDependencies; el volumen cae a la mitad al instantenpm cache clean --force: limpia la caché de npm; si no, se queda en la capa de la imagen- Separar build y ejecución: la compilación TypeScript ocurre en builder; la imagen de producción solo tiene JS
Según datos de Oak Oliver Engineering, una app Express típica sin optimizar ronda 900 MB; con multietapa, unos 120 MB. Reducción aproximada del 87 %.
Python: de 300 MB a 100 MB
Python es un caso especial: no hay paso de compilación, pero los paquetes de dependencias son enormes (numpy, pandas pueden pesar cientos de MB).
Dockerfile completo:
# Fase de construcción
FROM python:3.9-slim AS builder
WORKDIR /app
# Instalar dependencias en directorio de usuario
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Fase de producción
FROM python:3.9-alpine
WORKDIR /app
# Copiar dependencias
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# Copiar código de la aplicación
COPY . .
EXPOSE 8000
CMD ["python", "app.py"]
Aquí se usa pip install --user, instalando en /root/.local y copiando todo el directorio a la imagen de producción.
Trucos centrales:
--no-cache-dir: pip cachea los paquetes descargados por defecto; este flag evita residuos de caché- slim vs alpine: en build usa
slim(mejor compatibilidad), en producciónalpine(menor volumen) - Entorno virtual: si las dependencias son complejas, considera venv en lugar de
--user
Datos reales: un proyecto con FastAPI + SQLAlchemy, imagen original ~300 MB; con multietapa ~100 MB.
Elección de imagen base: Alpine vs Distroless vs Slim
Elegir la imagen base de la fase de ejecución es una decisión con trade-offs.
Resumo la comparación de las tres opciones habituales:
| Característica | Alpine | Distroless | Slim |
|---|---|---|---|
| Tamaño base | 3-5 MB | 20-65 MB | 50-100 MB |
| Seguridad | Media | Muy alta | Media |
| Dificultad de depuración | Baja (tiene shell) | Alta (sin shell) | Baja (tiene shell) |
| Compatibilidad | Con matices (glibc) | Buena | Buena |
| Casos de uso | Binarios estáticos Go | Alta exigencia de seguridad | Node.js/Python |
Alpine: el menor volumen, pero cuidado con glibc
Alpine Linux usa musl libc en lugar de glibc estándar. Para Go no hay problema (compilación estática), pero algunas dependencias de Python o Node.js pueden fallar.
Caí en esa trampa: un proyecto Python con numpy no arrancaba en Alpine, error ImportError: cannot import name 'random'. Tras investigar, era un tema de compatibilidad musl/glibc.
Dos soluciones:
- Instalar
libc6-compat:apk add libc6-compat - O usar directamente
slimen lugar dealpine
Distroless: referente en seguridad, pero incómodo para depurar
Distroless es una familia de imágenes de Google: sin shell, sin gestor de paquetes, solo lo imprescindible para ejecutar la app.
Según el análisis de danieldemmel.me, Distroless elimina la mayoría de CVE de alto riesgo porque un atacante no puede ejecutar comandos vía shell.
El precio: cuando algo falla, no puedes docker exec para ver logs o depurar. Solo logs externos y monitorización.
Si buscas seguridad extrema, Distroless es la mejor opción:
FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/myapp /
ENTRYPOINT ["/myapp"]
Slim: opción equilibrada
Las imágenes oficiales -slim (como node:18-slim, python:3.9-slim) son un término medio entre Alpine y la imagen completa.
Algo más grandes que Alpine, pero mejor compatibilidad y shell para depurar. Si no quieres pelearte con musl/glibc, slim es la opción cómoda.
Mi recomendación:
- Apps Go: prioriza
scratchoalpine - Node.js/Python: empieza con
slim, confirma que todo va bien y luego pruebaalpine - Alta exigencia de seguridad: usa
distroless, pero prepara antes un plan de depuración
Guía de errores: 5 fallos habituales y cómo evitarlos
Tras escribir tantos Dockerfiles, los errores que he pisado llenarían una piscina. Estos son los 5 más frecuentes.
Error 1: COPY —from=0 copiando todo
Error típico de quien empieza: copiar en bloque desde la fase anterior.
# Mal
FROM builder
COPY --from=0 /app /app
Eso trae todo el directorio de builder, incluida la cadena Go, caché npm, archivos temporales… la imagen se hincha al instante.
Lo correcto: copiar solo lo necesario.
# Bien
COPY --from=builder /app/myapp /myapp
COPY --from=builder /app/dist /dist
Error 2: no limpiar la caché
La caché de apt/apk puede quedarse en la capa aunque la borres después.
# Mal (la caché queda en la capa anterior)
RUN apt-get update && apt-get install -y curl
RUN apt-get clean
Lo correcto: limpiar en la misma capa que la instalación.
# Bien
RUN apt-get update && apt-get install -y curl && apt-get clean && rm -rf /var/lib/apt/lists/*
O usar --no-cache:
RUN apk add --no-cache curl
Error 3: compatibilidad glibc en Alpine
Como comenté, Alpine usa musl libc; parte de las dependencias Python/Node.js no son compatibles.
Error típico:
ImportError: cannot import name 'random' from 'numpy.random'
Solución: instalar libc6-compat o cambiar a slim.
Error 4: no definir usuario no root
Por defecto el contenedor corre como root, con riesgo de seguridad mayor.
Buena práctica: crear un usuario dedicado.
RUN adduser -D appuser
USER appuser
Así, aunque comprometan el contenedor, el atacante solo tiene permisos de usuario normal.
Error 5: ignorar .dockerignore
.dockerignore es la “lista de resta” del Dockerfile. Sin configurarlo, COPY . . trae todo el proyecto: .git, node_modules, tests…
Crea .dockerignore:
.git
.gitignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
*.md
.env
Reduce el contexto de build y acelera la construcción de la imagen.
Conclusión
La construcción multietapa es la técnica más práctica para adelgazar imágenes Docker.
La idea en una frase: en el entorno de build dejan las herramientas de compilación; en ejecución solo va la aplicación.
Repaso de datos:
- Go: 980 MB → 10 MB (reducción del 99 %)
- Node.js: 900 MB → 120 MB (reducción del 87 %)
- Python: 300 MB → 100 MB (reducción del 67 %)
Si aún no has usado multietapa, pruébalo ahora. Elige un proyecto, reescribe el Dockerfile con las plantillas de arriba y compara con docker images.
Seguro que te sorprende — al menos el push en CI/CD ya no hará timeout.
Optimización de imágenes Docker con construcción multietapa
Flujo completo para reducir una imagen Docker voluminosa al mínimo tamaño posible
⏱️ Estimated time: 30 min
- 1
Step 1: Analizar la composición actual de la imagen
Usa el comando `docker history` para ver el tamaño de cada capa:
```bash
docker history your-image:tag
```
Identifica las capas que más espacio ocupan, normalmente:
• La imagen base en sí
• Herramientas de compilación y dependencias de build
• Caché del gestor de paquetes - 2
Step 2: Escribir un Dockerfile multietapa
Crea un Dockerfile con fase de construcción y fase de ejecución:
```dockerfile
# Fase de construcción
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp .
# Fase de ejecución
FROM alpine:3.18
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
```
Puntos clave:
• Usa AS para nombrar cada fase
• COPY --from=builder copia solo los archivos necesarios - 3
Step 3: Construir y comparar el tamaño de la imagen
Construye la nueva imagen y compara el volumen:
```bash
docker build -t myapp:optimized .
docker images | grep myapp
```
Compara la diferencia de tamaño antes y después de la optimización. - 4
Step 4: Verificar que la aplicación funciona correctamente
Ejecuta el contenedor y prueba la aplicación:
```bash
docker run -d -p 8080:8080 myapp:optimized
curl http://localhost:8080/health
```
Asegúrate de que la funcionalidad es completa y no faltan dependencias. - 5
Step 5: Desplegar en producción
Actualiza el flujo de CI/CD para usar la nueva imagen:
• Súbela al registro de imágenes
• Actualiza el Deployment de Kubernetes o docker-compose.yml
• Verifica que el despliegue sea correcto
FAQ
¿La construcción multietapa afecta la velocidad de build?
¿Cómo elegir entre Alpine y Distroless?
¿Para qué lenguajes sirve la construcción multietapa?
¿Cómo gestionar archivos de configuración en una build multietapa?
¿Cuánto puede reducirse el volumen de la imagen con multietapa?
¿Qué hay que tener en cuenta con FROM scratch?
10 min de lectura · Publicado el: 19 abr 2026 · Actualizado el: 21 ago 2026
Guía práctica de Docker
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Builds multietapa de Docker en la práctica: imágenes Go/Java/Rust de GB a MB
Análisis profundo de los builds multietapa de Docker con casos reales: optimiza imágenes Go un 98%, Java un 86% y Rust un 99,4%. Incluye Dockerfiles completos y lecciones aprendidas.
Parte 5 de 38
Siguiente
Acelerar builds de Docker: guía práctica para ir 10 veces más rápido con caché
Domina la caché por capas de Docker, la configuración de .dockerignore y la optimización del Dockerfile para pasar de 10 minutos a 30 segundos. Con ejemplos de código completos y caché montada de BuildKit.
Parte 7 de 38



Comentarios
Inicia sesión con GitHub para dejar un comentario