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

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
Iniciá sesión para comentar y dar "me gusta".
Todavía no hay comentarios. Sé la primera persona en escribir uno.