Builds multietapa de Docker en la práctica: imágenes Go/Java/Rust de GB a MB

Una imagen de 650 MB que me hizo pensar
El viernes por la tarde miraba el dashboard de K8s: un Pod llevaba cinco minutos descargando la imagen. Una app Spring Boot simple, empaquetada en 650 MB. Un becario del equipo me preguntó: «¿Por qué es tan grande?» Me quedé en blanco; nunca me lo había planteado en serio.
Esa noche investigué. A la mañana siguiente, la misma app pesaba 89 MB. El despliegue pasó de 5 minutos a menos de 1 minuto. El becario me miró distinto.
Solo cambié unas líneas del Dockerfile. La técnica se llama build multietapa.
Al principio pensé que no servía de mucho. Nuestros proyectos Go pesaban 295 MB, Java superaba 500 MB; nos acostumbramos a que las imágenes fueran así. Hasta que vi a alguien comprimir una imagen Rust de 2 GB a 11 MB — sí, de GB a MB — y entendí que llevaba años empaquetando mal.
El núcleo del problema: los lenguajes compilados necesitan compiladores y herramientas de build, pero en runtime no hacen falta. El Dockerfile tradicional empaqueta Maven, Gradle y el compilador de Go, como mudarte y llevar el taladro y el cemento de la reforma: no tiene sentido.
Hoy verás tres casos reales con Go, Java y Rust para adelgazar imágenes con builds multietapa:
- Go de 295 MB a 6,47 MB (98% menos)
- Java Spring Boot de 650 MB a 89 MB (86% menos)
- Rust de 2 GB a 11,2 MB (99,4% menos)
Si solo te interesa un lenguaje, salta al capítulo correspondiente. Cada caso es completo y puedes copiar el código directamente.
¿Por qué tu imagen es tan hinchada?
Primero, una tabla con datos reales de mis pruebas:
| Lenguaje | Build monolítico | Build multietapa | Reducción |
|---|---|---|---|
| Go | 295 MB | 6,47 MB | 98% |
| Java Spring Boot | 650 MB | 89 MB | 86% |
| Rust | 2,1 GB | 11,2 MB | 99,4% |
La primera vez que vi esta tabla me quedé helado. Sobre todo Rust: de 2 GB a 11 MB. No es optimización, parece magia.
Luego analicé la composición de las imágenes y entendí el problema. Los lenguajes compilados necesitan un compilador para generar el binario. Go usa el compilador de Go, Java Maven o Gradle, Rust Cargo. No son pequeños:
- Compilador Go: ~300 MB
- Maven + OpenJDK: ~500 MB
- Toolchain Rust: ~1,5 GB
Un Dockerfile tradicional se ve así:
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]
¿Parece correcto? En realidad, no. Usa toda la imagen golang:1.21 (295 MB) con compilador, herramientas de build y depuración. Tras compilar, nada se elimina: todo va a la imagen final.
Es como comprar un piso en obra, reformarlo y quedarte con cemento, taladro, sierra y la caja de herramientas del albañil dentro. Absurdo, pero muchos Dockerfiles hacen exactamente eso.
En runtime solo necesitas el binario compilado. Un binario Go suele pesar unos MB. Un jar de Java, un poco más, pero decenas de MB. Rust también es pequeño. El resto — cientos de MB — son herramientas de compilación y la imagen base.
La hinchazón no solo desperdicia almacenamiento. Los dolores reales:
- Descarga lenta: en CI/CD, cada despliegue descarga la imagen. 650 MB con mala red te hacen dudar de la vida
- Riesgo de seguridad: compiladores, código fuente y herramientas de build en producción son regalos para un atacante
- Caché de build desperdiciada: cambias una línea y reconstruyes todo, porque compilador y código van acoplados
Recuerdo un despliegue en Alibaba Cloud: cinco personas desplegando a la vez, cada una con 500 MB+, saturando la red interna. Desde entonces me propuse estudiar optimización de imágenes.
Build multietapa: un Dockerfile, dos entornos
Los builds multietapa llegaron con Docker 17.05. La idea es simple: varias etapas en un Dockerfile; las primeras compilan, las últimas ejecutan, y solo pasan los artefactos necesarios.
En lenguaje llano: la cuadrilla reforma en la primera etapa; en la segunda te mudas solo con la casa terminada. Cemento y taladro se quedan en la primera.
Un ejemplo mínimo:
# Primera etapa: build
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp
# Segunda etapa: runtime
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]
¿Ves los puntos clave?
- El primer FROM lleva
AS builder, un nombre para la etapa - El segundo FROM inicia una etapa nueva con la imagen ligera alpine
- COPY —from=builder copia solo el binario; el resto se descarta
La etapa golang:1.21 pesa 295 MB, pero la imagen final es alpine (~5 MB) más tu binario (unos MB): unas docenas de MB en total.
Cuando entendí esto, pensé: «solo quiero el resultado, no el proceso». Compilador, código fuente e intermedios son proceso; el binario es el resultado. Docker descarta la primera etapa y conserva la segunda.
¿Y si necesitas depurar la primera etapa? Docker tiene un comando muy útil:
docker build --target builder -t myapp:debug .
--target builder construye solo hasta esa etapa. Así puedes entrar al contenedor de build para depurar.
El diseño es elegante. Puedes tener tres, cuatro o más etapas:
FROM node:18 AS frontend-builder
# Build del frontend
FROM golang:1.21 AS backend-builder
# Build del backend
FROM nginx:alpine
# Copiar artefactos de frontend y backend
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
COPY --from=backend-builder /app/api /usr/local/bin/api
Cada etapa hace su trabajo; la de runtime reúne todo. El Dockerfile queda muy claro.
Ahora, con lenguajes compilados, uso build multietapa por defecto. Ya es hábito.
Adelgazamiento extremo con Go
Go es mi favorito para optimizar imágenes. ¿Por qué? El binario es estático, sin dependencias de librerías del sistema; puedes ejecutarlo en una imagen vacía.
Primero, la forma tradicional (no la copies):
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]
Construye:
docker build -t myapp:old .
docker images myapp:old
# REPOSITORY TAG IMAGE ID SIZE
# myapp old abc123def456 295MB
295 MB. Un HTTP service simple.
Versión multietapa optimizada:
# Etapa de build
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Copiar dependencias y descargarlas (aprovechar caché)
COPY go.mod go.sum ./
RUN go mod download
# Copiar código y compilar
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o myapp .
# Etapa de runtime
FROM scratch
WORKDIR /app
COPY --from=builder /app/myapp .
EXPOSE 8080
CMD ["./myapp"]
Otra vez:
docker build -t myapp:new .
docker images myapp:new
# REPOSITORY TAG IMAGE ID SIZE
# myapp new def456ghi789 6.47MB
¡6,47 MB! De 295 MB, 98% menos.
Puntos clave del Dockerfile:
1. CGO_ENABLED=0
Desactiva CGO y genera un binario estático puro. Si usas librerías C (p. ej. SQLite), no funciona; necesitas otra imagen base.
2. -ldflags=“-w -s”
Parámetros de optimización:
-w: quita información de depuración-s: quita la tabla de símbolos
Reduce el binario un 20-30% más. En producción casi nunca necesitas esos datos.
3. FROM scratch
scratch es la imagen vacía de Docker: 0 bytes. Un binario estático de Go corre sin librerías del sistema.
4. Truco de caché de dependencias
Copio primero go.mod y go.sum, luego go mod download. Si no cambian, Docker reutiliza la capa de dependencias. Cambiar código de negocio no fuerza redescarga. Los rebuilds pasan de 2 minutos a 15 segundos en mis pruebas.
Avanzado: zona horaria y certificados CA
scratch no trae ni zona horaria ni CA. Si tu app hace HTTPS o maneja zonas horarias, fallará. Copia desde builder:
FROM scratch
WORKDIR /app
# Datos de zona horaria
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Certificados CA
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/myapp .
ENV TZ=Asia/Shanghai
CMD ["./myapp"]
O usa gcr.io/distroless/static-debian11 en lugar de scratch: ~2 MB, con zona horaria y CA:
FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/myapp /app/myapp
CMD ["/app/myapp"]
Yo suelo usar distroless; menos dolores de cabeza.
Adelgazamiento elegante con Java/Spring Boot
Java es más complejo. Necesita JRE; no puedes usar scratch como en Go. Pero con el método correcto, el volumen baja mucho.
Forma tradicional (muy común):
FROM maven:3.8-openjdk-17
WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests
CMD ["java", "-jar", "target/myapp.jar"]
Resultado: 650 MB. Maven ~500 MB más el jar y cachés.
Versión multietapa:
# Etapa de build
FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /app
# Copiar pom.xml y descargar dependencias (caché)
COPY pom.xml .
RUN mvn dependency:go-offline -B
# Copiar código y empaquetar
COPY src ./src
RUN mvn package -DskipTests
# Etapa de runtime
FROM openjdk:17-jre-slim
WORKDIR /app
# Solo el jar
COPY --from=builder /app/target/*.jar app.jar
# Ajuste JVM
ENV JAVA_OPTS="-Xms128m -Xmx512m -XX:+UseContainerSupport"
EXPOSE 8080
CMD java $JAVA_OPTS -jar app.jar
Resultado:
docker images myapp:new
# REPOSITORY TAG IMAGE ID SIZE
# myapp new xyz789abc012 89MB
De 650 MB a 89 MB, 86% menos.
Puntos clave:
1. JDK vs JRE
Build con openjdk-17 (compilador); runtime con openjdk-17-jre-slim (solo runtime). JRE es mucho más pequeño:
- OpenJDK 17: ~500 MB
- OpenJDK 17 JRE: ~200 MB
- OpenJDK 17 JRE Slim: ~80 MB
2. Caché de dependencias Maven
Copia primero pom.xml y ejecuta mvn dependency:go-offline. Si pom.xml no cambia, la capa se cachea. Sin esto, cada cambio de código redescargaba dependencias; el repo Maven fuera de China a menudo tardaba 10-15 minutos. Ahora, segundos.
3. Ajuste JVM
-XX:+UseContainerSupport hace que la JVM respete los límites de memoria del contenedor. Sin esto, asigna según el host y puedes tener OOM.
-Xms128m -Xmx512m define el heap. Ajusta según tu app; no pongas 1G a ciegas.
Versión Gradle
Con Gradle, algo así:
FROM gradle:8.5-jdk17 AS builder
WORKDIR /app
# Configuración Gradle
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
# Descargar dependencias
RUN gradle dependencies --no-daemon
# Código y build
COPY src ./src
RUN gradle bootJar --no-daemon
FROM openjdk:17-jre-slim
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
ENV JAVA_OPTS="-Xms128m -Xmx512m -XX:+UseContainerSupport"
CMD java $JAVA_OPTS -jar app.jar
Trampa: build por capas de Spring Boot
Spring Boot 2.3+ permite separar dependencias y código de negocio para mejorar caché:
FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests
RUN java -Djarmode=layertools -jar target/*.jar extract
FROM openjdk:17-jre-slim
WORKDIR /app
# Copiar capas en orden; dependencias cambian menos
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
CMD ["java", "org.springframework.boot.loader.JarLauncher"]
Aunque cambies código de negocio, la capa de dependencias no se invalida. Honestamente, yo no lo uso mucho: más mantenimiento; la versión simple basta.
Despliegue mínimo con Rust
Rust da el efecto más espectacular. Toolchain enorme (1,5 GB+), binario minúsculo. La primera vez: de 2,1 GB a 11,2 MB.
Forma tradicional:
FROM rust:1.75
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/myapp"]
Resultado: 2,1 GB. rustc, cargo y dependencias, todo empaquetado.
Versión multietapa:
# Etapa de build
FROM rust:1.75 AS builder
WORKDIR /app
# Dependencias primero (caché)
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# Código real
COPY src ./src
RUN touch src/main.rs # Actualizar timestamp, forzar recompilación
RUN cargo build --release
# Etapa de runtime
FROM gcr.io/distroless/cc-debian11
WORKDIR /app
COPY --from=builder /app/target/release/myapp .
CMD ["./myapp"]
Resultado:
docker images myapp:new
# REPOSITORY TAG IMAGE ID SIZE
# myapp new rst345uvw678 11.2MB
¡11,2 MB! 99,4% menos.
Caché específica de Rust
Compilar dependencias en Rust es lento (a veces 15 minutos). El truco: crear un main.rs falso, compilar dependencias, borrarlo y copiar el código real.
Si Cargo.toml y Cargo.lock no cambian, la capa de dependencias se cachea. Cambiar código solo recompila tu crate. En un proyecto mediano, de 15 minutos a 2.
Enlace estático vs dinámico
Por defecto, el binario puede depender de glibc. Si es Rust puro, sin C, compila con musl y usa scratch:
FROM rust:1.75 AS builder
WORKDIR /app
# Toolchain musl
RUN rustup target add x86_64-unknown-linux-musl
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl
RUN rm -rf src
COPY src ./src
RUN touch src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl
# Runtime
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
CMD ["/myapp"]
Puede bajar a 5-8 MB.
Yo suelo usar gcr.io/distroless/cc: mejor compatibilidad, unos MB más, vale la pena.
Trampa: ubicación de caché de Cargo
Algunos tutoriales cachean /usr/local/cargo. No lo hagas. Muchos intermedios inflan la capa de caché y ralentizan. Lo probé: capa de 800 MB, cada build eterno.
Trucos avanzados: builds rápidos y estables
Lo básico del build multietapa ya lo vimos. Aquí, técnicas para acelerar y estabilizar.
1. Archivo .dockerignore
Crucial y a menudo ignorado. Como .gitignore, indica qué no copiar al contexto de build.
Sin esto, Docker empaquetaba todo el proyecto (node_modules, .git, target…) y lo enviaba al daemon. Cientos de MB; solo el contexto tardaba medio minuto.
Ahora creo .dockerignore en cada proyecto:
# Control de versiones
.git
.gitignore
# Dependencias
node_modules
target
dist
build
# IDE
.vscode
.idea
*.swp
# Tests y docs
**/*_test.go
**/*_test.rs
*.md
docs/
# Secretos
.env
.env.local
*.key
*.pem
Los builds son 3-5 veces más rápidos, sobre todo en CI/CD.
2. Depurar builds multietapa
--target otra vez. ¿Falla en builder?
# Solo hasta builder
docker build --target builder -t myapp:debug .
# Entrar a la imagen
docker run -it myapp:debug sh
# Ejecutar build manualmente
Muy útil. Un build Go falló con un error raro; entré al contenedor, ejecuté go build manualmente y descubrí incompatibilidad de versión.
3. Nombrar etapas
Nombres con sentido, no stage1/stage2:
# Mal
FROM golang:1.21 AS stage1
FROM node:18 AS stage2
FROM nginx AS stage3
# Bien
FROM golang:1.21 AS backend-builder
FROM node:18 AS frontend-builder
FROM nginx AS runtime
En seis meses te lo agradecerás.
4. BuildKit en paralelo
BuildKit es el motor nuevo: builds paralelos y mejor caché:
export DOCKER_BUILDKIT=1
docker build .
O puntualmente:
DOCKER_BUILDKIT=1 docker build .
Ventajas:
- Etapas independientes en paralelo
- Caché más inteligente
- Salida más clara
Lo uso por defecto; en CI/CD también DOCKER_BUILDKIT=1.
5. Fijar versión de imagen base
No uses FROM golang:latest. En producción, fija versión:
# Mal — comportamiento impredecible
FROM golang:latest
# Bien — versión explícita
FROM golang:1.21.5-alpine3.18
# Mejor — SHA256
FROM golang@sha256:abc123...
Un despliegue falló porque golang:latest se actualizó y rompió dependencias. Desde entonces, siempre fijo versión.
6. Orden de COPY y caché
El orden importa. Archivos que cambian poco primero:
# Buen orden
FROM golang:1.21-alpine AS builder
WORKDIR /app
# 1. Dependencias (cambian poco)
COPY go.mod go.sum ./
RUN go mod download
# 2. Código (cambia mucho)
COPY . .
RUN go build -o myapp
# Mal orden
COPY . . # Todo de golpe
RUN go mod download && go build -o myapp
En el primero, cambiar código no redescarga dependencias. En el segundo, cualquier cambio invalida todo.
Lo repetimos en los casos, pero merece énfasis: es el núcleo de la optimización de caché.
Guía de elección de imagen base
Elegir imagen base cansa. Alpine, Slim, Distroless, Scratch… Cada uno tiene defensores. Tabla según mi experiencia:
| Tipo | Volumen | Contenido | Ventajas | Desventajas | Uso |
|---|---|---|---|---|---|
| scratch | 0 MB | Vacío | Mínimo volumen, mínima superficie de ataque | Sin shell, sin depuración, sin CA | Go/Rust estático |
| distroless | 2-20 MB | Runtime, CA | Sin shell, seguro, pequeño | Depuración difícil | Go, Java, Rust, Node.js |
| alpine | 5-40 MB | musl libc, gestor paquetes | Pequeño, con shell | musl, problemas DNS | Sin dependencia glibc |
| slim | 70-120 MB | Debian/Ubuntu reducido | glibc completo, compatible | Un poco más grande | Con dependencias C |
| Completa | 200 MB+ | Sistema completo | Todas las herramientas | Grande, riesgo | No recomendada en prod |
Mi estrategia:
Go
- Preferida:
gcr.io/distroless/static-debian11(con CA) - Alternativa:
scratch(copiar CA y zona horaria manualmente) - Con CGO:
gcr.io/distroless/base-debian11oalpine
Java
- Preferida:
openjdk:17-jre-slim - Avanzada:
gcr.io/distroless/java17-debian11(más pequeña, depuración difícil) - Evitar:
openjdk:17-alpine(bugs JVM en Alpine)
Rust
- Preferida:
gcr.io/distroless/cc-debian11 - Estático puro:
scratch - Evitar: imagen
rustcompleta en producción
Trampas de Alpine
Muchos lo recomiendan; yo he tropezado:
- musl vs glibc: Alpine usa musl; Java y Python a veces fallan raro
- DNS: Go en Alpine puede tener timeouts DNS; requiere config extra
- Zona horaria: sin datos por defecto; instala
tzdata
Si no dominas Alpine, -slim es más seguro. Unos MB más, menos sorpresas.
Sobre Distroless
Distroless de Google: sin shell, sin gestor de paquetes, solo lo esencial para runtime.
Ventajas:
- Superficie de ataque mínima (¿shell para qué?)
- Volumen pequeño
- Mantenimiento oficial y parches
Desventajas:
- Depuración difícil; no hay
docker exec -it - Preparar todo en build
En producción uso Distroless. Para depurar, --target builder; en prod, Distroless.
Árbol de decisión
¿Qué lenguaje?
├─ Go
│ ├─ Go puro → distroless/static o scratch
│ └─ Con CGO → distroless/base o alpine
├─ Java
│ ├─ Estabilidad → openjdk:jre-slim
│ └─ Volumen → distroless/java
├─ Rust
│ ├─ Rust puro → distroless/cc o scratch
│ └─ Con C → distroless/cc
└─ Otro
└─ Prueba slim primero
Cubre ~90% de mis proyectos.
Tres ideas centrales
La esencia del build multietapa:
- Etapa de build: imagen completa para compilar
- Etapa de runtime: imagen mínima para ejecutar
- COPY —from: solo los artefactos necesarios
Simple, pero el efecto es brutal: 70-90% menos volumen, builds 2-3 veces más rápidos con caché, seguridad mucho mejor.
Mi hábito: en proyectos nuevos, el primer Dockerfile es multietapa. Go, Java o Rust, mismo patrón. Ya es memoria muscular.
Acciones concretas:
Hoy mismo:
- Elige un proyecto y prueba build multietapa
- Compara con
docker imagesantes y después - Si funciona, extiéndelo al resto
Para profundizar:
- Documentación oficial de Docker
- Herramienta
divepara analizar capas (docker run --rm -it wagoodman/dive:latest your-image) - Funciones avanzadas de BuildKit
A largo plazo:
- Caché de imágenes en CI/CD
- Actualizar imágenes base periódicamente
- Escanear con Trivy u otras herramientas
Para cerrar: optimizar imágenes Docker no es difícil técnicamente; lo difícil es la mentalidad. Muchos equipos tienen imágenes de hace años sin revisar, cada vez más grandes, hasta que el despliegue se vuelve insoportable.
No esperes a ese día. Prueba hoy el build multietapa: verás lo pequeñas y rápidas que pueden ser tus imágenes.
Cuéntame en comentarios: ¿cuánto pesa la imagen de tu proyecto? ¿Qué resultados obtuviste? ¿Qué trampas encontraste? Me interesa saberlo.
Flujo completo de optimización con builds multietapa de Docker
Imágenes Go/Java/Rust de GB a MB con Dockerfiles completos y lecciones aprendidas
⏱️ Estimated time: 1 hr
- 1
Step 1: Entender el principio del build multietapa
Principio del build multietapa:
• Los lenguajes compilados necesitan compiladores y herramientas de build para generar el ejecutable
• Pero en runtime no hacen falta
• El Dockerfile tradicional empaqueta Maven, Gradle y el compilador de Go
• Es como mudarte y llevar el taladro y el cemento de la reforma a la casa nueva: no tiene sentido
El núcleo del problema:
• Los lenguajes compilados necesitan compiladores y herramientas de build
• Pero en runtime no hacen falta
Pasos del build multietapa:
• Primera etapa (build): imagen completa con compilador y herramientas, compila el código
• Segunda etapa (runtime): imagen mínima con solo el runtime, copia el ejecutable de la primera etapa
• La imagen final solo contiene runtime y ejecutable - 2
Step 2: Build multietapa con Go en la práctica
Build multietapa con Go:
Primera etapa (build):
• FROM golang:1.21 AS builder
• WORKDIR /app
• COPY go.mod go.sum ./
• RUN go mod download
• COPY . .
• RUN go build -o app
Segunda etapa (runtime):
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/app .
• CMD ["./app"]
Resultados:
• Solo se copia el binario compilado
• Imagen de 295 MB a 6,47 MB (98% menos)
• Despliegue de 5 minutos a menos de 1 minuto
• Volumen de imagen drásticamente reducido - 3
Step 3: Build multietapa con Java y Rust en la práctica
Build multietapa con Java:
Primera etapa (build):
• FROM maven:3.9 AS builder
• WORKDIR /app
• COPY pom.xml .
• RUN mvn dependency:go-offline
• COPY src ./src
• RUN mvn clean package -DskipTests
Segunda etapa (runtime):
• FROM eclipse-temurin:17-jre-alpine
• WORKDIR /app
• COPY --from=builder /app/target/app.jar app.jar
• CMD ["java", "-jar", "app.jar"]
Resultado: imagen de 650 MB a 89 MB (86% menos)
Build multietapa con Rust:
Primera etapa (build):
• FROM rust:1.75 AS builder
• WORKDIR /app
• COPY Cargo.toml Cargo.lock .
• RUN cargo fetch
• COPY src ./src
• RUN cargo build --release
Segunda etapa (runtime):
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/target/release/app .
• CMD ["./app"]
Resultado: imagen de 2 GB a 11 MB (99,4% menos) - 4
Step 4: Mejores prácticas y optimización a largo plazo
Mejores prácticas:
1. Elegir la imagen de runtime adecuada
• Go: distroless/static o scratch
• Java: openjdk:jre-slim o distroless/java
• Rust: distroless/cc o scratch
2. Usar .dockerignore para excluir archivos innecesarios
3. Aprovechar la caché de build (copiar primero dependencias, luego código fuente)
4. Actualizar periódicamente la imagen base para corregir vulnerabilidades
Optimización a largo plazo:
• Configurar caché de imágenes en el pipeline CI/CD
• Actualizar periódicamente la imagen base
• Escanear imágenes con herramientas como Trivy
Optimizar imágenes Docker no es difícil técnicamente; lo difícil es la mentalidad. Muchos equipos tienen imágenes de hace años sin revisar, cada vez más grandes, hasta que el despliegue se vuelve insoportable.
FAQ
¿Qué es un build multietapa de Docker y por qué lo necesitas?
El núcleo del problema: los lenguajes compilados necesitan compiladores y herramientas de build, pero en runtime no hacen falta.
Pasos del build multietapa:
• Primera etapa (build): imagen completa con compilador y herramientas, compila el código
• Segunda etapa (runtime): imagen mínima con solo el runtime, copia el ejecutable de la primera etapa
• La imagen final solo contiene runtime y ejecutable
¿Qué resultados da la optimización con builds multietapa?
• Go de 295 MB a 6,47 MB (98% menos)
• Java Spring Boot de 650 MB a 89 MB (86% menos)
• Rust de 2 GB a 11 MB (99,4% menos)
• Despliegue de 5 minutos a menos de 1 minuto
Caso real: una app Spring Boot simple, imagen Docker de 650 MB; con build multietapa, 89 MB y despliegue de 5 minutos a menos de 1 minuto.
¿Cómo implementar un build multietapa con Go?
Primera etapa con imagen golang:1.21:
• FROM golang:1.21 AS builder
• WORKDIR /app
• COPY go.mod go.sum ./
• RUN go mod download
• COPY . .
• RUN go build -o app
Segunda etapa con imagen alpine:latest:
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/app .
• CMD ["./app"]
Solo se copia el binario compilado; imagen de 295 MB a 6,47 MB (98% menos). Despliegue de 5 minutos a menos de 1 minuto.
¿Cómo implementar builds multietapa con Java y Rust?
Primera etapa con imagen maven:3.9:
• FROM maven:3.9 AS builder
• WORKDIR /app
• COPY pom.xml .
• RUN mvn dependency:go-offline
• COPY src ./src
• RUN mvn clean package -DskipTests
Segunda etapa con imagen eclipse-temurin:17-jre-alpine:
• FROM eclipse-temurin:17-jre-alpine
• WORKDIR /app
• COPY --from=builder /app/target/app.jar app.jar
• CMD ["java", "-jar", "app.jar"]
Imagen de 650 MB a 89 MB (86% menos)
Build multietapa con Rust:
Primera etapa con imagen rust:1.75:
• FROM rust:1.75 AS builder
• WORKDIR /app
• COPY Cargo.toml Cargo.lock .
• RUN cargo fetch
• COPY src ./src
• RUN cargo build --release
Segunda etapa con imagen alpine:latest:
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/target/release/app .
• CMD ["./app"]
Imagen de 2 GB a 11 MB (99,4% menos)
¿Cuáles son las mejores prácticas para builds multietapa?
1) Elegir la imagen de runtime adecuada:
• Go: distroless/static o scratch
• Java: openjdk:jre-slim o distroless/java
• Rust: distroless/cc o scratch
2) Usar .dockerignore para excluir archivos innecesarios
3) Aprovechar la caché de build (copiar primero dependencias, luego código fuente)
4) Actualizar periódicamente la imagen base
Optimización a largo plazo:
• Configurar caché de imágenes en CI/CD
• Actualizar periódicamente la imagen base
• Escanear con herramientas como Trivy
Optimizar imágenes Docker no es difícil técnicamente; lo difícil es la mentalidad. Muchos equipos tienen imágenes de hace años sin revisar, cada vez más grandes, hasta que el despliegue se vuelve insoportable.
14 min de lectura · Publicado el: 17 dic 2025 · 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
Optimización de Dockerfile en la práctica: 5 trucos para reducir el tamaño de la imagen un 80%
¿Tu imagen Docker pesa varios GB? Domina 5 trucos — imagen base Alpine, fusionar instrucciones RUN, build multietapa, .dockerignore y limpieza de caché — para bajar la imagen de 1,2 GB a 180 MB (85% menos). Con caso Node.js completo y datos reales.
Parte 4 de 38
Siguiente
Docker multietapa en producción: de 1 GB a 10 MB
Domina Docker multietapa y reduce imágenes de producción de 1 GB a 10 MB. Plantillas para Go, Node.js y Python, comparativa Alpine vs Distroless y 5 errores habituales que debes evitar.
Parte 6 de 38



Comentarios
Inicia sesión con GitHub para dejar un comentario