Como te comenté en el capítulo anterior de este tutorial de Docker, has aprendido a configurar entornos de desarrollo completos con contenedores. Pero hay una habilidad que separa a un usuario ocasional de un profesional de Docker: saber optimizar imágenes.
La optimización de imágenes Docker es un arte que combina técnicas de construcción, selección de imágenes base, gestión de caché y herramientas de análisis. Una imagen bien optimizada no solo ocupa menos espacio en disco y se descarga más rápido, sino que también tiene menor superficie de ataque, se construye en menos tiempo y despliega más rápido en producción.
Una imagen desoptimizada es lenta de construir, pesada de transferir y peligrosa de ejecutar. Cada capa innecesaria, cada paquete que no se limpia, cada archivo temporal que queda en la imagen, es un riesgo de seguridad y un lastre de rendimiento. ¿Te imaginas pasar de 800 MB a 18 MB? Pues sí, se puede.
En este capítulo vas a ver técnicas que van desde lo más básico hasta lo más avanzado: BuildKit, montajes efímeros, multi-stage builds, imágenes distroless, scratch, Alpine, limpieza profunda, herramientas de análisis como Dive y hadolint, y caché remota para CI/CD. Partiremos de ejemplos reales con Python, Node, Go y Java, y las optimizaremos paso a paso hasta reducirlas a su mínima expresión funcional.
BuildKit: el motor moderno de construcción
Antes de meterte en las técnicas de optimización, necesitas conocer la herramienta que las hace posibles: BuildKit.
BuildKit es el motor de construcción que Docker utiliza desde la versión 18.09. Es más rápido, más seguro y más flexible que el antiguo motor «legacy builder». BuildKit permite ejecución concurrente de instrucciones independientes, montajes efímeros que no forman parte de la imagen final, caché eficiente con enlaces simbólicos y permisos, construcciones multi-plataforma, y secretería sin dejar rastro en las capas.
Habilitar BuildKit
BuildKit está activo por defecto desde Docker Engine 23.0+. Si usas una versión anterior, actívalo:
# Variable de entorno
export DOCKER_BUILDKIT=1
# O configuración global en /etc/docker/daemon.json
{
"features": {
"buildkit": true
}
}
Sintaxis del Dockerfile
La primera línea del Dockerfile debe declarar la sintaxis que usarás. BuildKit introduce su propia sintaxis extendida:
# syntax=docker/dockerfile:1
# ↑ Sintaxis estable v1 (Docker 23+)
# syntax=docker/dockerfile:1.4
# ↑ Sintaxis v1.4 — incluye --link, heredity, chmod en COPY
# syntax=docker/dockerfile:1.7
# ↑ Sintaxis v1.7 — incluye --parents en COPY, mejoras en cache mounts
La práctica recomendada hoy es usar # syntax=docker/dockerfile:1 que se resuelve a la última versión estable de la serie 1.x. Si necesitas funciones específicas de una versión, pincha la versión exacta. Esto no es más que la punta del iceberg, porque BuildKit trae mucho más debajo.
Seguridad con BuildKit
BuildKit nunca monta el contexto si no es necesario. Esto significa que si tu Dockerfile no usa COPY . ., BuildKit no envía el contexto al daemon. Además, los montajes temporales (--mount=type=cache, --mount=type=secret) no se almacenan en las capas, evitando fugas de información. Esto te puede ahorrar más de un disgusto si trabajas con secretos en entornos CI/CD.
Montajes avanzados con --mount
BuildKit introduce cuatro tipos de montajes que son fundamentales para la optimización. Se declaran dentro de las instrucciones RUN y existen solo durante la ejecución de esa instrucción — no forman parte de la imagen final. Este es uno de los avances más importantes que trajo BuildKit.
--mount=type=cache
El montaje de caché permite persistir directorios entre distintas ejecuciones de docker build. Es la técnica más impactante para acelerar builds. Si alguna vez has esperado 5 minutos a que se instalen las dependencias en cada build, esto te va a cambiar la vida.
# syntax=docker/dockerfile:1
FROM node:20-alpine
# Cache de npm — evita descargar dependencias en cada build
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
COPY . .
CMD ["node", "index.js"]
# syntax=docker/dockerfile:1
FROM python:3.12-slim
# Cache de pip
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
# syntax=docker/dockerfile:1
FROM golang:1.23-alpine
# Cache de Go modules
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /app
CMD ["/app"]
Opciones del cache mount:
- target — Directorio dentro del contenedor a cachear (ej:
target=/root/.npm) - id — Identificador único del caché (por defecto: target). Útil si quieres cachés separados para producción y desarrollo
- sharing — Modo de compartición entre builds concurrentes:
shared(defecto),private,locked - from — Etapa multi-stage de la que copiar el caché inicial
- source — Ruta dentro de la etapa
froma copiar - mode — Permisos del directorio
- uid / gid — UID/GID del propietario
La caché se almacena físicamente en /var/lib/docker/buildkit/ en el host. Si crece demasiado, puedes limpiarla con:
docker builder prune --filter type=exec.cachemount
--mount=type=bind
Similar a un bind mount en tiempo de compilación. Monta un directorio del contexto (o de otro punto de montaje) dentro del RUN. Muy útil para montar solo las partes del código que necesitas sin copiarlas permanentemente.
# syntax=docker/dockerfile:1
FROM python:3.12-slim
# Monta el código fuente sin copiarlo a la imagen
# El código no forma parte de la imagen final
RUN --mount=type=bind,source=.,target=/app \
pip install --no-cache-dir -r /app/requirements.txt && \
python -m compileall /app
COPY . /app
CMD ["python", "/app/app.py"]
Parámetros: source (ruta en el contexto), target (ruta dentro del contenedor), from (etapa multi-stage de origen), rw (lectura/escritura, defecto: true).
--mount=type=secret
Proporciona secretos (tokens, claves SSH, passwords) a la instrucción RUN sin que queden almacenados en ninguna capa de la imagen. Esto es importantísimo para no dejar tus credenciales en la imagen final.
# syntax=docker/dockerfile:1
FROM node:20-alpine
# Secreto para autenticarse contra un registry privado
RUN --mount=type=secret,id=npmrc \
cp /run/secrets/npmrc ~/.npmrc && \
npm ci --only=production
# Construir pasando el secreto
docker build --secret id=npmrc,src=$HOME/.npmrc -t mi-app .
Parámetros: id (identificador), target (ruta de montaje, defecto: /run/secrets/<id>), required (true/false), mode (permisos), uid/gid.
--mount=type=ssh
Permite reenviar el agente SSH del host al contenedor de build, para clonar repositorios privados sin exponer la clave.
# syntax=docker/dockerfile:1
FROM golang:1.23-alpine
# Clonar repos privados durante el build
RUN --mount=type=ssh \
git clone git@github.com:mi-org/repo-privado.git /app/vendor
docker build --ssh default -t mi-app .
Caché de paquetes: pip, npm, apt, Go, Maven
Cada gestor de paquetes tiene su propio directorio de caché. Montarlos como type=cache reduce drásticamente los tiempos de build. Te voy a mostrar cómo hacerlo con los gestores más populares.
Caché para apt (Debian/Ubuntu)
# syntax=docker/dockerfile:1
FROM ubuntu:24.04
# Cache de paquetes .deb descargados
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update && \
apt-get install -y --no-install-recommends \
python3 \
python3-pip \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
Importante: Aunque la caché montada persiste entre builds, el rm -rf /var/lib/apt/lists/* sigue siendo necesario si quieres que la imagen final no contenga las listas de paquetes (que ocupan ~30 MB). Al haber montado /var/lib/apt/lists como cache mount, ese directorio no forma parte de la capa. Por tanto, el rm -rf no es necesario si usas cache mount: el directorio nunca llega a la imagen.
Caché para pip (Python)
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
El directorio /root/.cache/pip puede acumular decenas de MB. Con --mount=type=cache ese contenido nunca va a la imagen.
Caché para npm (Node.js)
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json .
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production && \
# Limpiar cache residual de npm
rm -rf /root/.npm/_cacache
COPY . .
CMD ["node", "index.js"]
Nota sobre npm ci vs npm install: npm ci es más rápido y determinista que npm install porque instala exactamente lo que dice package-lock.json sin resolver versiones. En CI/CD y producción, usa npm ci siempre.
Caché para Go
# syntax=docker/dockerfile:1
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /app/server
FROM alpine:3.20
COPY --from=builder /app/server /server
CMD ["/server"]
¿Por qué dos cache mounts en Go? /go/pkg/mod cachea los módulos descargados. /root/.cache/go-build cachea los objetos compilados. Sin el segundo, go rebuild compila todo desde cero aunque solo cambie una línea.
Caché para Maven/Gradle (Java)
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 \
mvn dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
mvn package -DskipTests -Dmaven.test.skip=true
FROM eclipse-temurin:21-jre-alpine
COPY --from=builder /app/target/*.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]
Multi-stage builds: el patrón más importante
El multi-stage build es la técnica más poderosa para reducir el tamaño de las imágenes. Consiste en usar múltiples instrucciones FROM en un mismo Dockerfile, donde cada una inicia una nueva etapa que puede copiar artefactos de etapas anteriores. Esto lo puedes entender como una receta de cocina donde tienes una cocina para preparar los ingredientes y otra para montar el plato final.
Patrón básico
# syntax=docker/dockerfile:1
# --- ETAPA 1: Compilación ---
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server
# --- ETAPA 2: Imagen final mínima ---
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/server /server
EXPOSE 8080
CMD ["/server"]
¿Por qué funciona? La imagen final solo contiene Alpine (~5 MB), los paquetes ca-certificates y tzdata (~3 MB), y el binario compilado (~15-20 MB). Total: ~25 MB en lugar de los ~380 MB de la imagen golang:1.23-alpine.
Patrón multi-etapa para aplicaciones complejas
# syntax=docker/dockerfile:1
# Etapa 0: Descarga de dependencias del sistema
FROM debian:bookworm-slim AS base
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# Etapa 1: Build de assets frontend
FROM node:20-alpine AS frontend-builder
WORKDIR /app
COPY frontend/package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY frontend/ .
RUN npm run build
# Etapa 2: Build de backend
FROM maven:3.9-eclipse-temurin-21 AS backend-builder
WORKDIR /app
COPY backend/pom.xml .
RUN --mount=type=cache,target=/root/.m2 \
mvn dependency:go-offline
COPY backend/ .
RUN --mount=type=cache,target=/root/.m2 \
mvn package -DskipTests
# Etapa 3: Imagen final
FROM eclipse-temurin:21-jre-alpine
COPY --from=base /etc/ssl/certs /etc/ssl/certs
COPY --from=frontend-builder /app/dist /app/static
COPY --from=backend-builder /app/target/*.jar /app/app.jar
EXPOSE 8080
CMD ["java", "-jar", "/app/app.jar"]
Ventajas del multi-stage:
- Tamaño reducido: solo lo necesario en la imagen final
- Seguridad: herramientas de compilación (compiladores, gestores de paquetes, SDKs) no viajan a producción
- Separación de responsabilidades: cada etapa hace una cosa
- Caché eficiente: si solo cambia el frontend, la etapa de backend se cachea
- Imagen base única: puedes usar imágenes grandes para compilar y mínimas para ejecutar
Copiar archivos entre etapas
# Copia de una etapa específica
COPY --from=builder /app/server /server
# Copia de una imagen externa (no definida en el Dockerfile)
COPY --from=nginx:1.25-alpine /etc/nginx/nginx.conf /etc/nginx/nginx.conf
# Copia con cambios de propietario (sintaxis v1.4+)
COPY --from=builder --chown=appuser:appuser /app/server /server
# Copia con --link (lo veremos más adelante)
COPY --link --from=builder /app/server /server
Nombrar y referenciar etapas
# Etapas nombradas se referencian por nombre
FROM node:20-alpine AS deps
# ...
FROM node:20-alpine AS build
COPY --from=deps /app/node_modules /app/node_modules
# ...
FROM alpine:3.20 AS runtime
COPY --from=build /app/dist /app
Si una etapa no se referencia desde ninguna otra, no se incluye en la imagen final, pero sí se cachea para builds posteriores.
Distroless: el estándar de oro para producción
Las imágenes distroless de Google (en gcr.io/distroless) son imágenes que contienen solo tu aplicación y sus dependencias de runtime. Sin shell, sin gestor de paquetes, sin utilidades UNIX (no hay ls, cat, ps, bash, apt, curl…).
Esta ausencia tiene consecuencias importantes en tres frentes:
- Tamaño: ~25 MB vs ~120 MB (slim) / ~50 MB (alpine)
- Superficie de ataque: mínima (no hay shell, no hay binarios extra) vs media (hay binarios que explotar)
- Debugging: difícil (necesitas
docker debug) vs fácil (docker exec -it bash) - CVEs: decenas (solo librerías del runtime) vs cientos potenciales
Ejemplo práctico: Python con distroless
# syntax=docker/dockerfile:1
# Etapa de compilación
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir --user -r requirements.txt
COPY . .
# Compilar bytecode para arranque más rápido
RUN python -m compileall .
# Etapa de runtime distroless
FROM gcr.io/distroless/python3-debian12
COPY --from=builder /root/.local /root/.local
COPY --from=builder /app /app
WORKDIR /app
ENV PATH=/root/.local/bin:$PATH
CMD ["app.py"]
Ejemplo práctico: Node.js con distroless
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
COPY . .
FROM gcr.io/distroless/nodejs20-debian12
COPY --from=builder /app /app
WORKDIR /app
CMD ["index.js"]
Variantes de distroless
Google ofrece varias variantes según el runtime necesario:
gcr.io/distroless/static-debian12— Solo binario estático + CA certs (~2 MB)gcr.io/distroless/base-debian12— Base + glibc + SSL + tzdata (~15 MB)gcr.io/distroless/cc-debian12— Base + compilador C/C++ (~25 MB)gcr.io/distroless/python3-debian12— Python 3.11 + base (~50 MB)gcr.io/distroless/nodejs20-debian12— Node.js 20 + base (~65 MB)gcr.io/distroless/java21-debian12— JRE 21 + base (~120 MB)
Todas incluyen el sufijo :nonroot para ejecutar como usuario no root, o :debug que añade una shell BusyBox para debugging.
# No root por defecto
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
EXPOSE 8080
USER 65532:65532
ENTRYPOINT ["/server"]
# Con debugging habilitado
FROM gcr.io/distroless/static-debian12:debug
# Incluye BusyBox — puedes hacer docker exec
Scratch: la imagen de 0 bytes
La imagen scratch es una imagen vacía. No contiene archivos, no contiene directorios, no contiene nada. Es el punto de partida más pequeño posible: 0 bytes.
Solo tiene sentido para binarios compilados estáticamente que no dependen de librerías del sistema. Los lenguajes que producen binarios 100% estáticos son Go (con CGO_ENABLED=0), Rust (con target musl), C (compilado con musl, ej: gcc -static), Zig, Nim y Crystal.
Go en Scratch
# syntax=docker/dockerfile:1
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
# CGO_ENABLED=0 produce binario estático
# -ldflags="-s -w" elimina tablas de símbolos y debug info
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build \
-ldflags="-s -w" \
-o /app/server
# --- Imagen final: SCRATCH ---
FROM scratch
# Certificados CA (necesarios para HTTPS)
COPY --from=alpine:3.20 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Zona horaria (opcional)
COPY --from=alpine:3.20 /usr/share/zoneinfo /usr/share/zoneinfo
# El binario
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Resultado: la imagen final pesa exactamente lo que pesa el binario (~15 MB) más los certificados (~0.5 MB). Total: ~15.5 MB.
API REST en Go con Fiber
Código de ejemplo (main.go):
package main
import (
"github.com/gofiber/fiber/v2"
"log"
)
func main() {
app := fiber.New()
app.Get("/", func(c *fiber.Ctx) error {
return c.SendString("¡Hola desde Scratch!")
})
log.Fatal(app.Listen(":8080"))
}
Dockerfile:
# syntax=docker/dockerfile:1
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build \
-ldflags="-s -w" \
-o /app/server
FROM scratch
COPY --from=alpine:3.20 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
# Construir
docker build -t api-go-scratch .
# Ver tamaño
docker images api-go-scratch
# REPOSITORY TAG IMAGE ID CREATED SIZE
# api-go-scratch latest abcdef123456 5s ago 16.2MB
Rust en Scratch
# syntax=docker/dockerfile:1
FROM rust:1.78-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN --mount=type=cache,target=/usr/local/cargo/registry \
cargo fetch
COPY src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
RUSTFLAGS="-C target-feature=-crt-static" \
cargo build --release --target=x86_64-unknown-linux-musl
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/mi-app /app
ENTRYPOINT ["/app"]
Limitaciones de Scratch
No puedes usar docker exec -it <contenedor> sh porque no hay shell. No hay apt ni apk para instalar nada. No hay ca-certificates a menos que los copies. No hay /etc/passwd por lo que los logs de usuario aparecen como I have no name!.
Si necesitas alguna de estas cosas, considera Distroless o Alpine en lugar de Scratch.
Alpine: optimización y mitos
Alpine Linux es una distribución basada en musl libc y BusyBox, conocida por su tamaño mínimo (~5 MB). Es la imagen base más popular para contenedores Docker pequeños.
Ventajas reales de Alpine
- Tamaño: ~5 MB vs ~80 MB de Debian slim
- Seguridad: menos binarios = menos superficie de ataque
- Velocidad: descarga e inicio ultrarrápidos
- apk: gestor de paquetes rápido y minimalista
Mitos y realidades
Mito 1: «Alpine es siempre la mejor opción» — No. Alpine usa musl libc, no glibc. Esto puede causar problemas con extensiones nativas de Python que compilan contra glibc (ej: cryptography, psycopg2), binarios precompilados para glibc (muchos wheels de PyPI), y aplicaciones que asumen glibc.
Mito 2: «Alpine es más seguro que slim/distroless» — No necesariamente. Alpine tiene su propio conjunto de CVEs. La musl libc ha tenido vulnerabilidades como cualquier otra libc. Además, Alpine incluye shell y utilidades que distroless no tiene.
Mito 3: «Las imágenes Alpine siempre son más pequeñas» — No siempre. Por ejemplo, python:3.12-alpine pesa ~50 MB mientras que python:3.12-slim pesa ~120 MB. Pero si tu app instala paquetes nativos que compilan contra glibc, el tamaño final puede ser mayor en Alpine porque necesitas instalar gcc, musl-dev, libffi-dev, etc.
Cuándo usar Alpine
| Usa Alpine si… | No uses Alpine si… |
|---|---|
| Tu app es 100% Python puro (sin C extensions) | Usas psycopg2, cryptography, oracledb |
| Tu app es Node.js puro (sin módulos nativos) | Usas sharp, bcrypt, node-canvas |
| Tu app es Go/Rust compilado estáticamente | Scratch es mejor opción |
| Necesitas shell en producción (debugging) | Distroless es más seguro |
| Espacio en disco es crítico | El rendimiento de musl es inaceptable |
Dockerfile Alpine optimizado
# syntax=docker/dockerfile:1
FROM python:3.12-alpine
# apk con --no-cache: no guarda listas de paquetes
RUN apk add --no-cache \
ca-certificates \
tzdata
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
La opción --no-cache de apk equivale a apt-get update + apt-get install + rm -rf /var/lib/apt/lists/* en un solo paso. No necesitas limpiar nada.
Python wheels en Alpine
Alpine no tiene ruedas (wheels) precompiladas para muchos paquetes Python porque PyPI no publica wheels para manylinux (basado en glibc) que funcionen con musl. Esto significa que pip tiene que compilar desde el código fuente, lo que:
- Requiere instalar
gcc,musl-dev,python3-devy otras herramientas de compilación - Ralentiza el build significativamente
- Puede fallar si la extensión no es compatible con musl
Solución: no uses Alpine para Python si necesitas extensiones nativas. Usa python:3.12-slim o distroless.
# MAL — compila psycopg2 desde fuente en Alpine
FROM python:3.12-alpine
RUN apk add --no-cache postgresql-dev gcc musl-dev
RUN pip install psycopg2-binary # ¡No existe binary para musl!
# Tiene que compilar desde fuente — build lento y frágil
# BIEN — usar slim
FROM python:3.12-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq-dev && \
rm -rf /var/lib/apt/lists/*
RUN pip install psycopg2-binary # Tiene wheel para glibc
Encadenamiento && y limpieza de capas
Por qué encadenar con &&
Cada instrucción RUN crea una capa independiente en la imagen. Esto es como las capas de una cebolla: cada capa se apila sobre la anterior. Si tienes:
# MAL — tres capas innecesarias
RUN apt-get update
RUN apt-get install -y python3
RUN rm -rf /var/lib/apt/lists/*
La capa 2 (apt-get update) se almacena con los listados de paquetes (~30 MB). La capa 3 instala python3. La capa 4 borra los listados, pero los listados siguen ocupando espacio en la capa 2 porque Docker almacena el diff entre capas, no el estado final.
El resultado: la imagen ocupa 30 MB más de lo necesario.
Solución correcta:
# BIEN — todo en una misma capa
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 && \
rm -rf /var/lib/apt/lists/*
Patrón de limpieza completo
# syntax=docker/dockerfile:1
FROM ubuntu:24.04
RUN apt-get update && \
apt-get install -y --no-install-recommends \
python3 \
python3-pip \
curl \
git \
build-essential \
&& \
# Limpieza de paquetes temporales
apt-get purge -y --auto-remove build-essential && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
apt purge con autoclean y autoremove
Cuando instalas herramientas de compilación solo para construir algo, debes eliminarlas antes de que acabe el RUN. Esto no es posible hacerlo en un RUN posterior porque el espacio ya se perdió.
# Patrón: instalar, usar, purgar — TODO en el mismo RUN
FROM ubuntu:24.04
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
python3-dev \
&& \
# Usar las herramientas (ej: compilar extensión Python)
pip install --no-cache-dir psycopg2 && \
# Purgar herramientas de compilación
apt-get purge -y --auto-remove \
build-essential \
libpq-dev \
python3-dev \
&& \
apt-get clean && \
apt-get autoclean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
Explicación de los comandos apt:
apt-get purge -y --auto-remove— Elimina paquetes y sus dependencias no necesariasapt-get clean— Borra archivos.debdescargados en/var/cache/apt/archives/apt-get autoclean— Borra solo los.debde paquetes que ya no están disponiblesrm -rf /var/lib/apt/lists/*— Borra las listas de paquetes descargadas porapt-get update
Patrón para Python con dependencias de compilación
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
# Instalar dependencias del sistema, instalar pip, purgar
RUN --mount=type=cache,target=/root/.cache/pip \
apt-get update && \
apt-get install -y --no-install-recommends \
gcc \
libc6-dev \
libffi-dev \
&& \
pip install --no-cache-dir -r requirements.txt && \
apt-get purge -y --auto-remove \
gcc \
libc6-dev \
libffi-dev \
&& \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
COPY . .
FROM python:3.12-slim
COPY --from=builder /app /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
WORKDIR /app
CMD ["python", "app.py"]
Nota: aunque purgues gcc en la etapa builder, el espacio ocupado por las herramientas de compilación sigue estando en la capa del builder. Pero como la imagen final es una etapa distinta que solo copia lo necesario, no importa: las herramientas no viajan a la imagen final.
COPY --link: herencia de capas reutilizable
COPY --link (disponible desde sintaxis docker/dockerfile:1.4) cambia la forma en que Docker maneja la capa de COPY. En lugar de hacer que la nueva capa se base en la anterior, enlaza la capa copiada directamente a la imagen base, independientemente de las capas intermedias.
Sin --link (comportamiento tradicional)
FROM node:20-alpine # Capa A
WORKDIR /app # Capa B
COPY package.json . # Capa C (depende de B)
Si cambias WORKDIR, la capa C se invalida aunque package.json no haya cambiado. El contenido de package.json se tiene que volver a copiar y la capa se recalcula.
Con --link
FROM node:20-alpine # Capa A
WORKDIR /app # Capa B
COPY --link package.json . # Capa C (enlazada a A, no a B)
La capa C se enlaza directamente a la capa A (la imagen base). Si cambias WORKDIR, la capa C no se invalida porque no depende de B. Solo se recalcula B.
Beneficio real
# syntax=docker/dockerfile:1.4
FROM python:3.12-slim AS base
# COPY --link = esta capa se enlaza directamente a python:3.12-slim
COPY --link requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY --link . /app
WORKDIR /app
CMD ["python", "app.py"]
Si cambias WORKDIR o la instrucción RUN, la capa de COPY requirements.txt se mantiene en caché porque no depende de nada anterior. Esto acelera builds cuando modificar instrucciones tempranas del Dockerfile no debería invalidar las copias de archivos.
COPY --link con multi-stage
# syntax=docker/dockerfile:1.4
FROM golang:1.23-alpine AS builder
# ...
RUN go build -o /app/server
FROM scratch
COPY --link --from=builder /app/server /server
COPY --link --from=alpine:3.20 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]
Al usar --link aquí, la capa del binario y la capa de certificados se enlazan directamente a scratch. Si en el futuro cambias la imagen base a alpine:3.21, las capas de certificados y binario pueden reutilizarse si el contenido es idéntico.
COPY --parents (sintaxis 1.7+)
Desde la sintaxis 1.7, puedes conservar la estructura de directorios con --parents:
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS builder
WORKDIR /app
COPY --parents src/*.js package.json ./
# Crea: /app/src/*.js y /app/package.json
.dockerignore: el primer filtro de optimización
El archivo .dockerignore es tu primera línea de defensa contra imágenes hinchadas. Le dice a Docker qué archivos no enviar al daemon como contexto de build.
Sin .dockerignore
# El contexto incluye node_modules (300 MB), .git (200 MB), .env...
docker build -t mi-app .
# Enviando build context al Docker daemon... 567.8 MB
Con .dockerignore
Crea un archivo .dockerignore en la raíz del proyecto:
# Dependencias (se instalan dentro del contenedor)
node_modules/
vendor/
__pycache__/
*.pyc
*.pyo
.venv/
venv/
# Git
.git/
.gitignore
.gitattributes
.gitmodules
# CI/CD
.github/
.gitlab/
.circleci/
# Entorno y secretos
.env
.env.*
*.pem
*.key
secrets/
!secrets/example.env
# Logs y datos
*.log
*.sqlite
*.sqlite3
data/
uploads/
# Documentación (no necesaria en producción)
README.md
LICENSE
docs/
*.md
# IDE y editor
.idea/
.vscode/
*.swp
*.swo
*~
# Build
dist/
build/
*.tsbuildinfo
# OS
.DS_Store
Thumbs.db
Cómo verificar el contexto
# Ver qué se enviaría al daemon
docker build -f Dockerfile -t test --no-cache . 2>&1 | head -5
# O mejor: usar tar para inspeccionar el contexto
tar -czf - . | tar -tzf - | head -20
Reglas de .dockerignore
- Las reglas de exclusión funcionan como
.gitignore(glob patterns) !delante de un patrón lo incluye aunque su directorio padre esté excluido- Las exclusiones se aplican al contexto, no al contenedor
- Un
.dockerignorevacío envía todo; uno con solo*envía nada (útil conCOPY --linkde otras etapas)
Ejemplo avanzado
# Excluir todo por defecto
*
# Incluir solo lo necesario
!Dockerfile
!docker-compose.yml
!src/
!package.json
!package-lock.json
!tsconfig.json
!public/
!scripts/
Esto asegura que solo los archivos explícitamente incluidos viajen al daemon, minimizando el contexto de build.
--squash: comprimir capas
La opción --squash (experimental) comprime todas las capas de una imagen en una sola capa. Esto puede reducir el tamaño porque elimina los diffs sobrantes entre capas.
# Habilitar squash (experimental)
echo '{"experimental": true}' | sudo tee -a /etc/docker/daemon.json
sudo systemctl restart docker
# Construir con squash
docker build --squash -t mi-app .
Antes vs después
Sin squash:
Capa 1 (FROM): 188 MB
Capa 2 (apt): 30 MB
Capa 3 (pip): 45 MB
Capa 4 (COPY): 0.5 MB
Total: 263.5 MB
Con squash:
Capa 1 (FROM+apt+pip+COPY): 200 MB
Total: 200 MB
Ahorro: ~63 MB porque los archivos borrados entre capas ya no ocupan espacio.
Limitaciones de --squash
- Es experimental desde 2017 — puede tener bugs
- No funciona con BuildKit (
docker buildx build) - Pierdes el historial de capas: no puedes ver qué capa añadió qué
- Las capas comprimidas no se comparten entre imágenes
- Se pierde la ventaja del caching de capas (todo es una capa)
Alternativa: Squash manual con multi-stage
# Simular squash con multi-stage
# Stage 1: construir normalmente
FROM python:3.12-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends ... && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Stage 2: exportar como tar y reimportar
FROM scratch AS squash
COPY --from=build / /
Esto produce el mismo efecto que --squash pero es compatible con BuildKit y funciona en Docker buildx.
Herramientas de análisis de imágenes
No basta con aplicar las técnicas: tienes que medir el resultado. Aquí es donde entran las herramientas de análisis.
Dive
Dive es una herramienta interactiva para explorar las capas de una imagen Docker. Te muestra el contenido de cada capa, el espacio que ocupa cada archivo, el espacio que se ahorraría si optimizaras ciertos archivos, y comparaciones entre versiones de una imagen.
# Instalar Dive
# Linux
wget https://github.com/wagoodman/dive/releases/download/v0.12.0/dive_0.12.0_linux_amd64.deb
sudo apt install ./dive_0.12.0_linux_amd64.deb
# Con brew (macOS/Linux)
brew install dive
# Usar Dive
dive mi-app:latest
Dive abre una interfaz TUI dividida en dos paneles: panel izquierdo con lista de capas con tamaño y % de eficiencia, y panel derecho con sistema de archivos de la capa seleccionada, con espacio ocupado.
Métrica clave: el % de eficiencia de Dive indica cuánto espacio se desperdicia entre capas. Un 98%+ es excelente. Un 70% indica que hay mucho contenido borrado entre capas (candidato a squash o multi-stage).
# Análisis no interactivo
dive mi-app:latest --ci
# Salida JSON
dive mi-app:latest --json > dive-report.json
hadolint
hadolint es un linter para Dockerfiles. Analiza tu Dockerfile y te dice qué estás haciendo mal según las mejores prácticas.
# Instalar
# Linux
wget https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64
chmod +x hadolint-Linux-x86_64
sudo mv hadolint-Linux-x86_64 /usr/local/bin/hadolint
# Con brew
brew install hadolint
# Usar
hadolint Dockerfile
# Análisis de un Dockerfile
hadolint Dockerfile.dev
Ejemplo de salida:
Dockerfile:3 DL3008 error: Pin versions in apt-get install. Instead of `apt-get install -y python3` use `apt-get install -y python3=3.11.*`
Dockerfile:5 DL3009 info: Delete the apt-get lists after installing something
Dockerfile:8 DL3045 warning: Use COPY instead of ADD for files
Dockerfile:12 DL3013 warning: Pin versions in pip install
Reglas más importantes de hadolint:
| Código | Severidad | Descripción |
|---|---|---|
DL3008 | error | No pinneas versiones en apt-get install |
DL3009 | info | No limpias las listas de apt |
DL3013 | warning | No pinneas versiones en pip install |
DL3018 | error | No usas --no-cache en apk add |
DL3020 | error | Usas COPY en lugar de ADD para archivos remotos |
DL3045 | warning | Usas ADD donde COPY es suficiente |
DL3059 | info | Múltiples RUN consecutivos que se pueden combinar |
DL4001 | warning | Usas sudo sin necesidad |
# Ignorar reglas específicas
hadolint --ignore DL3008 --ignore DL3013 Dockerfile
# Usar configuración personalizada: .hadolint.yaml
cat > .hadolint.yaml << 'EOF'
ignored:
- DL3008 # No pinning versions (entorno controlado)
- DL3013 # No pinning pip versions
trustedRegistries:
- docker.io
- gcr.io
EOF
hadolint Dockerfile
docker-slim
docker-slim reduce automáticamente el tamaño de imágenes Docker analizando qué necesita realmente tu aplicación para ejecutarse.
# Instalar
curl -sL https://raw.githubusercontent.com/slimtoolkit/slim/master/scripts/install-dockerslim.sh | sudo bash -s -
# Usar: crea una versión mini de tu imagen (~50-95% más pequeña)
docker-slim build mi-app:latest
# Resultado: mi-app.slim:latest
# A menudo reduce de 800 MB a ~30 MB
docker-slim build --http-probe mi-app:latest
# Con docker-compose
docker-slim build --compose-file docker-compose.yml --target-service api
¿Cómo funciona docker-slim?
- Ejecuta tu imagen en un contenedor monitorizado
- Analiza qué archivos, librerías, dispositivos y syscalls usa realmente
- Crea una nueva imagen con solo lo necesario
- Añade instrumentación mínima para compatibilidad
Ejemplo práctico:
# Imagen original
docker images python-app
# python-app latest abc123 5 days ago 876 MB
# Slim
docker-slim build --http-probe python-app:latest
# python-app.slim latest def456 10s ago 32 MB
# Verificación
docker run --rm -p 8000:8000 python-app.slim:latest
# Funciona igual
Limitaciones de docker-slim:
- No funciona bien con aplicaciones que cargan librerías dinámicamente (import dinámico,
dlopen) - A veces rompe aplicaciones que dependen de archivos que no se tocan en el arranque
- El análisis puede ser lento (tarda 30-60s en analizar)
- No recomendado para producción sin pruebas exhaustivas
Caché remota: --cache-from y --cache-to
Cuando construyes imágenes en CI/CD, la caché local no persiste entre builds porque cada job empieza en un entorno limpio. Para solucionarlo, BuildKit permite exportar la caché a un registry o almacenamiento remoto, e importarla en builds posteriores.
Caché inline (en la misma imagen)
La opción más simple: la caché se incrusta dentro de la propia imagen del registry.
# Construir exportando caché inline
docker buildx build \
--cache-from=type=registry,ref=registry.example.com/mi-app:cache \
--cache-to=type=inline \
-t registry.example.com/mi-app:latest \
--push \
.
Ventaja: no necesitas un tag separado para la caché. Desventaja: la imagen pesa más porque incluye metadatos de caché.
Caché separada en registry
# Construir con caché separada
docker buildx build \
--cache-from=type=registry,ref=registry.example.com/mi-app:cache \
--cache-to=type=registry,ref=registry.example.com/mi-app:cache,mode=max \
-t registry.example.com/mi-app:latest \
--push \
.
Parámetros del cache-to type=registry:
| Parámetro | Descripción |
|---|---|
mode | min (solo capas runtime, defecto) o max (todas las capas, build más rápido) |
ref | Tag de la imagen que almacenará la caché |
image-manifest | true (defecto) para usar el manifiesto de imagen; false para usar manifiesto separado |
oci-mediatypes | true/false — usar tipos de medios OCI |
Caché con S3/GCS
Para equipos grandes, la caché en S3 (o compatible, como MinIO) es más eficiente:
# Cache en S3
docker buildx build \
--cache-from=type=s3,bucket=mi-bucket,name=mi-app-cache,region=us-east-1 \
--cache-to=type=s3,bucket=mi-bucket,name=mi-app-cache,region=us-east-1,mode=max \
-t registry.example.com/mi-app:latest \
--push \
.
Parámetros S3/GCS:
| Parámetro | type=s3 | type=gcs |
|---|---|---|
bucket | Requerido | Requerido |
name | Nombre de la caché | Nombre de la caché |
region | Región AWS | — |
blobs_prefix | Prefijo para objetos blob | Prefijo para objetos blob |
prefix | Prefijo general | Prefijo general |
Caché local (para entornos con NFS compartido)
# Exportar e importar caché local
# Útil cuando varios builders comparten un volumen NFS
docker buildx build \
--cache-from=type=local,src=/mnt/shared/cache \
--cache-to=type=local,dest=/mnt/shared/cache,mode=max \
-t mi-app:latest \
.
Estrategia recomendada para CI/CD
# .github/workflows/build.yml (GitHub Actions)
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: registry.example.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: registry.example.com/mi-app:${{ github.sha }},registry.example.com/mi-app:latest
cache-from: type=registry,ref=registry.example.com/mi-app:cache
cache-to: type=registry,ref=registry.example.com/mi-app:cache,mode=max
Ejemplo completo: de 800 MB a 18 MB
Vamos a aplicar todas las técnicas a un ejemplo real: una API REST en Node.js con Express. Verás cómo pasamos de una imagen de 823 MB a solo 18 MB aplicando las técnicas de forma progresiva.
Fase 1: Imagen sin optimizar (823 MB)
# Dockerfile — SIN optimizar
FROM node:20
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
docker build -t api-node-bloated .
docker images api-node-bloated
# 823 MB
Fase 2: Alpine + orden de capas (278 MB)
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
docker build -t api-node-alpine .
# 278 MB
Fase 3: Multi-stage + prune (118 MB)
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
docker build -t api-node-multistage .
# 118 MB
Fase 4: Distroless + cache mounts (78 MB)
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
COPY . .
FROM gcr.io/distroless/nodejs20-debian12
COPY --from=builder /app /app
WORKDIR /app
EXPOSE 3000
CMD ["index.js"]
docker build -t api-node-distroless .
# 78 MB
Fase 5: docker-slim (18 MB)
docker-slim build --http-probe api-node-distroless:latest
# Resultado
docker images
# api-node-distroless.slim 18.2 MB
Progresión completa:
| Fase | Tamaño | Mejora | Técnica aplicada |
|---|---|---|---|
| Sin optimizar | 823 MB | — | — |
| Alpine | 278 MB | 66 % | Imagen base Alpine |
| Multi-stage | 118 MB | 86 % | Separar build de runtime |
| Distroless | 78 MB | 91 % | Sin shell ni paquetes extra |
| docker-slim | 18 MB | 98 % | Análisis de uso real |
De 823 MB a 18 MB con las mismas funcionalidades. Esto no es más que la punta del iceberg: las mismas técnicas aplicadas a cualquier stack te darán resultados similares.
Lista de verificación: imagen optimizada
Antes de dar por terminada una imagen, repasa esta checklist de optimización:
| # | Verificación | Herramienta | Cómo |
|---|---|---|---|
| 1 | Imagen base mínima | docker images | Usa slim, alpine, distroless o scratch |
| 2 | Multi-stage build | Revisión manual | Separa build de runtime |
| 3 | Capas optimizadas | docker history | Combina RUN con && |
| 4 | Sin paquetes innecesarios | Dive | --no-install-recommends + purge |
| 5 | Sin listas de apt | Dive | rm -rf /var/lib/apt/lists/* |
| 6 | Sin archivos temporales | Dive | rm -rf /tmp/* /var/tmp/* |
| 7 | Sin secretos en capas | Dive + docker history | Usa --mount=type=secret |
| 8 | .dockerignore presente | ls -la | Excluye node_modules, .git, .env |
| 9 | Orden de capas correcto | Revisión manual | Inmutable → dependencias → código |
| 10 | Versionado pinned | hadolint | python3=3.12.* no python3 |
| 11 | Cache mounts activos | Revisión manual | --mount=type=cache para pip/npm/go |
| 12 | COPY –link usado | Revisión manual | Mejora reutilización de caché |
| 13 | Usuario no root | docker inspect | USER appuser |
| 14 | HEALTHCHECK definido | docker inspect | HEALTHCHECK CMD curl ... |
| 15 | Etiquetas OCI | docker inspect | LABEL org.opencontainers.image.* |
| 16 | hadolint pasa | hadolint Dockerfile | Sin errores críticos |
| 17 | Dive eficiencia > 95% | dive --ci | Sin desperdicio entre capas |
| 18 | docker-slim opcional | docker-slim build | Reduce tamaño un 90%+ |
Verificación
Para asegurarte de que todo lo que has aprendido funciona, aquí tienes una serie de comandos que puedes ejecutar para verificar que tus imágenes están optimizadas correctamente.
Verificar tamaño de imágenes
# Listar imágenes con sus tamaños
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
# Ver el historial de capas de una imagen
docker history mi-app:latest
# Ver el tamaño detallado de cada capa
docker history --no-trunc mi-app:latest
Verificar contexto de build
# Ver qué archivos se enviarán al daemon de Docker
tar -czf - . | tar -tzf - | sort
# Construir en seco para ver el contexto
docker build -t test-context --no-cache . 2>&1 | head -10
Analizar con Dive
# Análisis interactivo de capas
dive mi-app:latest
# Análisis CI (no interactivo) — muestra eficiencia
dive mi-app:latest --ci
# Busca un % de eficiencia > 95%
Pasar hadolint al Dockerfile
# Linter de Dockerfile
hadolint Dockerfile
# Verificar que no hay errores críticos
# DL3008, DL3009, DL3018 son los más importantes
Verificar que los secretos no están en capas
# Inspeccionar el historial en busca de secretos
docker history --no-trunc mi-app:latest | grep -i "secret\|password\|token\|key"
# Si ves algo, cambia a --mount=type=secret
Probar la imagen optimizada
# Ejecutar el contenedor y comprobar que funciona
docker run --rm -d -p 8080:8080 --name test-optimized mi-app:latest
curl http://localhost:8080
# Verificar que no hay shell (en distroless/scratch)
docker exec test-optimized sh
# Debería fallar: "sh: not found" o similar
# Limpiar
docker stop test-optimized
Más información,
- Documentación oficial de Docker — Best practices for Dockerfile
- Documentación oficial de Docker — Multi-stage builds
- Documentación oficial de Docker — BuildKit cache mounts
- Documentación oficial de Docker — Dockerfile reference
- Documentación oficial de Docker — docker build command
- Documentación oficial de Docker — BuildKit
- hadolint — Linter de Dockerfiles (GitHub)
- SlimToolkit — Optimización automática de imágenes (GitHub)
- Distroless — Imágenes base de Google (GitHub)