Tools: Optimicé una imagen Docker de 1.58GB a 186MB. Y rompí el hot reload sin que nadie me lo dijera por dos días. - Expert Insights

Tools: Optimicé una imagen Docker de 1.58GB a 186MB. Y rompí el hot reload sin que nadie me lo dijera por dos días. - Expert Insights

Cómo optimizar imagen Docker tamaño: lo que funciona de verdad

Lo que rompí sin darme cuenta

Los errores más comunes al optimizar imágenes Docker

FAQ: optimización de imágenes Docker

Conclusión: la métrica que falta en todos los posts de optimización 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. 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. 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: Y el .dockerignore que importa tanto como el Dockerfile: Resultado: 1.58GB → 186MB. Un 88% menos. Pull times en CI/CD cayeron de 4 minutos a 40 segundos. Legítimo. 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: 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. 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: Más archivos, cero confusión. 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: 2. El orden de las capas importa para el cache Esto lo sabía pero igual lo veo roto constantemente: 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 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. ¿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. ¿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. 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: 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 Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# Dockerfile ORIGINAL — el que pesaba 1.58GB FROM node:20 WORKDIR /app # Copiamos todo sin filtrar nada COPY package*.json ./ RUN -weight: 500;">npm -weight: 500;">install COPY . . # Build del TS RUN -weight: 500;">npm run build EXPOSE 3000 CMD ["node", "dist/index.js"] # Dockerfile ORIGINAL — el que pesaba 1.58GB FROM node:20 WORKDIR /app # Copiamos todo sin filtrar nada COPY package*.json ./ RUN -weight: 500;">npm -weight: 500;">install COPY . . # Build del TS RUN -weight: 500;">npm run build EXPOSE 3000 CMD ["node", "dist/index.js"] # Dockerfile ORIGINAL — el que pesaba 1.58GB FROM node:20 WORKDIR /app # Copiamos todo sin filtrar nada COPY package*.json ./ RUN -weight: 500;">npm -weight: 500;">install COPY . . # Build del TS RUN -weight: 500;">npm run build EXPOSE 3000 CMD ["node", "dist/index.js"] # 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 -weight: 500;">npm ci --include=dev # Copiamos fuente y compilamos COPY tsconfig.json ./ COPY src/ ./src/ RUN -weight: 500;">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 -weight: 500;">npm ci --only=production && -weight: 500;">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"] # 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 -weight: 500;">npm ci --include=dev # Copiamos fuente y compilamos COPY tsconfig.json ./ COPY src/ ./src/ RUN -weight: 500;">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 -weight: 500;">npm ci --only=production && -weight: 500;">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"] # 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 -weight: 500;">npm ci --include=dev # Copiamos fuente y compilamos COPY tsconfig.json ./ COPY src/ ./src/ RUN -weight: 500;">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 -weight: 500;">npm ci --only=production && -weight: 500;">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"] # .dockerignore — todo lo que NO debe entrar node_modules dist .-weight: 500;">git .gitignore *.md .env* .dockerignore Dockerfile* -weight: 500;">npm-debug.log* # .dockerignore — todo lo que NO debe entrar node_modules dist .-weight: 500;">git .gitignore *.md .env* .dockerignore Dockerfile* -weight: 500;">npm-debug.log* # .dockerignore — todo lo que NO debe entrar node_modules dist .-weight: 500;">git .gitignore *.md .env* .dockerignore Dockerfile* -weight: 500;">npm-debug.log* # -weight: 500;">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: -weight: 500;">npm run dev # ts-node-dev ports: - "3000:3000" # -weight: 500;">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: -weight: 500;">npm run dev # ts-node-dev ports: - "3000:3000" # -weight: 500;">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: -weight: 500;">npm run dev # ts-node-dev ports: - "3000:3000" # -weight: 500;">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: -weight: 500;">npm run dev ports: - "3000:3000" environment: - NODE_ENV=development # -weight: 500;">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: -weight: 500;">npm run dev ports: - "3000:3000" environment: - NODE_ENV=development # -weight: 500;">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: -weight: 500;">npm run dev ports: - "3000:3000" environment: - NODE_ENV=development # Dockerfile.dev — solo para desarrollo, sin ambigüedad FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN -weight: 500;">npm ci # Todas las dependencias, incluyendo dev # El source lo monta el volumen de compose # No copiamos nada más acá EXPOSE 3000 CMD ["-weight: 500;">npm", "run", "dev"] # Dockerfile.dev — solo para desarrollo, sin ambigüedad FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN -weight: 500;">npm ci # Todas las dependencias, incluyendo dev # El source lo monta el volumen de compose # No copiamos nada más acá EXPOSE 3000 CMD ["-weight: 500;">npm", "run", "dev"] # Dockerfile.dev — solo para desarrollo, sin ambigüedad FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN -weight: 500;">npm ci # Todas las dependencias, incluyendo dev # El source lo monta el volumen de compose # No copiamos nada más acá EXPOSE 3000 CMD ["-weight: 500;">npm", "run", "dev"] # -weight: 500;">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" # -weight: 500;">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" # -weight: 500;">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" # 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 # 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 # 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 # MAL — invalida el cache de dependencias con cada cambio de código COPY . . RUN -weight: 500;">npm -weight: 500;">install # BIEN — el cache de -weight: 500;">npm -weight: 500;">install sobrevive cambios en el source COPY package*.json ./ RUN -weight: 500;">npm -weight: 500;">install COPY . . # MAL — invalida el cache de dependencias con cada cambio de código COPY . . RUN -weight: 500;">npm -weight: 500;">install # BIEN — el cache de -weight: 500;">npm -weight: 500;">install sobrevive cambios en el source COPY package*.json ./ RUN -weight: 500;">npm -weight: 500;">install COPY . . # MAL — invalida el cache de dependencias con cada cambio de código COPY . . RUN -weight: 500;">npm -weight: 500;">install # BIEN — el cache de -weight: 500;">npm -weight: 500;">install sobrevive cambios en el source COPY package*.json ./ RUN -weight: 500;">npm -weight: 500;">install COPY . . # Después de instalar, limpiá el cache — ahorra 50-100MB fácil RUN -weight: 500;">npm ci --only=production && -weight: 500;">npm cache clean --force # Después de instalar, limpiá el cache — ahorra 50-100MB fácil RUN -weight: 500;">npm ci --only=production && -weight: 500;">npm cache clean --force # Después de instalar, limpiá el cache — ahorra 50-100MB fácil RUN -weight: 500;">npm ci --only=production && -weight: 500;">npm cache clean --force # Instalar dive -weight: 500;">brew -weight: 500;">install dive # macOS # o -weight: 500;">docker run --rm -it -v /var/run/-weight: 500;">docker.sock:/var/run/-weight: 500;">docker.sock wagoodman/dive nombre-imagen # Instalar dive -weight: 500;">brew -weight: 500;">install dive # macOS # o -weight: 500;">docker run --rm -it -v /var/run/-weight: 500;">docker.sock:/var/run/-weight: 500;">docker.sock wagoodman/dive nombre-imagen # Instalar dive -weight: 500;">brew -weight: 500;">install dive # macOS # o -weight: 500;">docker run --rm -it -v /var/run/-weight: 500;">docker.sock:/var/run/-weight: 500;">docker.sock wagoodman/dive nombre-imagen - ¿Corré -weight: 500;">docker-compose up y 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?