17

Optimización de imágenes Docker

Vistas: 0
Tutorial: Tutorial de Docker
Optimización de imágenes Docker

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 from a 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:

  1. Tamaño reducido: solo lo necesario en la imagen final
  2. Seguridad: herramientas de compilación (compiladores, gestores de paquetes, SDKs) no viajan a producción
  3. Separación de responsabilidades: cada etapa hace una cosa
  4. Caché eficiente: si solo cambia el frontend, la etapa de backend se cachea
  5. 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áticamenteScratch es mejor opción
Necesitas shell en producción (debugging)Distroless es más seguro
Espacio en disco es críticoEl 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:

  1. Requiere instalar gcc, musl-dev, python3-dev y otras herramientas de compilación
  2. Ralentiza el build significativamente
  3. 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 necesarias
  • apt-get clean — Borra archivos .deb descargados en /var/cache/apt/archives/
  • apt-get autoclean — Borra solo los .deb de paquetes que ya no están disponibles
  • rm -rf /var/lib/apt/lists/* — Borra las listas de paquetes descargadas por apt-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

  1. Las reglas de exclusión funcionan como .gitignore (glob patterns)
  2. ! delante de un patrón lo incluye aunque su directorio padre esté excluido
  3. Las exclusiones se aplican al contexto, no al contenedor
  4. Un .dockerignore vacío envía todo; uno con solo * envía nada (útil con COPY --link de 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ódigoSeveridadDescripción
DL3008errorNo pinneas versiones en apt-get install
DL3009infoNo limpias las listas de apt
DL3013warningNo pinneas versiones en pip install
DL3018errorNo usas --no-cache en apk add
DL3020errorUsas COPY en lugar de ADD para archivos remotos
DL3045warningUsas ADD donde COPY es suficiente
DL3059infoMúltiples RUN consecutivos que se pueden combinar
DL4001warningUsas 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?

  1. Ejecuta tu imagen en un contenedor monitorizado
  2. Analiza qué archivos, librerías, dispositivos y syscalls usa realmente
  3. Crea una nueva imagen con solo lo necesario
  4. 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ámetroDescripción
modemin (solo capas runtime, defecto) o max (todas las capas, build más rápido)
refTag de la imagen que almacenará la caché
image-manifesttrue (defecto) para usar el manifiesto de imagen; false para usar manifiesto separado
oci-mediatypestrue/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ámetrotype=s3type=gcs
bucketRequeridoRequerido
nameNombre de la cachéNombre de la caché
regionRegión AWS—
blobs_prefixPrefijo para objetos blobPrefijo para objetos blob
prefixPrefijo generalPrefijo 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:

FaseTamañoMejoraTécnica aplicada
Sin optimizar823 MB——
Alpine278 MB66 %Imagen base Alpine
Multi-stage118 MB86 %Separar build de runtime
Distroless78 MB91 %Sin shell ni paquetes extra
docker-slim18 MB98 %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ónHerramientaCómo
1Imagen base mínimadocker imagesUsa slim, alpine, distroless o scratch
2Multi-stage buildRevisión manualSepara build de runtime
3Capas optimizadasdocker historyCombina RUN con &&
4Sin paquetes innecesariosDive--no-install-recommends + purge
5Sin listas de aptDiverm -rf /var/lib/apt/lists/*
6Sin archivos temporalesDiverm -rf /tmp/* /var/tmp/*
7Sin secretos en capasDive + docker historyUsa --mount=type=secret
8.dockerignore presentels -laExcluye node_modules, .git, .env
9Orden de capas correctoRevisión manualInmutable → dependencias → código
10Versionado pinnedhadolintpython3=3.12.* no python3
11Cache mounts activosRevisión manual--mount=type=cache para pip/npm/go
12COPY –link usadoRevisión manualMejora reutilización de caché
13Usuario no rootdocker inspectUSER appuser
14HEALTHCHECK definidodocker inspectHEALTHCHECK CMD curl ...
15Etiquetas OCIdocker inspectLABEL org.opencontainers.image.*
16hadolint pasahadolint DockerfileSin errores críticos
17Dive eficiencia > 95%dive --ciSin desperdicio entre capas
18docker-slim opcionaldocker-slim buildReduce 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,

Deja una respuesta