Por qué una imagen Docker pesa diez veces más que la app

Un servicio Node con unos pocos cientos de kilobytes de código fuente termina en una imagen de más de 1 GB. Es el resultado normal de una build que funciona, no un error en una línea concreta. El peso viene de una imagen base que arrastra un sistema operativo completo, una toolchain de build que la app en ejecución nunca llama, y una caché del gestor de paquetes que nadie limpia.

Lo pagas en cada deploy: docker pull se vuelve más lento, y un escáner de vulnerabilidades tiene varios cientos de paquetes extra sobre los que informar. El almacenamiento también se acumula, en cada tag que hayas publicado alguna vez. Arreglarlo significa reescribir el Dockerfile, no la app.

Qué imagen base elegir: alpine, slim o distroless

La línea FROM fija el punto de partida. Elegirla mal ahí y nada más en el archivo compensa la diferencia.

Imagen baseTamaño aproximadoQué contiene
node:261,77 GBDebian completa, compiladores, varios runtimes de lenguajes, documentación
node:26-slim371 MBDebian recortada, sin toolchain de build
node:26-alpine247 MBmusl libc, apk, unos 50 paquetes
gcr.io/distroless/nodejs22-debian12212 MBSolo runtime de Node, sin shell, sin gestor de paquetes, unos 10 paquetes

El patrón se repite fuera de Node. debian:bookworm-slim ronda los 74 MB frente a los 77 MB de ubuntu:22.04. alpine:latest está cerca de los 7 MB. gcr.io/distroless/static-debian12, pensada para un binario Go o Rust compilado estáticamente que no necesita nada más que los certificados CA, roza los 2 MB.

Alpine consigue su tamaño cambiando glibc por musl libc, y ese cambio es la factura: los módulos nativos compilados contra glibc (algunos paquetes de npm, la mayoría de los paquetes de pip con extensiones en C) fallan con segfault o lanzan errores de símbolo no encontrado bajo musl. Un tag -slim evita el riesgo y aun así recorta la imagen en dos tercios. Pasa a Alpine solo después de confirmar que tus dependencias no se ven afectadas.

Distroless va más allá. Sin shell, sin gestor de paquetes, nada que un atacante pueda ejecutar tras conseguir ejecución de código, y tampoco sh para ti, lo que se convierte en un coste real la primera vez que un contenedor se comporta mal en producción.

El orden de las capas, .dockerignore y los mecanismos de una build multi-stage se tratan en Buenas Prácticas de Dockerfile: Builds Rápidas y Ligeras. Aquí se parte de que esa separación ya existe y se centra en lo que un stage de build por sí solo no arregla.

Cómo mantener las cachés del gestor de paquetes fuera de la imagen

Una build multi-stage mantiene el compilador fuera del stage de runtime. No hace nada por la caché propia del gestor de paquetes, que aterriza en la capa donde corrió la instalación: inofensiva en un stage de build que descartas, peso muerto en el stage final si ese stage también instala algo.

Los cache mounts de BuildKit lo resuelven sin ningún paso de limpieza:

1
2
3
4
5
6
7
8
9
# syntax=docker/dockerfile:1
FROM node:26-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .
RUN npm run build

--mount=type=cache le da a ese RUN un directorio que persiste entre builds y que nunca se confirma en una capa. La caché de npm sigue acelerando la siguiente build; nada de eso llega a la imagen. Python usa RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt, Go --mount=type=cache,target=/root/.cache/go-build.

Sin BuildKit, dile al instalador que no haga caché en absoluto: pip install --no-cache-dir, o npm ci && npm cache clean --force. Las dos mitades van en el mismo RUN. Borrar un archivo en una capa posterior lo esconde detrás de un whiteout marker y deja los bytes en la capa de abajo, así que la versión dividida en dos RUN termina enviando la caché de todos modos. Un servicio construido por Docker Compose con build: . ejecuta el mismo Dockerfile con el mismo builder, así que los cache mounts aplican igual.

Cómo eliminar símbolos de depuración y archivos que la app nunca lee

Las dependencias de build son solo la mitad del problema. Las dependencias de runtime traen archivos que en producción nunca se leen: suites de test, documentación .md, cachés .pyc compiladas, source maps.

Para Python, salta la caché de pip y la caché de bytecode en la capa que instala:

1
2
3
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-compile -r requirements.txt \
    && find /usr/local/lib -name '__pycache__' -exec rm -rf {} +

Un binario compilado lleva información de depuración que producción nunca lee. Un flag en el stage de build la elimina:

1
2
# Go: elimina la tabla de símbolos y la información de depuración DWARF en tiempo de build
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server
1
2
# C/C++/Rust: elimina los símbolos de un binario ya compilado
RUN strip /app/binary

-ldflags="-s -w" quita normalmente entre un 20 y un 30% de un binario Go. Nada que notes en una CLI pequeña, bytes reales en cualquier cosa con un grafo de dependencias grande. Los símbolos eliminados solo cuestan algo si conectas un depurador exactamente al binario que corre en producción, algo que no haces en un contenedor sin shell a la que engancharte.

Cómo averiguar qué capa está agrandando la imagen

Docker guardó dónde fueron los bytes. Solo tienes que leerlo:

1
docker history myapp:latest

Una línea por capa, con la instrucción que la creó y su tamaño. Añade --no-trunc cuando varias líneas RUN se parecen en la vista truncada.

Eso señala la instrucción costosa, no los archivos costosos dentro de ella. dive hace la segunda mitad:

1
dive myapp:latest

Abre una interfaz de terminal sobre las capas, marca en color los archivos añadidos, modificados y eliminados, y reporta una puntuación de eficiencia junto con el total de bytes desperdiciados: espacio ocupado por archivos que una capa posterior sobrescribió o borró pero que nunca salieron de la imagen. CI=true dive myapp:latest ejecuta el mismo chequeo de forma no interactiva y termina con código distinto de cero cuando la imagen no cumple los umbrales de un archivo .dive-ci:

1
2
3
4
rules:
  lowestEfficiency: 0.95
  highestWastedBytes: 20MB
  highestUserWastedPercent: 0.10

Una imagen que creció en silencio deja de ser algo que un compañero nota semanas después y pasa a ser un check que falla en el pull request que lo causó.

Cuándo no vale la pena reducir la imagen

Un script que construyes una vez y ejecutas en tu propia máquina no necesita una base distroless ni un binario sin símbolos. La imagen nunca sale de tu disco, y el trabajo cuesta más que el gigabyte que ahorra. Lo mismo para la imagen de un job de CI de vida corta, reconstruida desde cero en cada ejecución: quitar 200 MB de algo que se descarga una vez y se descarta no aporta nada.

El compromiso pesa más en el extremo pequeño. Una imagen distroless o scratch no tiene shell, así que docker exec -it myapp sh en un contenedor que se comporta mal falla directamente. Depuras desde los logs y una reproducción local, o mantienes una variante de depuración justo para eso (distroless publica tags -debug con una shell busybox). Alpine renuncia a menos comodidad y a cambio te da el riesgo de musl. Ninguno de estos costes está oculto, y por eso “usa siempre la imagen base más pequeña” es peor consejo que elegir la base servicio por servicio, según lo que ese servicio realmente necesita.

Por dónde empezar en una imagen que ya tienes

Ejecuta docker history sobre lo que despliegas hoy, antes de cambiar nada. La capa más grande suele ser una corrección obvia en cuanto tiene un nombre al lado. Después ejecuta dive sobre la misma imagen con los umbrales de arriba: una ejecución que falla en el primer intento te da un número que superar y un informe a nivel de archivo de dónde está el desperdicio, que es mejor que adivinar en el Dockerfile a partir de lo que suele inflar las imágenes.

Artículos relacionados