Reduce el Tamaño de las Imágenes Docker: Una Guía Práctica para Compilaciones Más Rápidas

Haz las imágenes Docker más pequeñas y las compilaciones más rápidas con compilaciones multi-etapa, Dockerfiles amigables con la caché y contextos de compilación limpios.

Reduce el Tamaño de las Imágenes Docker: Una Guía Práctica para Compilaciones Más Rápidas

Reducir el tamaño de las imágenes Docker no se trata de perseguir el número más pequeño en docker image ls. El objetivo útil es una compilación que sea rápida, repetible, fácil de parchear y lo suficientemente pequeña como para no ralentizar CI, despliegues o el desarrollo local.

Una imagen inflada generalmente proviene de algunos hábitos comunes: copiar todo el repositorio demasiado pronto, instalar herramientas de compilación en la imagen de ejecución, dejar atrás las cachés del gestor de paquetes, usar etiquetas latest y enviar un contexto de compilación enorme a Docker. Corrige esos hábitos y la mayoría de los equipos verán compilaciones más limpias sin hacer nada exótico.

Comienza midiendo la imagen actual:

docker image ls my-app
docker history --no-trunc my-app:latest

docker history muestra qué instrucciones del Dockerfile agregaron tamaño. Si un paso RUN agrega una gran cantidad, inspecciona ese paso antes de cambiar todo lo demás. Para una mirada más profunda, herramientas como dive pueden mostrar los archivos agregados y eliminados en cada capa, pero los comandos incorporados son suficientes para una primera pasada.

Elige una imagen base primero por compatibilidad, después por tamaño

La elección de la imagen base importa, pero la imagen más pequeña no siempre es la mejor. Alpine es pequeña porque usa musl libc y un espacio de usuario mínimo. Eso funciona bien para muchas imágenes de Go, Node, Python y utilidades, pero puede crear fricción con extensiones nativas, binarios precompilados, comportamiento de DNS o paquetes que esperan glibc.

Un orden práctico es:

  1. Usa una imagen oficial para tu tiempo de ejecución.
  2. Prefiere una etiqueta versionada como python:3.12-slim sobre python:latest.
  3. Prueba slim antes de saltar a Alpine si tu aplicación tiene dependencias nativas.
  4. Usa distroless o scratch cuando ya sepas exactamente lo que necesita el tiempo de ejecución.

Por ejemplo, un servicio Python con psycopg, bibliotecas de imágenes o paquetes de criptografía puede ser más fácil de mantener en python:3.12-slim que en python:3.12-alpine. La imagen Alpine puede ser más pequeña al principio, pero si agregas compiladores y paquetes de compatibilidad para hacer que las dependencias se compilen, el resultado final puede no ser más simple o mucho más pequeño.

Para servicios Go, scratch o distroless pueden ser excelentes porque un binario compilado estáticamente puede necesitar muy poco en tiempo de ejecución. Incluso entonces, recuerda los certificados TLS, los datos de zona horaria y los usuarios no root. Una imagen diminuta que no puede hacer solicitudes HTTPS no es una imagen de producción útil.

Usa compilaciones multi-etapa para mantener las herramientas de compilación fuera de producción

Las compilaciones multi-etapa son la forma más confiable de reducir el tamaño de la imagen Docker para aplicaciones compiladas y compilaciones de frontend. La etapa de compilación puede ser grande. Puede contener compiladores, gestores de paquetes, herramientas de prueba y archivos fuente. La etapa final debe contener solo lo que la aplicación necesita para ejecutarse.

Un ejemplo en Go:

FROM golang:1.22 AS builder
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

La parte importante no es la imagen base exacta. La parte importante es el límite: el código fuente, la caché de módulos y el compilador se quedan en builder; la imagen final obtiene el binario.

Una aplicación Node tiene una forma similar, pero necesitas separar las dependencias de desarrollo de las dependencias de producción:

FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build

FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

Para activos de frontend servidos por nginx, la imagen final no necesita Node en absoluto:

FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html

Ese patrón elimina node_modules, el árbol fuente y las herramientas de compilación de la imagen de ejecución.

Haz que la caché de Docker trabaje para ti

La invalidación de la caché de Docker es simple pero implacable. Si una instrucción cambia, Docker reconstruye esa capa y todas las capas posteriores. Si copias todo el proyecto antes de instalar las dependencias, cada edición de código puede forzar una reinstalación de dependencias.

Esto es lento:

FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]

Esto suele ser más rápido:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]

La capa de dependencias solo se reconstruye cuando requirements.txt cambia. Los cambios en el código fuente solo reconstruyen la capa posterior COPY . ..

La misma regla se aplica a Maven, Gradle, npm, pnpm, Cargo y módulos Go. Copia primero los manifiestos de dependencias, instala o descarga las dependencias, luego copia el resto del código fuente.

Mantén el contexto de compilación pequeño con .dockerignore

Docker envía el contexto de compilación al demonio antes de comenzar a compilar. Si tu contexto incluye .git, node_modules local, informes de cobertura, videos, capturas de pantalla, bases de datos de prueba y artefactos de compilación, tu compilación es más lenta incluso antes de que se ejecute el Dockerfile.

Un .dockerignore básico podría verse así:

.git
.gitignore
.env
.env.*
node_modules
coverage
dist
build
target
.pytest_cache
__pycache__
*.log
.DS_Store

Ten cuidado con las exclusiones amplias. Si ignoras dist pero tu Dockerfile espera copiar un dist precompilado, la compilación fallará. Si ignoras .env, eso suele ser bueno para los secretos, pero tu compilación no debería depender de él. La compilación debe recibir los valores requeridos a través de argumentos de compilación explícitos o configuración de CI.

Puedes ver el tamaño del contexto al inicio de la salida clásica de compilación de Docker, y la salida de BuildKit también hace visibles las transferencias de contexto grandes. Si el primer paso lleva mucho tiempo antes de que se ejecute cualquier instrucción del Dockerfile, verifica .dockerignore.

Limpia las cachés del gestor de paquetes en la misma capa

Un error frecuente es instalar paquetes en una capa y eliminar las cachés en una capa posterior. Los archivos desaparecen de la vista del sistema de archivos final, pero aún pueden existir en el historial de la imagen.

Para imágenes Debian y Ubuntu:

RUN apt-get update &&     apt-get install -y --no-install-recommends ca-certificates curl &&     rm -rf /var/lib/apt/lists/*

Para Alpine:

RUN apk add --no-cache ca-certificates curl

Para Python:

RUN pip install --no-cache-dir -r requirements.txt

Para npm:

RUN npm ci --omit=dev && npm cache clean --force

No sigas comandos de limpieza sin pensar. Usa la limpieza que coincida con el gestor de paquetes y la imagen base. También evita apt-get upgrade en la mayoría de los Dockerfiles de aplicaciones. Hace que las compilaciones sean menos predecibles y puede arrastrar más cambios de los que pretendías. Parchea reconstruyendo regularmente a partir de imágenes base mantenidas y fijando la familia de imágenes que esperas.

Prefiere COPY a menos que necesites ADD

COPY copia archivos del contexto de compilación. ADD tiene un comportamiento adicional: puede desempaquetar archivos tar locales y obtener URL remotas en algunos casos. Ese comportamiento adicional puede sorprender a los mantenedores y hacer que el comportamiento de la caché sea más difícil de razonar.

Usa COPY para archivos de aplicación normales:

COPY ./src ./src

Usa ADD solo cuando quieras intencionalmente su comportamiento de extracción de archivos. Para descargas remotas, prefiere curl o wget en un paso RUN donde puedas verificar sumas de verificación y fallar claramente.

Reduce lo que instalas, no solo lo que eliminas

La mejor limpieza de caché es no instalar archivos innecesarios en primer lugar. Usa --no-install-recommends con apt-get. Evita instalar editores, shells, gestores de paquetes y herramientas de depuración en imágenes de producción a menos que tengas una razón.

Dicho esto, no elimines todas las herramientas si dificulta las operaciones. Una imagen de producción puede ser mínima pero aún observable. Para algunos equipos, una imagen slim con un shell es una mejor compensación que una imagen distroless porque la depuración de guardia es más simple. Para otros equipos con un fuerte registro, trazado y contenedores de depuración efímeros, distroless es una buena opción.

La respuesta honesta es específica de la carga de trabajo. Las imágenes más pequeñas generalmente se extraen más rápido y exponen menos paquetes para parchear, pero la mantenibilidad aún importa.

Usa montajes de caché de BuildKit para descargas de dependencias

BuildKit puede mantener las cachés de paquetes fuera de la imagen final mientras las reutiliza entre compilaciones. Esto es diferente de dejar las cachés dentro de la imagen.

Para npm:

# syntax=docker/dockerfile:1.7
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .

Para apt, los montajes de caché pueden ayudar en entornos CI donde se realizan compilaciones repetidas en el mismo compilador, aunque muchos sistemas CI usan trabajadores nuevos a menos que configures una caché persistente. Las características de BuildKit son potentes, pero mantén el Dockerfile legible para el equipo que tiene que mantenerlo.

Verifica el contenido de la imagen final

Después de los cambios, demuestra lo que mejoró:

docker build -t my-app:optimized .
docker image ls my-app
docker history my-app:optimized

Luego ejecuta la imagen de la misma manera que la ejecuta producción. Una imagen más pequeña que falla porque carece de certificados, datos de configuración regional, archivos de zona horaria o un directorio escribible no es una mejora.

Las pruebas de humo útiles incluyen:

docker run --rm my-app:optimized --version
docker run --rm -p 8080:8080 my-app:optimized
curl -f http://localhost:8080/health

Si la aplicación escribe archivos, prueba esa ruta. Si llama a servicios HTTPS, prueba TLS. Si se ejecuta como no root, prueba los permisos.

Un orden de optimización práctico

Cuando reviso una imagen Docker de gran tamaño, uso este orden:

  1. Agrega o corrige .dockerignore.
  2. Reordena el Dockerfile para que la instalación de dependencias se pueda almacenar en caché.
  3. Introduce compilaciones multi-etapa.
  4. Cambia de imágenes base completas a slim, Alpine, distroless o scratch donde sea compatible.
  5. Elimina las cachés del gestor de paquetes y las dependencias de desarrollo en la misma capa en que se crean.
  6. Inspecciona con docker history o dive y repite solo donde la evidencia lo indique.

Este orden evita la astucia prematura. También mantiene el Dockerfile comprensible, lo que importa más que exprimir los últimos megabytes.