Back

¿Por Qué Tu Docker Build Tarda Tanto? Guía Práctica para Optimizar Dockerfiles

¿Te ha pasado? Cambias una línea de código, haces push, y te quedas mirando cómo el CI tarda 15 minutos en hacer el build. Otra vez. Van cinco hoy.

Si te suena familiar, no eres el único. Los builds lentos de Docker son uno de esos problemas que todos sufrimos pero nadie arregla porque "así es Docker". La cosa es que afectan más de lo que parece: matan la productividad, alargan cada iteración, y convierten un hotfix de 5 minutos en una hora de espera.

Pero aquí va la buena noticia: la mayoría de los builds lentos se pueden arreglar. Y no hablo de mejoras marginales. Con las técnicas correctas, puedes reducir los tiempos de build entre 80% y 95%. Ese build de 15 minutos puede terminar en menos de un minuto.

En esta guía vamos a ver por qué los builds se vuelven lentos y cómo arreglarlo paso a paso.

El Costo Real de los Builds Lentos

Antes de meternos en lo técnico, hagamos cuentas de por qué esto importa.

Los Números

Imagina un equipo de 10 devs:

  • Cada uno hace 5 commits al día
  • Cada commit dispara un build de 15 minutos
  • Son 750 minutos de espera al día — 12 horas y media
  • Al año (250 días hábiles): 3,125 horas — equivalente a 1.5 ingenieros trabajando tiempo completo solo esperando builds

Si bajas el build a 2 minutos, esas 3,125 horas se convierten en 417. Básicamente "contrataste" un ingeniero extra sin gastar en recruiting.

El Problema de la Concentración

¿Qué haces mientras esperas el build? ¿Slack? ¿Twitter? 15 minutos es mucho para quedarte mirando, pero poco para empezar algo nuevo.

Estudios de productividad dicen que después de una interrupción tardamos unos 23 minutos en volver a concentrarnos. Cuando el build termina, ya perdiste el hilo. Si esto pasa 5 veces al día... bueno, ahí se te fue la mañana.

Cómo Funciona el Cache de Docker

La clave para optimizar builds es entender el cache. Si no entiendes esto, todo lo demás es inútil.

La Mecánica del Build

Cuando ejecutas docker build, Docker procesa el Dockerfile línea por línea. Pero no ejecuta todo desde cero cada vez:

  1. Lee cada instrucción (FROM, RUN, COPY, etc.)
  2. Revisa si tiene esa capa cacheada de un build anterior
  3. Si el cache es válido, lo reutiliza. Si no, ejecuta la instrucción.
  4. Cuando se invalida el cache en una capa, todas las siguientes se reconstruyen

El punto 4 es clave. El cache de Docker es una cadena. Si se rompe en el paso 3, los pasos 4, 5, 6 y todos los demás se ejecutan de nuevo, aunque no hayan cambiado.

¿Qué Invalida el Cache?

InstrucciónSe invalida cuando...
RUNCambia el comando
COPYCambia el contenido o permisos de los archivos
ADDLo mismo que COPY, más contenido de URLs
ARGCambia el valor del argumento
ENVCambia el valor de la variable

La más problemática es COPY. Si pones COPY . . al inicio del Dockerfile, cualquier cambio en cualquier archivo (hasta el README) invalida el cache de todo lo que viene después.

Los 7 Errores Más Comunes en Dockerfiles

Los builds lentos tienen patrones. Evita estos y ya vas a ver mejoras.

Error 1: Copiar Todo Antes de Instalar

# ❌ Mal: El error clásico FROM node:20 WORKDIR /app COPY . . # Cualquier cambio rompe el cache RUN npm install # Se reinstala TODO cada vez RUN npm run build

Cambias una línea de código y npm install corre de nuevo. En proyectos con 500 dependencias, eso son 3-5 minutos tirados a la basura.

# ✅ Bien: Copiar estratégicamente FROM node:20 WORKDIR /app COPY package*.json ./ # Solo los archivos de dependencias RUN npm install # Se cachea si no cambian COPY . . # Después el resto RUN npm run build

Si package.json no cambió, npm install usa el cache. Solo se copia el código nuevo y se hace el build.

Error 2: DevDependencies en Producción

# ❌ Mal: TypeScript, Jest, todo va a producción FROM node:20 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build CMD ["node", "dist/server.js"]

La imagen final tiene todas las devDependencies que nunca se van a usar.

# ✅ Bien: Multi-stage build FROM node:20 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-slim AS production WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY --from=builder /app/dist ./dist CMD ["node", "dist/server.js"]

El build pasa en builder, producción solo tiene lo necesario.

Error 3: No Usar .dockerignore

Sin .dockerignore, cada docker build manda node_modules, .git, y todo al daemon. Estamos hablando de cientos de MB cada vez.

# .dockerignore - Créalo
node_modules
.git
*.md
.env*
.vscode
coverage
dist
*.log
.DS_Store

node_modules especialmente es inútil mandarlo — lo vas a reinstalar de todas formas.

Error 4: Imágenes Base Pesadas

# ❌ Muy pesado FROM node:20 # 1.1GB FROM python:3.11 # 1.0GB
# ✅ Mejor FROM node:20-slim # 240MB FROM node:20-alpine # 140MB FROM python:3.11-slim # 150MB FROM python:3.11-alpine # 52MB

Ojo con alpine — usa musl libc en vez de glibc y algunos paquetes nativos pueden fallar. Probá antes de ir a producción.

Error 5: apt-get Por Separado

# ❌ Capas de más RUN apt-get update RUN apt-get install -y curl RUN apt-get install -y git RUN apt-get clean
# ✅ Todo junto RUN apt-get update && \ apt-get install -y --no-install-recommends curl git && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*

Menos capas, y el clean tiene que estar en la misma capa que el install para que realmente ahorre espacio.

Error 6: No Usar BuildKit

BuildKit es el builder moderno de Docker, default desde la versión 23.0. Pero muchos sistemas todavía lo tienen deshabilitado.

export DOCKER_BUILDKIT=1 docker build . # O en daemon.json { "features": { "buildkit": true } }

Con BuildKit:

  • Los stages independientes se ejecutan en paralelo
  • El cache es más inteligente
  • Puedes pasar secretos de forma segura
  • Puedes usar cache mounts (esto es lo importante)

Error 7: No Fijar Versiones

# ❌ Siempre descarga lo último → nunca cachea RUN curl -L https://example.com/latest/binary -o /usr/bin/binary
# ✅ Versión fija → cachea ARG BINARY_VERSION=1.2.3 RUN curl -L https://example.com/v${BINARY_VERSION}/binary -o /usr/bin/binary

Con versiones explícitas, Docker puede reutilizar descargas anteriores.

BuildKit Cache Mounts — Acá Está la Magia

Todo lo anterior son buenas prácticas. Pero si querés que el build sea realmente rápido, necesitás cache mounts.

Cache del Package Manager

# Node.js FROM node:20 WORKDIR /app COPY package*.json ./ RUN --mount=type=cache,target=/root/.npm \ npm ci # Go FROM golang:1.21 WORKDIR /app COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download # Python FROM python:3.11 WORKDIR /app COPY requirements.txt ./ RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txt # Rust FROM rust:1.73 WORKDIR /app COPY Cargo.toml Cargo.lock ./ RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ cargo build --release

--mount=type=cache mantiene ese directorio entre builds. Los paquetes que ya descargaste se reutilizan. npm, pip, go modules — casi instantáneos.

Cache de Compilación

Algunos lenguajes se benefician de caches del compilador:

# C/C++ con ccache FROM gcc:12 RUN apt-get update && apt-get install -y ccache ENV PATH="/usr/lib/ccache:${PATH}" WORKDIR /app COPY . . RUN --mount=type=cache,target=/root/.ccache \ make -j$(nproc)

Compartir Caches Entre Builds

Por defecto los cache mounts están aislados por build. Pero puedes compartirlos:

RUN --mount=type=cache,target=/root/.npm,id=npm-cache,sharing=shared \ npm ci

sharing=shared permite que builds concurrentes lean y escriban en el mismo cache.

Cache de Build

# Next.js RUN --mount=type=cache,target=/app/.next/cache \ npm run build

Next.js guarda info de builds anteriores en .next/cache. Si lo mantenés, solo recompila lo que cambió.

Multi-Stage Bien Hecho

Multi-stage no es solo para imágenes más chicas. Es para paralelización y separación de cache.

El Patrón de Build Paralelo

# Stage 1: Dependencias FROM node:20 AS deps WORKDIR /app COPY package*.json ./ RUN npm ci # Stage 2: Build (después de deps) FROM deps AS builder COPY . . RUN npm run build # Stage 3: Tests (después de deps, paralelo con build) FROM deps AS tester COPY . . RUN npm test # Stage 4: Lint (después de deps, paralelo con build y tests) FROM deps AS linter COPY . . RUN npm run lint # Final: Producción FROM node:20-slim AS production WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=deps /app/node_modules ./node_modules CMD ["node", "dist/server.js"]

BuildKit ejecuta builder, tester y linter en paralelo porque solo dependen de deps. Lo que serían 6 minutos secuenciales pueden ser 2 minutos paralelos.

Builds Condicionales

ARG BUILD_ENV=production FROM node:20 AS base WORKDIR /app COPY package*.json ./ RUN npm ci FROM base AS development COPY . . CMD ["npm", "run", "dev"] FROM base AS production-builder COPY . . RUN npm run build FROM node:20-slim AS production WORKDIR /app COPY --from=production-builder /app/dist ./dist COPY --from=production-builder /app/node_modules ./node_modules CMD ["node", "dist/server.js"]

Así lo buildeas:

docker build --target development -t myapp:dev . docker build --target production -t myapp:prod .

Cache en CI/CD

En local el cache se mantiene. En CI cada job es un container nuevo. Hay que configurar cache externo.

GitHub Actions

name: Build on: push jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: myapp:latest cache-from: type=gha cache-to: type=gha,mode=max

type=gha usa el cache nativo de GitHub Actions.

GitLab CI

build: stage: build image: docker:24 services: - docker:dind variables: DOCKER_BUILDKIT: 1 script: - docker build --cache-from $CI_REGISTRY_IMAGE:cache --build-arg BUILDKIT_INLINE_CACHE=1 -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -t $CI_REGISTRY_IMAGE:cache . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - docker push $CI_REGISTRY_IMAGE:cache

--cache-from trae capas del registry, BUILDKIT_INLINE_CACHE=1 embebe metadata de cache en la imagen.

Servicio de Cache de Build

Para equipos grandes, podés usar servicios especializados:

docker buildx build \ --cache-from type=registry,ref=myregistry/myapp:buildcache \ --cache-to type=registry,ref=myregistry/myapp:buildcache,mode=max \ -t myapp:latest .

Estrategias por Lenguaje

Node.js / JavaScript

FROM node:20-alpine AS deps WORKDIR /app # npm ci para instalaciones determinísticas COPY package*.json ./ RUN --mount=type=cache,target=/root/.npm \ npm ci --prefer-offline FROM deps AS builder COPY . . # Cache de Next.js RUN --mount=type=cache,target=/app/.next/cache \ npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production # Solo archivos necesarios COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static CMD ["node", "server.js"]

El .next/cache de Next.js guarda artefactos de build que aceleran los rebuilds.

Python

FROM python:3.11-slim AS builder # Dependencias de build RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Virtual environment RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . RUN --mount=type=cache,target=/root/.cache/pip \ pip install --no-compile -r requirements.txt FROM python:3.11-slim AS runtime COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" WORKDIR /app COPY . . CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]

El virtualenv se crea en builder y se copia a runtime, sin herramientas de build.

Go

Go produce binarios estáticos excelentes:

FROM golang:1.21-alpine AS builder WORKDIR /app # Dependencias primero (para cache) COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download COPY . . RUN --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server . # Runtime mínimo - solo el binario FROM scratch COPY --from=builder /app/server /server COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ ENTRYPOINT ["/server"]

La imagen final puede ser menor a 20MB — solo el binario y certificados TLS.

Rust

FROM rust:1.73 AS planner WORKDIR /app RUN cargo install cargo-chef COPY . . RUN cargo chef prepare --recipe-path recipe.json FROM rust:1.73 AS cacher WORKDIR /app RUN cargo install cargo-chef COPY --from=planner /app/recipe.json recipe.json RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ cargo chef cook --release --recipe-path recipe.json FROM rust:1.73 AS builder WORKDIR /app COPY --from=cacher /app/target target COPY --from=cacher /usr/local/cargo /usr/local/cargo COPY . . RUN cargo build --release FROM debian:bookworm-slim AS runtime COPY --from=builder /app/target/release/myapp /usr/local/bin/ CMD ["myapp"]

cargo-chef compila dependencias por separado, mejorando muchísimo el cache.

Midiendo Performance

No podés optimizar lo que no podés medir. Así se perfila un build:

Timing con BuildKit

# Build con timing detallado DOCKER_BUILDKIT=1 docker build --progress=plain . 2>&1 | tee build.log # Parsear tiempos grep "DONE\|CACHED" build.log

El Comando docker history

docker history myapp:latest --format "{{.Size}}\t{{.CreatedBy}}"

Muestra el tamaño de cada capa y el comando. Útil para identificar pasos inflados.

Script de Benchmark

#!/bin/bash # benchmark-build.sh iterations=5 echo "Benchmarking Docker build..." # Limpiar cache docker builder prune -f >/dev/null 2>&1 # Build frío (sin cache) start=$(date +%s.%N) docker build -t myapp:bench . >/dev/null 2>&1 end=$(date +%s.%N) cold_time=$(echo "$end - $start" | bc) echo "Build frío: ${cold_time}s" # Builds calientes total=0 for i in $(seq 1 $iterations); do start=$(date +%s.%N) docker build -t myapp:bench . >/dev/null 2>&1 end=$(date +%s.%N) duration=$(echo "$end - $start" | bc) total=$(echo "$total + $duration" | bc) echo "Build caliente $i: ${duration}s" done avg=$(echo "scale=2; $total / $iterations" | bc) echo "Promedio build caliente: ${avg}s"

Caso Real: De 14 Minutos a 47 Segundos

Esto lo aplicamos en un monorepo de React.

El Dockerfile Original

FROM node:18 WORKDIR /app COPY . . RUN npm install RUN npm run build CMD ["npx", "serve", "-s", "build"]
  • Tiempo: 14 minutos 23 segundos (promedio de 10 builds)
  • Imagen: 1.8GB

Problemas:

  • COPY . . antes de npm install
  • Sin .dockerignore
  • Imagen node completa (1.1GB)
  • Sin multi-stage (devDependencies en producción)

Paso 1: .dockerignore + Orden de COPY

FROM node:18 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build ...
# .dockerignore
node_modules
.git
build
coverage
  • Tiempo: 6 minutos 12 segundos (57% menos)
  • Con cache: 2 minutos 8 segundos

Paso 2: Multi-stage

FROM node:18-slim AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM deps AS builder COPY . . RUN npm run build FROM node:18-slim AS runner WORKDIR /app RUN npm install -g serve COPY --from=builder /app/build ./build EXPOSE 3000 CMD ["serve", "-s", "build"]
  • Tiempo: 4 minutos 45 segundos (67% menos que el original)
  • Imagen: 450MB

Paso 3: Cache Mounts

FROM node:18-slim AS deps WORKDIR /app COPY package*.json ./ RUN --mount=type=cache,target=/root/.npm npm ci FROM deps AS builder COPY . . RUN npm run build FROM nginx:alpine AS runner COPY --from=builder /app/build /usr/share/nginx/html EXPOSE 80
  • Tiempo: 1 minuto 52 segundos (87% menos que el original)
  • Con cache: 47 segundos (97% menos!)
  • Imagen: 42MB

Resultado Final

MétricaAntesDespuésMejora
Build frío14m 23s1m 52s87%
Build con cache14m 23s47s97%
Tamaño imagen1.8GB42MB98%
Costo CI mensual~$180~$1293%

Problemas Comunes

"El cache no funciona"

  1. CI efímero: Cada job es un container nuevo → usá cache de registry
  2. BuildKit deshabilitado: docker info | grep buildkit
  3. ARG que cambia: Si el valor de un ARG cambia, invalida todo lo que viene después
  4. Comandos con tiempo: RUN date siempre invalida
# ❌ Invalida cache cada build RUN echo "Build: $(date)" > /build-info.txt # ✅ Usar build arg ARG BUILD_TIME RUN echo "Build: $BUILD_TIME" > /build-info.txt

"Multi-stage es más lento"

Si no tenés BuildKit, los stages no se paralelizan:

docker info | grep -i buildkit

"Cache mounts no funcionan en CI"

Los mounts persistentes son locales. Usá cache externa:

- name: Build run: | docker buildx build \ --cache-from type=gha \ --cache-to type=gha,mode=max \ -t myapp .

Checklist de Optimización

  • DOCKER_BUILDKIT=1 configurado
  • .dockerignore existe (node_modules, .git, etc.)
  • Dependencias se copian primero, código después
  • Imagen base slim o alpine
  • Multi-stage separando build de runtime
  • --mount=type=cache para package managers
  • Cache configurado en CI
  • Comandos RUN combinados
  • Dependencias externas con versión fija
  • Tiempos de build medidos

Conclusión

Optimizar Docker builds es de esas cosas que se hacen una vez y dan valor para siempre. Las técnicas de esta guía — orden de capas, multi-stage, cache mounts — pueden bajar builds de 15 minutos a menos de un minuto.

Builds rápidos no solo ahorran tiempo:

  • Feedback loops más cortos
  • Menor costo de CI
  • Más deploys por día
  • Desarrolladores más contentos

Empezá con lo fácil: .dockerignore, orden de COPY, habilitar BuildKit. Después agregá multi-stage y cache mounts. Medí cada cambio.

Te vas a sorprender de cuánta velocidad estaba escondida en tu Dockerfile.

dockerdevopsoptimizationcicdcontainersbuildkitperformance

Explora herramientas relacionadas

Prueba estas herramientas gratuitas de Pockit