Cómo crear imágenes Docker ligeras con multistage y minimizar capas
Docker & Containers · Por Kevin Marcondes · Publicado el · Actualizado el · 11 min de lectura
Esta guía muestra, de forma práctica, cómo crear imágenes Docker ligeras usando builds multistage y minimizando capas. Verás ejemplos de Dockerfile para Node.js y Go, técnicas de cache con BuildKit, .dockerignore, distroless y cómo medir tamaño y tiempo de build para validar los beneficios.
Requisitos previos y escenario de prueba (Node.js)
Necesitas Docker instalado y funcionando. Si aún no lo tienes, sigue esta guía: instalar Docker en Linux. Vamos a crear una app Node.js simple para medir antes y después del multistage.
Usaremos también un .dockerignore para evitar copiar archivos innecesarios. Cada paso viene comentado para que puedas reproducirlo y adaptarlo a tu proyecto.
- Docker instalado y usuario con permiso para ejecutar containers
- Conexión a Internet para descargar imágenes base
- Editor de texto y terminal
mkdir -p nodeapp && cd nodeapp
cat > package.json <<'EOF'
{
"name": "nodeapp",
"private": true,
"type": "module",
"scripts": { "start": "node server.js" }
}
EOF
cat > server.js <<'EOF'
import http from 'node:http'
const port = process.env.PORT || 3000
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('ok')
})
server.listen(port, () => console.log(`listening on ${port}`))
EOF
cat > .dockerignore <<'EOF'
node_modules
npm-cache
.git
Dockerfile*
.dockerignore
*.log
EOF
Capas, cache y BuildKit en pocas palabras
Cada instrucción en el Dockerfile crea una capa. Menos capas bien diseñadas ayudan al cache y al pull. El cache evita rehacer pasos cuando nada cambió. BuildKit mejora el paralelismo, el cache granular y los mounts de cache por herramienta.
Vamos a activar BuildKit para aprovechar cache avanzado y una salida de build más clara.
- COPY de archivos estables antes de archivos volátiles aumenta el cache hit
- Agrupar comandos relacionados reduce capas
- Mounts de cache de BuildKit aceleran npm, pip y go
# Ative o BuildKit no shell atual
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
# Opcional: usar builder moderno
docker buildx create --use --name fastbuilder 2>/dev/null || docker buildx use fastbuilder
Dockerfile ingenuo (sin multistage) para comparar
Vamos crear un Dockerfile básico que copia todo e instala dependencias. Es funcional, pero más grande de lo necesario. Después comparamos con la versión multistage.
Si estás empezando con Dockerfile, este otro guía da el contexto inicial: crear tu primer Dockerfile.
- Una base oficial completa aumenta el tamaño final
- COPY . . lleva archivos innecesarios si no tienes un .dockerignore bien hecho
- Instalar devDependencies por error infla la imagen
cat > Dockerfile.naive <<'EOF'
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
EOF
# Build inicial
docker build -f Dockerfile.naive -t nodeapp:naive .
# Ver o tamanho
docker images nodeapp:naive
Multistage build optimizado para Node.js (con distroless)
Ahora, un Dockerfile multistage. Primero resolvemos dependencias en una imagen de build. Luego copiamos solo lo necesario a una imagen final mínima basada en distroless, sin shell.
Las imágenes distroless reducen la superficie de ataque y el tamaño, pero no permiten shell interactivo. Usa logs y probes de salud para diagnosticar.
- Separar deps de runtime evita llevar herramientas de build
- distroless elimina shell y gestor de paquetes del runtime
- COPY selectivo asegura solo lo esencial en el stage final
cat > Dockerfile.multi <<'EOF'
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
RUN corepack enable
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Se usar TypeScript/Vite/etc., descomente sua etapa de build
# RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=build /app /app
EXPOSE 3000
# distroless executa "node" como entrypoint; indique o script
CMD ["server.js"]
EOF
# Build otimizado
docker build -f Dockerfile.multi -t nodeapp:multi .
# Checar tamanho
docker images | grep nodeapp
Ejemplo extra: binario Go en imagen mínima
Para lenguajes compilados, la ganancia es aún mayor. Construimos el binario en una imagen de build y copiamos solo el ejecutable a una base mínima distroless estática.
Ejecutar como usuario no‑root es buena práctica. Ajusta GOOS/GOARCH según el target de tu despliegue.
- CGO desactivado genera binario estático y portable
- Usa cache de módulos para acelerar builds repetidos
- ENTRYPOINT directo al binario simplifica
mkdir -p ../goapp && cd ../goapp
cat > go.mod <<'EOF'
module example.com/goapp
go 1.22
EOF
cat > main.go <<'EOF'
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
http.ListenAndServe(":8080", nil)
}
EOF
cat > Dockerfile.go <<'EOF'
# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod .
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/app
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /out/app /app/app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/app"]
EOF
# Build e ver tamanho
docker build -f Dockerfile.go -t goapp:distroless .
docker images goapp:distroless
cd -
Técnicas para minimizar capas y archivos
Además del multistage, combina comandos y evita enviar archivos innecesarios en el contexto del build. Con BuildKit, los mounts de cache aceleran los gestores de paquetes. La tabla resume cuándo usar cada técnica.
Si tu CI/CD usa GitHub Actions, integra cache de build para obtener mejoras consistentes: pipeline de despliegue automático.
- Agrupa RUN con && y limpieza en la misma capa
- Usa .dockerignore para no enviar basura al daemon
- Coloca COPY package*.json antes de COPY . para cache
- Cache de npm/pip/go acelera builds repetidos
| Técnica | Cuándo usar | Observaciones |
|---|---|---|
| .dockerignore | Siempre | Reduce el contexto de build y las invalidaciones innecesarias |
| RUN combinado | Base Debian/Alpine | La limpieza en el mismo RUN evita capas con cache de paquetes |
| Mount de cache (BuildKit) | npm/pip/go | Reutiliza caches entre builds sin que lleguen a la imagen final |
| distroless/scratch | Producción | Menor superficie de ataque; sin shell, usa logs y probes |
| Usuario no‑root | Siempre | Reduce el impacto de vulnerabilidades en el runtime |
# Exemplo de RUN combinado (Debian/Ubuntu base)
cat > Dockerfile.snippets <<'EOF'
FROM debian:stable-slim AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# BuildKit cache de npm
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# .dockerignore típico
# node_modules\n.git\nDockerfile*\n.dockerignore\n*.log\nbuild\ndist\ncoverage\n
EOF
Cómo medir tamaño, capas y ganancias de cache
Vamos a comparar builds e inspeccionar capas. Los números varían según la máquina, la red y el cache local. Haz builds sin cache y luego con cache para notar la diferencia.
Puedes gestionar containers y ver métricas con una interfaz usando Portainer: usar Portainer.
- Mide sin cache para la línea base
- Repite con cache para ver la diferencia
- Usa docker history para ver capas y tamaños
# Na pasta nodeapp
cd nodeapp
# Linha de base (sem cache)
/usr/bin/time -f "naive sem cache: %E" docker build --no-cache -f Dockerfile.naive -t nodeapp:naive .
/usr/bin/time -f "multi sem cache: %E" docker build --no-cache -f Dockerfile.multi -t nodeapp:multi .
docker images | grep nodeapp
# Exemplo (pode variar):
# nodeapp multi 29MB
# nodeapp naive 180MB
# Com cache (repetir build)
/usr/bin/time -f "naive com cache: %E" docker build -f Dockerfile.naive -t nodeapp:naive .
/usr/bin/time -f "multi com cache: %E" docker build -f Dockerfile.multi -t nodeapp:multi .
# Inspecione camadas
docker history nodeapp:naive | head -n 10
docker history nodeapp:multi | head -n 10
Cómo confirmar que funcionó y solucionar problemas
Levanta los containers y realiza una petición HTTP. Recuerda: las imágenes distroless no tienen shell. Usa logs, healthchecks y variables de entorno para depurar.
Si algo falla, verifica puertos, permisos de archivos y variables de entorno. Ajusta el COPY y el .dockerignore para incluir solo lo necesario.
- ¿Error EACCES? Revisa permisos y usuario no‑root
- Sin shell en distroless: usa docker logs y probes
- ¿Build fallando? Confirma el orden de COPY y el cache
- ¿Puerto equivocado? Revisa EXPOSE y el mapeo -p
# Node (porta 3000)
docker run -d --rm -p 3000:3000 --name nodeapp nodeapp:multi
curl -sf http://localhost:3000 && echo "\nOK"
docker logs nodeapp | tail -n +1
# Go (porta 8080)
docker run -d --rm -p 8080:8080 --name goapp goapp:distroless
curl -sf http://localhost:8080 && echo "\nOK"
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
Checklist rápido antes de commitear
Antes de versionar tu Dockerfile, valida estos puntos. Evitarán builds lentos e imágenes pesadas en el futuro. Para profundizar en la infraestructura de ejecución, mira también este comparativo: Docker vs VMs para alta disponibilidad.
- .dockerignore cubre node_modules, logs y directorios de build
- COPY package*.json antes de COPY .
- Usa multistage y una imagen final mínima (slim/distroless)
- Sin herramientas de build en el stage final
- Ejecuta como usuario no‑root cuando sea posible
- BuildKit activado y caches funcionando
Preguntas frecuentes
¿Por qué los multistage builds generan imágenes más pequeñas?
Los multistage separan la fase de build (compilación e instalación de dependencias) del runtime. En el stage final solo se copian los artefactos necesarios, por lo que compiladores, caches temporales y herramientas de desarrollo no están en la imagen que se despliega. Eso reduce volumen y superficie de ataque y evita llevar archivos innecesarios al contenedor de producción.
¿Cuándo usar distroless en lugar de alpine o slim?
Distroless conviene cuando quieres minimizar la imagen y la superficie de ataque y no necesitas un shell en el runtime. Estas imágenes no incluyen intérprete ni gestor de paquetes, por lo que para depurar debes apoyarte en logs, probes de salud y variables de entorno. Si necesitas debug interactivo o herramientas en el container, prefieres alpine o slim.
¿Alpine siempre deja la imagen más pequeña?
Alpine suele generar imágenes pequeñas pero no garantiza el menor tamaño en todos los casos. Dependencias nativas, glibc versus musl y compatibilidades de librerías pueden hacer que alpine no sea la opción óptima para algunos stacks (por ejemplo Node.js o Python). La guía recomienda probar alpine y slim, comparando tamaño, tiempos de build y comportamiento en producción antes de elegir.
¿Cómo usar cache de dependencias con BuildKit?
Con BuildKit se usan mounts de cache en instrucciones RUN, por ejemplo --mount=type=cache apuntando al directorio de cache de npm, pip o go. Además, coloca los archivos de manifiesto (package.json, go.mod) en COPY antes de copiar todo el código para maximizar aciertos de cache. Así las dependencias solo se reinstalan cuando cambian los manifiestos, acelerando builds repetidos.
¿Puedo usar scratch como imagen final?
Scratch es adecuada para binarios estáticos (por ejemplo Go con CGO desactivado) porque la imagen queda vacía y muy pequeña. Ten en cuenta que scratch no incluye shell, certificados ni utilidades; si tu aplicación necesita CA o zonas horarias debes copiarlos en el stage final o usar una distroless que ya incluya lo necesario. No hay depuración interactiva en scratch.
¿Cómo inspeccionar capas y entender qué pesa?
Usa docker history para ver las capas y sus tamaños y docker system df -v para entender el uso de cache local. Inspecciona qué instrucciones crean capas grandes y reorganiza el Dockerfile (COPY de manifiestos antes de COPY ., agrupar RUN y limpieza en la misma capa). Elimina archivos generados que no se necesitan en el stage final para reducir el tamaño.
¿Quieres que revisemos tus Dockerfiles y pipelines?
Si deseas ayuda práctica para reducir el tamaño de tus imágenes y acelerar builds con multistage y BuildKit, podemos conversar sobre tu caso.
Hablar sobre optimización de buildsVer también
- Tutorial: implementar Canary releases con Traefik en Docker Compose usando headers y weighted routing para despliegues graduales de aplicaciones PHP
- Tutorial: configurar HAProxy con Let's Encrypt y healthchecks para balanceo de tráfico entre contenedores Docker en VPS (paso a paso)
- Tutorial práctico: implementar mTLS entre Nginx y microservicios usando smallstep CA en Docker Compose
- Tutorial: configurar cgroup v2 y systemd ResourceControl para limitar CPU, memoria e I/O de contenedores Docker en Debian