Tu imagen de contenedor pesa 1,2 GB y podría pesar 80

Leonardo Gurgitano5 minLeer en inglés

Volúmenes de hormigón apilados unos sobre otros, vistos desde abajo.
Volúmenes de hormigón apilados unos sobre otros, vistos desde abajo.

Una imagen de contenedor grande no es solo un número feo. Es tiempo de descarga en cada despliegue, en cada nodo nuevo del clúster y en cada arranque en frío. Y es superficie de ataque: cada programa que incluís y no usás es algo que alguien puede aprovechar si logra ejecutar código adentro.

La mayoría de las imágenes de más de un giga llegaron ahí por cuatro decisiones concretas. Vamos una por una, con lo que ahorra cada cambio.

El punto de partida

Un Dockerfile típico de Node, del que se ven a diario:

FROM node:22

WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

CMD ["node", "dist/server.js"]

Funciona. Y produce una imagen de alrededor de 1,1 GB. Tiene cuatro problemas, y ninguno es evidente al leerla.

1. La imagen final incluye todo lo necesario para compilar

node:22 trae el sistema operativo completo, el compilador de C, Python, Git y el gestor de paquetes. Todo eso hace falta para construir, y nada para ejecutar.

La solución es una compilación por etapas: una etapa construye, otra ejecuta, y solo se copia lo que hace falta.

# ── Etapa 1: construir ──────────────────────────────────
FROM node:22 AS construccion
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

# ── Etapa 2: ejecutar ───────────────────────────────────
FROM node:22-slim
WORKDIR /app
COPY --from=construccion /app/node_modules ./node_modules
COPY --from=construccion /app/dist ./dist
CMD ["node", "dist/server.js"]

Todo lo que no copiás explícitamente de una etapa a la otra se descarta. El compilador, los archivos de origen, la caché de npm y las dependencias de desarrollo no llegan a la imagen final.

De 1,1 GB a unos 240 MB.

2. El orden de las líneas define cuánto tarda cada compilación

Fijate que en el ejemplo de arriba COPY package*.json va antes que COPY . .. No es cosmético.

Docker guarda en caché cada instrucción, y cuando una cambia, invalida esa y todas las siguientes. En la versión original, COPY . . está antes de npm install: cualquier cambio en cualquier archivo del proyecto —una coma en un comentario— invalida la copia, y con ella la instalación de dependencias. Cada compilación reinstala todo desde cero.

Copiando primero solo package.json y package-lock.json, la instalación se reutiliza mientras no cambien las dependencias. Que es casi siempre.

En un proyecto mediano eso es la diferencia entre una compilación de dos minutos y una de diez segundos.

El mismo principio en otros lenguajes: COPY go.mod go.sum antes del código en Go, COPY *.csproj antes del resto en .NET, COPY requirements.txt en Python.

3. La imagen base trae un sistema operativo que no usás

node:22-slim sigue incluyendo un intérprete de comandos, un gestor de paquetes y decenas de utilidades. Una imagen sin distribución (distroless) trae solo el intérprete del lenguaje y sus bibliotecas: sin bash, sin apt, sin curl.

FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=construccion /app/node_modules ./node_modules
COPY --from=construccion /app/dist ./dist
CMD ["dist/server.js"]

De 240 MB a unos 130 MB. Y algo más importante que el tamaño: si alguien consigue ejecutar código dentro del contenedor, no hay intérprete de comandos para que lo use. Muchas cadenas de ataque dependen de poder ejecutar sh o descargar una herramienta con curl. Acá ninguna de las dos existe.

Para comparar magnitudes de bases mínimas: Alpine ronda los 26 MB, las imágenes de Chainguard unos 20 MB, y scratch —literalmente nada— 12,6 MB.

El costo real: no podés entrar al contenedor a mirar. docker exec ... sh no funciona porque no hay sh. Eso obliga a depender de los registros y las métricas, que es lo correcto en producción, pero cambia cómo se depura.

4. Ejecutás como administrador sin necesitarlo

Por omisión, el proceso dentro del contenedor corre como root. Si alguien escapa del proceso, arranca con el máximo de privilegios.

# Las imágenes distroless traen un usuario sin privilegios listo
USER nonroot

En una imagen normal, se crea:

RUN useradd --system --uid 10001 aplicacion
USER 10001

Usá el número, no el nombre. Kubernetes puede verificar runAsNonRoot: true mirando el identificador numérico; con un nombre tiene que resolverlo dentro de la imagen, y hay configuraciones donde eso falla.

Esto no reduce el tamaño. Es el cambio de una línea con mejor relación entre esfuerzo y beneficio de toda la lista.

El resultado

# syntax=docker/dockerfile:1

FROM node:22 AS construccion
WORKDIR /app
# Primero las dependencias: la caché se reutiliza mientras no cambien
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=construccion --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=construccion --chown=nonroot:nonroot /app/dist ./dist
USER nonroot
CMD ["dist/server.js"]
Tamaño Compilación con caché
Original 1,1 GB ~2 min
Por etapas 240 MB ~10 s
Sin distribución 130 MB ~10 s
Compilado a binario nativo 20-80 MB ~10 s

La última fila es para lenguajes que compilan a un ejecutable único —Go, Rust, o .NET con Native AOT—, donde la imagen final puede ser scratch con un solo archivo adentro.

Cómo saber qué está ocupando el lugar

Antes de optimizar sin saber dónde está el peso, mirá las capas:

docker history --no-trunc --format "{{.Size}}\t{{.CreatedBy}}" mi-imagen:latest

Ordenado por tamaño, casi siempre aparece lo mismo: la caché del gestor de paquetes, dependencias de desarrollo que llegaron a producción, o archivos de origen que quedaron copiados.

Y un .dockerignore bien hecho evita el problema en el origen:

node_modules
.git
dist
*.log
.env*
coverage

Sin él, COPY . . copia tu .git completo con todo el historial, y tu node_modules local —que además puede tener binarios compilados para otro sistema operativo que romperán la imagen de forma confusa.

Lo que yo haría primero

Si solo vas a hacer un cambio: separá en dos etapas y poné el archivo de dependencias antes que el código. Ese único cambio suele quitar el 80 % del peso y casi todo el tiempo de compilación.

Lo demás es mejora incremental. Eso es el salto.

Comentarios