Pasé tres horas optimizando una imagen Docker para un cliente. La llevé de 1.58GB a 186MB. Le mandé el PR con una descripción impecable, métricas incluidas, todo prolijo. Me sentí un genio.
Dos días después me escribe el dev del equipo: "Ey, el hot reload no funciona desde que mergearon tu cambio."
Dos días. 48 horas de un equipo laburando sin hot reload, recargando el servidor a mano, probablemente odiándome en silencio y sin saber por qué.
No lo cuento para hacerme el humilde. Lo cuento porque el post original que inspiró esto — I Shrunk My Docker Image From 1.58GB to 186MB — termina justo donde empieza el problema real. La segunda mitad del título, "Then I Had to Explain What I Actually Broke", es la que nadie escribe. Y es la más importante.
Cómo optimizar imagen Docker tamaño: lo que funciona de verdad
Antes de llegar a lo que rompí, el camino feliz. Porque la optimización en sí es legítima y vale la pena entenderla.
El proyecto era una app Node.js/Express con TypeScript. Imagen base oficial, todo en un solo stage, node_modules incluido con devDependencies y todo. Classic.
# Dockerfile ORIGINAL — el que pesaba 1.58GB
FROM node:20
WORKDIR /app
# Copiamos todo sin filtrar nada
COPY package*.json ./
RUN npm install
COPY . .
# Build del TS
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
Este Dockerfile tiene todos los problemas clásicos: imagen base completa con compiladores, devDependencies instaladas y presentes en la imagen final, sin .dockerignore efectivo, sin separación de concerns entre build y runtime.
La solución fue multi-stage build con imagen Alpine:
# Dockerfile OPTIMIZADO — 186MB
# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
# Primero las dependencias para aprovechar cache de capas
COPY package*.json ./
RUN npm ci --include=dev
# Copiamos fuente y compilamos
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Stage 2: producción — solo lo que necesita correr
FROM node:20-alpine AS production
WORKDIR /app
# Solo dependencias de producción
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Solo el código compilado, no el fuente
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
Y el .dockerignore que importa tanto como el Dockerfile:
# .dockerignore — todo lo que NO debe entrar
node_modules
dist
.git
.gitignore
*.md
.env*
.dockerignore
Dockerfile*
npm-debug.log*
Resultado: 1.58GB → 186MB. Un 88% menos. Pull times en CI/CD cayeron de 4 minutos a 40 segundos. Legítimo.
Lo que rompí sin darme cuenta
Acá está el problema que nadie menciona en los tutoriales de optimización.
El proyecto usaba un solo Dockerfile para dev y producción. En desarrollo, levantaban el container con docker-compose y un volumen montado sobre /app, corriendo ts-node-dev para hot reload. En producción, corrían el stage final con el código compilado.
Cuando cambié el Dockerfile a multi-stage, el stage production quedó perfecto. Pero el docker-compose.dev.yml seguía apuntando al mismo Dockerfile sin especificar target:
# docker-compose.dev.yml — ANTES de mi cambio
services:
api:
build:
context: .
dockerfile: Dockerfile # Sin especificar target
volumes:
- ./src:/app/src # Hot reload via volumen
command: npm run dev # ts-node-dev
ports:
- "3000:3000"
Cuando Docker construye un Dockerfile multi-stage sin target, usa el último stage. El último stage era production. El stage production no tiene ts-node-dev instalado. No tiene el código fuente. Tiene solo el dist/ compilado del momento del build.
Entonces el volumen ./src:/app/src montaba los archivos fuente... pero no había nada que los escuchara. El proceso que corría era node dist/index.js sobre código estático. Los cambios en el source no hacían absolutamente nada.
Y lo peor: el container arrancaba sin errores. La app funcionaba. Todo parecía bien. Solo que los cambios en el código no se reflejaban hasta que alguien reconstruía la imagen manualmente.
Dos días de eso.
# docker-compose.dev.yml — CORREGIDO
services:
api:
build:
context: .
dockerfile: Dockerfile
target: builder # Explícito: usá el stage con devDependencies
volumes:
- ./src:/app/src
- ./tsconfig.json:/app/tsconfig.json
command: npm run dev
ports:
- "3000:3000"
environment:
- NODE_ENV=development
Con target: builder especificado, el compose usa el stage que tiene todas las devDependencies incluyendo ts-node-dev, y el hot reload vuelve a funcionar.
Alternativamente — y esta es la solución que terminé implementando para que sea más explícita — separar los Dockerfiles:
# Dockerfile.dev — solo para desarrollo, sin ambigüedad
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci # Todas las dependencias, incluyendo dev
# El source lo monta el volumen de compose
# No copiamos nada más acá
EXPOSE 3000
CMD ["npm", "run", "dev"]
# docker-compose.dev.yml — usando Dockerfile.dev explícitamente
services:
api:
build:
context: .
dockerfile: Dockerfile.dev # Sin ambigüedad posible
volumes:
- ./src:/app/src
- ./tsconfig.json:/app/tsconfig.json
ports:
- "3000:3000"
Más archivos, cero confusión.
Los errores más comunes al optimizar imágenes Docker
Después de este episodio empecé a documentar los gotchas que no aparecen en los tutoriales.
1. Alpine y dependencias nativas
Alpine usa musl libc en lugar de glibc. Algunos paquetes Node con binarios nativos (bcrypt, sharp, canvas) no compilan en Alpine o se comportan diferente. Si tu app usa alguno de estos, probá la imagen antes de festejar el tamaño:
# Si tenés problemas con binarios nativos en Alpine,
# usá slim en lugar de alpine — menos dramático pero más seguro
FROM node:20-slim AS production
2. El orden de las capas importa para el cache
Esto lo sabía pero igual lo veo roto constantemente:
# MAL — invalida el cache de dependencias con cada cambio de código
COPY . .
RUN npm install
# BIEN — el cache de npm install sobrevive cambios en el source
COPY package*.json ./
RUN npm install
COPY . .
3. npm install vs npm ci
En Docker siempre npm ci. No hay discusión. npm install puede resolver versiones distintas cada vez. npm ci usa el lockfile y es reproducible.
4. No limpiar el cache de npm
# Después de instalar, limpiá el cache — ahorra 50-100MB fácil
RUN npm ci --only=production && npm cache clean --force
5. El .dockerignore que se olvida
Sin .dockerignore, el node_modules local entra en el build context y puede pisar el que instaló Docker. Siempre, siempre, .dockerignore antes de cualquier otra optimización.
FAQ: optimización de imágenes Docker
¿Cuánto puedo reducir una imagen Docker típica de Node.js?
Depende del punto de partida, pero en proyectos reales el rango típico es 70-90% de reducción. De node:20 (1.1GB base) a node:20-alpine (45MB base) ya es dramático. Sumando multi-stage para separar devDependencies del runtime, es habitual pasar de 1-2GB a 150-300MB.
¿Siempre conviene usar Alpine?
No. Alpine es excelente para la mayoría de los casos pero tiene incompatibilidades con paquetes que usan binarios nativos compilados contra glibc. Si usás sharp, bcrypt, canvas o similares, validá en Alpine antes de deployar. Si hay problemas, node:20-slim es el término medio: más pequeño que la imagen completa, más compatible que Alpine.
¿Qué es multi-stage build y por qué reduce el tamaño?
Multi-stage build te permite tener múltiples FROM en un Dockerfile. Cada stage es un environment separado. Podés hacer el build en un stage con todas las herramientas necesarias y copiar solo el artefacto final a un stage limpio. La imagen resultante solo contiene el último stage — sin compiladores, sin devDependencies, sin código fuente si no lo necesitás.
¿Cómo sé qué está ocupando espacio en mi imagen?
Usá docker image history nombre-imagen para ver el tamaño de cada capa. Para análisis más detallado, dive es una herramienta excelente: te muestra cada capa con un file explorer interactivo y cuánto espacio aporta cada archivo.
# Instalar dive
brew install dive # macOS
# o
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive nombre-imagen
¿El tamaño de imagen afecta el rendimiento en runtime?
El tamaño de imagen afecta principalmente los tiempos de pull y push — que impactan directo en los pipelines de CI/CD y en el tiempo de cold start en plataformas como Railway o Fly.io. Una vez que el container está corriendo, el tamaño de imagen no afecta el rendimiento. Lo que sí afecta en runtime es la cantidad de procesos, la memoria asignada y la configuración de Node, no el tamaño de la imagen.
¿Cómo evito el problema del hot reload que describe el post?
La solución más robusta es tener Dockerfiles separados para dev y producción (Dockerfile y Dockerfile.dev). Si preferís un solo Dockerfile multi-stage, especificá siempre el target en el docker-compose.dev.yml. Nunca dejés que Docker asuma qué stage usar en un compose de desarrollo — la asunción por defecto es el último stage, que generalmente es el de producción.
Conclusión: la métrica que falta en todos los posts de optimización
El número de MB que reducís es la métrica más fácil de mostrar y la menos importante para el equipo.
La métrica que importa es: ¿el flujo de desarrollo quedó intacto? ¿El equipo puede hacer cambios y verlos reflejados inmediatamente? ¿La paridad entre dev y prod es suficiente para que los bugs aparezcan antes del deploy?
Yo fallé esa métrica. La imagen quedó hermosa. El equipo perdió dos días.
Si estás encarando una optimización así, agregá esto al checklist antes de mergear:
- ¿Corré
docker-compose upy modificé un archivo en/src? ¿Se reflejó el cambio? - ¿Hay variables de entorno que el stage de producción no tiene?
- ¿Los health checks funcionan igual?
- ¿Las rutas de archivos estáticos son las mismas?
Cuatro preguntas, diez minutos. Hubieran salvado dos días de hot reload roto.
Es el mismo principio que aplico en cualquier cambio de infraestructura, desde los sistemas distribuidos que mencioné en el post sobre desarrollo multi-agente hasta el trabajo con runtimes custom como el de Rust para TypeScript: optimizar una dimensión sin medir el impacto en las otras es la forma más elegante de romper cosas. Lo aprendí en un cyber café a los 14, arreglando conexiones caídas con el local lleno — si la solución crea un problema nuevo que nadie ve, no es una solución.
Los 186MB se ven bien en el PR. El equipo que puede hacer hot reload se siente bien en el día a día. Optimizá ambos.
Este artículo fue publicado originalmente en juanchi.dev
Top comments (0)