Cambiar tema

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

Easton editorial illustration: tradeoff balance table

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

  1. Imagen base demasiado grande: ubuntu:20.04 ya pesa 77 MB; con la cadena de herramientas Go supera los 900 MB
  2. Herramientas de compilación residuales: gcc, make, git… en producción no sirven
  3. Caché sin limpiar: la caché de apt/apk se queda en las capas de la imagen
  4. 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 fase
  • COPY --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:

  1. FROM scratch: imagen vacía, punto de partida de 0 bytes, solo tu binario
  2. CGO_ENABLED=0: desactiva CGO y genera un binario estático puro
  3. Certificados CA: si la app llama APIs HTTPS, hay que copiar el archivo de certificados
  4. 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:

  1. npm ci --only=production: instala solo dependencies, omite devDependencies; el volumen cae a la mitad al instante
  2. npm cache clean --force: limpia la caché de npm; si no, se queda en la capa de la imagen
  3. 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:

  1. --no-cache-dir: pip cachea los paquetes descargados por defecto; este flag evita residuos de caché
  2. slim vs alpine: en build usa slim (mejor compatibilidad), en producción alpine (menor volumen)
  3. 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ísticaAlpineDistrolessSlim
Tamaño base3-5 MB20-65 MB50-100 MB
SeguridadMediaMuy altaMedia
Dificultad de depuraciónBaja (tiene shell)Alta (sin shell)Baja (tiene shell)
CompatibilidadCon matices (glibc)BuenaBuena
Casos de usoBinarios estáticos GoAlta exigencia de seguridadNode.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 slim en lugar de alpine

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 scratch o alpine
  • Node.js/Python: empieza con slim, confirma que todo va bien y luego prueba alpine
  • 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. 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. 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. 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. 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. 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?
La construcción multietapa aumenta el tiempo de build (porque hay que construir dos fases), pero el volumen final de la imagen se reduce mucho y la velocidad de despliegue y transferencia mejora notablemente. En flujos de CI/CD, el tiempo total suele ser menor.
¿Cómo elegir entre Alpine y Distroless?
Para aplicaciones Go compiladas estáticamente, prioriza Alpine o scratch; en Node.js/Python, empieza con slim para confirmar compatibilidad y luego prueba Alpine; en escenarios con requisitos de seguridad muy altos, elige Distroless, pero prepara antes un plan de logs y monitorización.
¿Para qué lenguajes sirve la construcción multietapa?
Casi para todos los lenguajes de programación. El efecto es más notable en Go (puede reducirse a 10 MB), Node.js (más del 80 %), Python (más del 60 %), Rust, Java y otros con paso de compilación o gestión de dependencias.
¿Cómo gestionar archivos de configuración en una build multietapa?
Los archivos de configuración suelen montarse por separado en la fase de ejecución; no se recomienda empaquetarlos en la imagen. Puedes usar Docker volume o Kubernetes ConfigMap. Si deben ir en la imagen, haz COPY en la fase de ejecución.
¿Cuánto puede reducirse el volumen de la imagen con multietapa?
Depende del lenguaje y del tipo de aplicación. En Go suele reducirse entre un 90 % y un 99 % (de 1 GB a 10 MB); en Node.js entre un 70 % y un 90 %; en Python entre un 50 % y un 70 %. La clave es conservar solo los archivos necesarios para ejecutar.
¿Qué hay que tener en cuenta con FROM scratch?
scratch es una imagen vacía: no tiene shell, ni gestor de paquetes, ni certificados CA. Si la aplicación necesita llamadas HTTPS, debes copiar /etc/ssl/certs/ca-certificates.crt desde la fase builder. Depurar es difícil; conviene validar primero con alpine.

10 min de lectura · Publicado el: 19 abr 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog