Docker Build lento: Caché de BuildKit que realmente lo acelera

¿Te fue útil?

Algunas compilaciones Docker son lentas por motivos honestos: estás compilando un monstruo, descargando media internet o haciendo criptografía a escala. Pero la mayoría de las “compilaciones lentas” son autoinfligidas. El patrón habitual: pagas por el mismo trabajo en cada commit, en cada portátil, en cada runner de CI, para siempre.

BuildKit puede arreglar eso—si dejas de tratar la caché como “Docker recuerda cosas” y empiezas a verla como una cadena de suministro. Esta es una guía de campo de caché que resiste la presión de producción: qué medir, qué cambiar y qué terminará volviéndose en tu contra.

El modelo mental: la caché de BuildKit no es magia, son direcciones

Las compilaciones Docker clásicas (el builder heredado) funcionaban como una pila de capas: cada instrucción creaba una instantánea del sistema de archivos. Si la instrucción y sus entradas no cambiaban, Docker podía reutilizar la capa. Esa historia sigue siendo en gran parte cierta, pero BuildKit cambió la mecánica y volvió la caché mucho más expresiva.

BuildKit trata tu compilación como un grafo de operaciones. Cada operación produce una salida, y esa salida puede ser cacheada mediante una clave derivada de los inputs (archivos, args, entorno, imágenes base y a veces metadatos). Si la clave coincide, BuildKit puede saltarse el trabajo y reutilizar la salida.

Esta última frase oculta la trampa: la caché solo es tan buena como tus claves. Si accidentalmente incluyes “la marca de tiempo de hoy” en tu clave, obtendrás un miss de caché cada vez. Si incluyes “todo el repo” como entrada de un paso temprano, invalidas el mundo cuando alguien edita un README.

Qué significa “realmente lo acelera”

Hay tres problemas de velocidad separados que la gente mezcla en una sola queja:

  • Trabajo repetido en una sola máquina: reconstruir el mismo Dockerfile localmente una y otra vez.
  • Trabajo repetido en varias máquinas: portátiles, runners efímeros de CI, flotas de compilación autoescaladas.
  • Pasos lentos incluso con cache hit: contextos grandes, pulls de imágenes lentos, descompresión e “instalar dependencias” que nunca se cachea.

La caché de BuildKit resuelve los tres—pero solo si eliges el mecanismo correcto:

  • Caché de capas (clásico): bueno para pasos inmutables que dependen de entradas estables.
  • Metadatos de caché inline: almacena información de caché dentro del manifiesto de la imagen para que otros builders la reutilicen.
  • Caché externa/remota: exporta la caché al registro/local/artifact para que los builders efímeros no empiecen en frío.
  • Cache mounts (RUN --mount=type=cache): hace que “instalar dependencias” sea rápido sin embutir caches en la imagen final.

Una regla operativa: si no puedes explicar por qué un paso está cacheado, no lo está. Simplemente tuvo suerte temporal.

Idea parafraseada de Werner Vogels (CTO de Amazon): “Todo falla, todo el tiempo.” Los misses de caché son un modo de fallo. Diseña en consecuencia.

Hechos interesantes y breve historia (porque importa)

  1. BuildKit empezó como un proyecto separado para reemplazar el builder heredado de Docker con un motor basado en grafos, permitiendo paralelismo y cachés más ricas.
  2. La caché de capas clásica de Docker precede a BuildKit y estaba muy ligada a “una instrucción = una instantánea de capa.” Ese modelo es simple, pero no puede expresar cachés efímeros de forma limpia.
  3. BuildKit puede ejecutar pasos independientes en paralelo (por ejemplo, tirar imágenes base mientras se transfiere el contexto de build), por eso los logs se ven distintos y los tiempos pueden mejorar sin cambiar el Dockerfile.
  4. .dockerignore es anterior a BuildKit pero se volvió más crítico a medida que los repos crecieron: los contextos grandes no solo ralentizan builds, sino que envenenan las claves de caché al cambiar entradas.
  5. El caché inline fue un hack pragmático: almacenar pistas de caché dentro de la imagen para que la siguiente compilación pueda reutilizar capas incluso en otra máquina.
  6. Los cache mounts de BuildKit fueron un cambio filosófico: “la caché es una preocupación de tiempo de build” y no “la caché está horneada en la imagen.” Esa es la diferencia entre builds rápidos e imágenes hinchadas.
  7. Las builds multietapa cambiaron el comportamiento de caché en equipos reales: puedes aislar toolchains costosos en etapas de build y mantener las etapas de runtime estables y amigables con la caché.
  8. La exportación de caché remota se volvió necesaria cuando CI pasó a runners efímeros y builders autoescalados donde el caché del disco local desaparece en cada ejecución.

Broma #1: La caché de Docker es como el estacionamiento de la oficina: todos creen tener un lugar reservado hasta que llega el lunes y demuestra lo contrario.

Guía de diagnóstico rápido: encuentra el cuello de botella en 10 minutos

Este es el orden de triage que ahorra tiempo. No empieces a “optimizar el Dockerfile” hasta saber cuál de estos es el verdadero villano.

1) ¿BuildKit está siquiera habilitado?

Si estás en una versión reciente de Docker normalmente lo está, pero “normalmente” no es un plan. El builder heredado se comporta distinto y carece de características clave como los cache mounts.

2) ¿El contexto de build es enorme o inestable?

Si estás enviando gigabytes, serás lento incluso con caché perfecta. Y si el contexto cambia en cada commit (logs, salidas de build, vendor dirs), las claves de caché churnean.

3) ¿Estás obteniendo cache hits donde los esperas?

Mira la salida: CACHED debería aparecer en pasos costosos. Si tu paso de instalación de dependencias se ejecuta cada vez, ese es tu objetivo.

4) ¿CI se inicia en frío en cada ejecución?

Los runners efímeros significan que la caché local se pierde. Sin un exportador/importador de caché remoto, estás reconstruyendo desde cero sin importar qué tan “amigable con la caché” parezca tu Dockerfile.

5) ¿Estás limitado por red o CPU?

Descargas de dependencias, pulls de imágenes base y actualizaciones de índices de paquetes consumen red. Las compilaciones consumen CPU. La solución difiere.

6) ¿Secrets/SSH están causando misses de caché?

Los secretos no forman parte de la clave de caché por diseño, pero la forma en que conectas dependencias privadas a menudo cambia comandos o introduce no determinismo, lo que destruye la reutilización de caché.

7) ¿Estás rompiendo la caché por accidente?

Culpables comunes: ADD . demasiado temprano, paquetes sin fijar versiones, args de build que cambian en cada ejecución, timestamps y “limpieza” que modifica los mtime de archivos de formas inesperadas.

Tareas prácticas: comandos, salidas, decisiones (12+)

Estos no son “comandos de juguete.” Son los que ejecutarás cuando tu pipeline de build esté en llamas y necesites decidir qué cambiar a continuación.

Task 1: Confirma que BuildKit está activado (y qué builder se usa)

cr0x@server:~$ docker build --progress=plain -t demo:bk .
#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 1.12kB done
#2 [internal] load metadata for docker.io/library/alpine:3.19
#2 DONE 0.8s
#3 [internal] load .dockerignore
#3 transferring context: 2B done
#3 DONE 0.0s
#4 [1/3] FROM docker.io/library/alpine:3.19
#4 resolve docker.io/library/alpine:3.19 done
#4 DONE 0.0s

Qué significa: Los pasos numerados y las fases “internal” son salida estilo BuildKit. Si ves el estilo antiguo “Step 1/…”, no estás usando BuildKit.

Decisión: Si la salida es heredada, habilita BuildKit vía entorno o configuración del daemon antes de hacer cualquier otra cosa. Si no, estarás optimizando el motor equivocado.

Task 2: Imprime la versión de Docker y las características del servidor

cr0x@server:~$ docker version
Client: Docker Engine - Community
 Version:           26.1.3
 API version:       1.45
Server: Docker Engine - Community
 Engine:
  Version:          26.1.3
  API version:      1.45 (minimum version 1.24)
  Experimental:     false

Qué significa: Las capacidades de BuildKit varían con las versiones del engine/buildx. Motores muy antiguos pueden ser “BuildKit-ish” pero carecer de características clave.

Decisión: Si estás varios números mayores atrás, actualiza primero. Las mejoras de caché en un engine antiguo son como afinar un carburador en un coche que necesita un cambio de motor.

Task 3: Verifica la disponibilidad de buildx y el builder actual

cr0x@server:~$ docker buildx ls
NAME/NODE       DRIVER/ENDPOINT             STATUS   BUILDKIT  PLATFORMS
default         docker
  default       default                     running  v0.12.5   linux/amd64,linux/arm64

Qué significa: Tienes buildx y una instancia de builder. La versión de BuildKit importa para algunos exportadores/importadores de caché.

Decisión: Si buildx falta o el builder falla, arréglalo. El caché remoto es más fácil con buildx.

Task 4: Mide el tamaño del contexto de build (el asesino silencioso)

cr0x@server:~$ docker build --no-cache --progress=plain -t demo:ctx .
#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 2.01kB done
#2 [internal] load .dockerignore
#2 transferring context: 2B done
#3 [internal] load build context
#3 transferring context: 812.4MB 12.3s done
#3 DONE 12.4s

Qué significa: Transferencia de contexto de 812MB. Incluso si todo está cacheado, acabas de gastar 12 segundos antes de que la compilación empezara a hacer trabajo real.

Decisión: Arregla .dockerignore y/o usa una ruta de contexto más ajustada. No aceptes “está bien en mi máquina” aquí; CI pagará más.

Task 5: Inspecciona qué hay en tu contexto de build (chequeo rápido)

cr0x@server:~$ tar -czf - . | wc -c
853224921

Qué significa: Tu contexto es ~853MB comprimido. Normalmente son salidas de build, node_modules, virtualenvs o artefactos de tests colándose.

Decisión: Añade exclusiones o construye desde un subdirectorio que contenga solo lo que necesita la imagen.

Task 6: Verifica hits de caché paso a paso

cr0x@server:~$ docker build --progress=plain -t demo:cachecheck .
#7 [2/6] COPY package.json package-lock.json ./
#7 CACHED
#8 [3/6] RUN npm ci
#8 CACHED
#9 [4/6] COPY . .
#9 DONE 0.9s
#10 [5/6] RUN npm test
#10 DONE 34.6s

Qué significa: La instalación de dependencias está cacheada; las pruebas no lo están (y probablemente no deberían). Has separado “entradas estables” de “entradas cambiantes”.

Decisión: Si el paso caro de dependencias no está cacheado, reestructura el Dockerfile o usa cache mounts.

Task 7: Muestra el uso de caché en el host

cr0x@server:~$ docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          41        6         9.12GB    6.03GB (66%)
Containers      12        2         311MB     243MB (78%)
Local Volumes   8         3         1.04GB    618MB (59%)
Build Cache     145       0         3.87GB    3.87GB

Qué significa: Existe caché de build y es considerable; “ACTIVE 0” sugiere que no está anclada por builds en ejecución. Esa caché podría ser podada agresivamente por scripts.

Decisión: Si la caché sigue desapareciendo, deja de ejecutar “docker system prune -a” en imágenes de CI o runners compartidos sin entender el impacto.

Task 8: Inspecciona el uso de disco del builder BuildKit

cr0x@server:~$ docker buildx du
ID                                        RECLAIMABLE  SIZE      LAST ACCESSED
m5p8xg7n0f3m2b0c6v2h7w2n1                  true         1.2GB     2 hours ago
u1k7tq9p4y2c9s8a1b0n6v3z5                  true         842MB     3 days ago
total:                                                       2.0GB

Qué significa: Buildx tiene su propia contabilidad. Si esto está vacío en CI, no tienes caché persistente entre ejecuciones.

Decisión: Para builders efímeros, planifica exportar/importar caché remoto.

Task 9: Prueba que un build arg rompe la caché

cr0x@server:~$ docker build --progress=plain --build-arg BUILD_ID=123 -t demo:arg .
#6 [2/5] RUN echo "build id: 123" > /build-id.txt
#6 DONE 0.2s
cr0x@server:~$ docker build --progress=plain --build-arg BUILD_ID=124 -t demo:arg .
#6 [2/5] RUN echo "build id: 124" > /build-id.txt
#6 DONE 0.2s

Qué significa: Ese paso nunca se cacheará entre distintos valores de BUILD_ID. Si ese arg se usa temprano, invalida todo lo que viene después.

Decisión: Mueve args volátiles al final o deja de incrustar IDs de build en capas del filesystem a menos que realmente los necesites.

Task 10: Confirma si el pull de la imagen base es un cuello de botella

cr0x@server:~$ docker pull ubuntu:22.04
22.04: Pulling from library/ubuntu
Digest: sha256:4f2...
Status: Image is up to date for ubuntu:22.04
docker.io/library/ubuntu:22.04

Qué significa: “up to date” indica que ya estaba presente. Si los pulls son lentos y frecuentes en CI, quizás falte un mirror de registro compartido o caché local.

Decisión: Si CI siempre tira imágenes, considera cachear imágenes base en los runners o usar un registro más cercano a los runners.

Task 11: Construye con export/import de caché explícito a un directorio local

cr0x@server:~$ docker buildx build --progress=plain \
  --cache-to type=local,dest=/tmp/bkcache,mode=max \
  --cache-from type=local,src=/tmp/bkcache \
  -t demo:localcache --load .
#11 [4/7] RUN apt-get update && apt-get install -y build-essential
#11 DONE 39.4s
#12 exporting cache to client directory
#12 DONE 0.6s

Qué significa: Exportaste una caché reutilizable a /tmp/bkcache. En la siguiente ejecución, esos pasos costosos deberían mostrar CACHED.

Decisión: Si la caché local ayuda pero CI sigue lento, necesitas exportar caché remoto (registro/artifact) en lugar de solo local.

Task 12: Valida el cache en la segunda ejecución (prueba, no sensaciones)

cr0x@server:~$ docker buildx build --progress=plain \
  --cache-from type=local,src=/tmp/bkcache \
  -t demo:localcache --load .
#11 [4/7] RUN apt-get update && apt-get install -y build-essential
#11 CACHED
#13 exporting to docker image format
#13 DONE 1.1s

Qué significa: El paso costoso de apt está cacheado. Has probado que el mecanismo funciona.

Decisión: Ahora puedes invertir en caché remoto con seguridad, porque sabes que tu Dockerfile es cacheable.

Task 13: Detecta no determinismo en la instalación de paquetes

cr0x@server:~$ docker build --progress=plain -t demo:apt .
#9 [3/6] RUN apt-get update && apt-get install -y curl
#9 DONE 18.7s
cr0x@server:~$ docker build --progress=plain -t demo:apt .
#9 [3/6] RUN apt-get update && apt-get install -y curl
#9 DONE 19.4s

Qué significa: Reconstruyó ambas veces, lo que puede ocurrir si algo anterior invalidó la capa, si usaste --no-cache o si los inputs del filesystem cambiaron.

Decisión: Asegúrate de que el paso RUN apt-get ... venga después solo de entradas estables. También considera fijar versiones de paquetes o usar una imagen base que ya incluya herramientas comunes.

Task 14: Identifica un ADD . demasiado temprano (prueba de invalidación de caché)

cr0x@server:~$ git diff --name-only HEAD~1
README.md
cr0x@server:~$ docker build --progress=plain -t demo:readme .
#6 [2/6] COPY . .
#6 DONE 0.8s
#7 [3/6] RUN npm ci
#7 DONE 45.1s

Qué significa: Cambiar el README activó COPY . ., lo que invalidó npm ci porque copiaste todo el repo antes de instalar dependencias.

Decisión: Copia solo los manifiestos de dependencias primero; copia el resto después. Esto no es una “micro-optimización”; es la diferencia entre una reconstrucción de 20s y una de 2 minutos.

Patrones de Dockerfile que hacen que la caché funcione

BuildKit recompensa la disciplina. Si tu Dockerfile es un cajón de trastos, tu caché se comportará igual: lleno, costoso y sin contener lo que necesitas.

Patrón 1: Separa los manifiestos de dependencias del código fuente

Esta es la solución clásica porque es el error más común. Quieres que la instalación de dependencias esté indexada por el lockfile, no por cada archivo del repositorio.

cr0x@server:~$ cat Dockerfile
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

COPY . .
RUN npm run build

FROM node:20-bookworm-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
CMD ["node","dist/server.js"]

Por qué funciona: el costoso paso npm ci depende principalmente de package-lock.json. Un cambio en README.md no debería reinstalar todo el mundo.

Patrón 2: Coloca los pasos volátiles al final

Cualquier cosa que cambie en cada build—IDs de build, SHA de git, estampados de versión—debería estar cerca del final. Si no, invalidas toda la caché aguas abajo.

Mal:

cr0x@server:~$ cat Dockerfile.bad
FROM alpine:3.19
ARG GIT_SHA
RUN echo "$GIT_SHA" > /git-sha.txt
RUN apk add --no-cache curl

Bien:

cr0x@server:~$ cat Dockerfile.good
FROM alpine:3.19
RUN apk add --no-cache curl
ARG GIT_SHA
RUN echo "$GIT_SHA" > /git-sha.txt

Por qué funciona: Mantienes capas estables reutilizables. Tu estampado sigue ahí, pero solo invalida a sí mismo.

Patrón 3: Builds multietapa como límites de caché

Las builds multietapa no son solo para imágenes runtime más pequeñas. También te permiten aislar el “cambio del toolchain” de la “estabilidad del runtime”.

  • Etapa de build: compiladores, gestores de paquetes, cachés, headers.
  • Etapa de runtime: pequeña, estable, menos partes móviles, menos invalidaciones de caché.

Patrón 4: Sé deliberado con las etiquetas de la imagen base

Si usas etiquetas flotantes como latest, tu imagen base puede cambiar sin que toques el Dockerfile. Eso es un evento de invalidación de caché más un problema de reproducibilidad.

Usa etiquetas explícitas y, en entornos de alta garantía, prefiere fijar por digest. Es una elección de política: conveniencia frente a repetibilidad.

Patrón 5: Minimiza la fluctuación de apt-get update

apt-get update es una fuente frecuente de comportamiento impredecible de caché. No porque no pueda cachearse—sino porque tiende a colocarse en capas que se invalidan por cambios no relacionados.

Además: siempre combina update e install en una sola capa. Capas separadas causan índices obsoletos y 404s misteriosos después.

Patrón 6: Mantén el contexto de build limpio

Si no controlas tu contexto, no controlas tus claves de caché. Usa .dockerignore agresivamente: artefactos de build, directorios de dependencias, logs, reportes de tests, archivos de entorno local y cualquier cosa producida por CI mismo.

Broma #2: Si tu contexto de build incluye node_modules, tu daemon Docker básicamente está haciendo crossfit—levantando peso innecesario repetidamente.

Caché remota que sobrevive en CI: registro, local y runners

El caché local está bien. El caché remoto es lo que hace que los equipos dejen de quejarse en Slack. Si tu runner de CI es efímero, despierta con amnesia en cada ejecución. Eso no es una falla moral; es como están diseñados esos sistemas.

Caché inline: comparte pistas de caché vía la imagen

El caché inline almacena metadatos de caché en la configuración de la imagen para que compilaciones posteriores puedan tirar la imagen y reutilizar capas. Esto es el “mínimo viable” de caché entre máquinas.

Ejemplo de build (omitido push de imagen aquí; lo importante son las mecánicas):

cr0x@server:~$ docker buildx build --progress=plain \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  -t demo:inlinecache --load .
#15 exporting config sha256:4c1d...
#15 DONE 0.4s
#16 writing image sha256:0f9a...
#16 DONE 0.8s

Qué significa: La imagen ahora lleva metadatos de caché. Otro builder puede usarlo con --cache-from apuntando a esa referencia de imagen.

Decisión: Usa caché inline cuando ya empujas imágenes y quieres una ruta de caché simple. No siempre es suficiente, pero es una base sólida.

Caché en registro: exporta la caché por separado (más poderoso)

BuildKit puede exportar caché a una referencia de registro. Esto suele ser mejor que el caché inline porque puede almacenar más detalle y no requiere “promover” una imagen solo para usar su caché.

cr0x@server:~$ docker buildx build --progress=plain \
  --cache-to type=registry,ref=registry.internal/demo:buildcache,mode=max \
  --cache-from type=registry,ref=registry.internal/demo:buildcache \
  -t registry.internal/demo:app --push .
#18 exporting cache to registry
#18 DONE 2.7s

Qué significa: La caché se almacena como un artifact OCI en tu registro. La siguiente build la importa, incluso en un runner nuevo.

Decisión: Si CI es efímero y las builds son lentas, esto suele ser el movimiento correcto. El trade-off es el crecimiento del storage del registro y ocasionales dolores de cabeza con permisos.

Caché en directorio local: bueno para reutilización en un solo runner

Las exportaciones de caché local son geniales cuando tienes un runner persistente con un workspace que sobrevive entre ejecuciones, o cuando puedes adjuntar un volumen persistente.

También es un buen paso “probar que funciona” antes de lidiar con autenticación de registro y políticas de CI.

Elegir un modo de caché

  • mode=min: caché más pequeño, menos resultados intermedios. Bueno cuando el almacenamiento es limitado.
  • mode=max: caché más completa, mejores tasas de acierto. Bueno cuando quieres velocidad y puedes pagar el almacenamiento.

En la práctica: empieza con mode=max en CI por una semana, observa el crecimiento del registro y las tasas de acierto, luego decide si necesitas restringirlo.

Cache mounts que realmente aceleran la instalación de dependencias

La caché de capas es tosca. Los gestores de dependencias son sutiles. Quieren un directorio de caché que persista entre ejecuciones, pero no quieres hornear esa caché en la imagen final. Los cache mounts de BuildKit son la herramienta correcta.

npm / yarn / pnpm

El ejemplo de npm se mostró antes. La idea: montar un directorio de caché en /root/.npm (o la ruta de caché del usuario) durante la instalación.

apt

Puedes cachéar listas y archivos de apt. Es útil cuando tienes instalaciones repetidas entre builds y la red de CI no es excelente. No es una bala de plata; los metadatos de apt cambian con frecuencia.

cr0x@server:~$ cat Dockerfile.aptcache
# syntax=docker/dockerfile:1.7
FROM ubuntu:22.04
RUN --mount=type=cache,target=/var/cache/apt \
    --mount=type=cache,target=/var/lib/apt/lists \
    apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
    && rm -rf /var/lib/apt/lists/*

Nota operativa: cachear /var/lib/apt/lists puede acelerar las cosas, pero también puede enmascarar cambios en los repositorios de manera sorprendente si estás depurando la disponibilidad de paquetes. Úsalo con conocimiento.

pip

Los builds de Python son infractores clásicos: se descargan ruedas cada vez, se compilan siempre, y alguien finalmente “lo arregla” copiando todo el virtualenv en la imagen (no lo hagas).

cr0x@server:~$ cat Dockerfile.pipcache
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
COPY . .
CMD ["python","-m","app"]

Go

Go tiene dos caches clave: descarga de módulos y cache de build. BuildKit puede persistirlos sin contaminar la imagen de runtime.

cr0x@server:~$ cat Dockerfile.go
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS build
WORKDIR /src
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 \
    go build -o /out/app ./cmd/app

FROM gcr.io/distroless/base-debian12
COPY --from=build /out/app /app
CMD ["/app"]

Por qué importa: la caché de descarga de módulos ahorra red; la caché de build ahorra CPU. Diferentes cuellos de botella, mismo mecanismo.

Secrets, SSH y por qué tu caché desaparece

Las dependencias privadas son donde “funciona localmente” va a morir. La gente lo parchea con ARG TOKEN=... y accidentalmente hornea secretos en capas (malo) o rompe la caché de una forma que solo aparece en CI (también malo).

Usa secretos de BuildKit en vez de ARG para credenciales

Los secretos montados durante la build no se almacenan en las capas de la imagen. Tampoco se convierten en claves de caché. Eso es una característica y una trampa: tu paso puede estar cacheado incluso si el secreto cambia, así que asegúrate de que la salida sea determinista.

cr0x@server:~$ cat Dockerfile.secrets
# syntax=docker/dockerfile:1.7
FROM alpine:3.19
RUN apk add --no-cache git openssh-client
RUN --mount=type=secret,id=git_token \
    sh -c 'TOKEN=$(cat /run/secrets/git_token) && echo "token length: ${#TOKEN}" > /tmp/token-info'

Decisión: Si necesitas clonaciones privadas de git, usa --mount=type=ssh con agente reenviado o una deploy key, no un token en ARG.

Montajes SSH: relativamente seguros, pero ojo con la reproducibilidad

Los montajes SSH evitan hornear secretos. También introducen un nuevo modo de fallo: tu build depende de la red y del estado del repositorio remoto. Fija commits/tags para determinismo, o tu caché será “correctamente” invalidada por cambios upstream.

Tres micro-historias corporativas desde las trincheras del cache

1) Incidente causado por una suposición errónea: “CI cachea capas Docker por defecto”

Una empresa mediana migró su CI de VMs de larga vida a runners efímeros. La migración se presentó como una victoria: entornos limpios, menos builds inestables, menos mantenimiento de disco. Todo cierto. Pero alguien asumió que la caché de capas de Docker “simplemente estaría ahí” como en las VMs antiguas.

El primer día tras el corte, los tiempos de build se triplicaron. No un poco más lento—lo suficiente para que las ventanas de despliegue perdieran aprobaciones. Los ingenieros respondieron como suelen: paralelizar jobs, añadir más runners, tirar dinero. La granja de builds se hizo más grande; las builds siguieron lentas.

El problema real fue aburrido. Las VMs antiguas tenían cachés Docker calientes. Los nuevos runners arrancaban vacíos cada vez. Cada build tiraba imágenes base, descargaba dependencias y recompilaba el mismo código. La caché existía, pero se evaporaba al final de cada job.

La solución fue igual de aburrida: exportación/importación de caché remota usando buildx con una referencia de caché en el registry. De la noche a la mañana, los tiempos de build volvieron cerca de la línea base anterior. La “optimización” no fue un truco elegante de Dockerfile. Fue reconocer la realidad de la infraestructura: los runners efímeros requieren estado externo si quieres reutilización.

2) Optimización que salió mal: “Cachearlo todo en la imagen para hacer builds más rápidos”

Otra organización tenía una build que ejecutaba pip install y tardaba una eternidad en portátiles de desarrollador. Alguien decidió “resolverlo” copiando todo el caché de pip y artefactos de build en la capa de la imagen tras la instalación. Funcionó—en un sentido estrecho. Las reconstrucciones fueron rápidas en esa máquina.

Luego la imagen empezó a inflarse. También se volvió inconsistente: dos ingenieros producían imágenes diferentes desde el mismo commit porque los directorios de caché contenían ruedas específicas de plataforma y basura de compilación. QA encontró comportamientos de “falla solo en staging” que no se reproducían localmente.

Seguridad se involucró porque el caché incluía artefactos descargados que no estaban rastreados en el lock de dependencias, complicando el análisis de procedencia. Ahora la compañía tenía una build rápida y una respuesta a incidentes lenta. No es un intercambio que quieras.

El rollback fue doloroso. Cambiaron a cache mounts de BuildKit para pip, mantuvieron las imágenes runtime ligeras e introdujeron una política de fijado de dependencias. Las builds siguieron siendo rápidas y los artefactos producidos se volvieron lo suficientemente deterministas para depurar.

3) Práctica aburrida pero correcta que salvó el día: “Claves de caché basadas en lockfiles, no en el estado del repo”

Durante un trimestre muy ocupado, un equipo envió muchos cambios pequeños: ajustes de configuración, ediciones de texto, flags de funciones. Sus builds de servicio eran basadas en Node y pesadas en dependencias; históricamente lentas. Pero su pipeline se mantuvo estable.

La razón no fue heroica. Meses antes, alguien había refactorizado Dockerfiles en los servicios para copiar solo los manifiestos de dependencias primero, ejecutar la instalación y después copiar el código de aplicación. También impusieron una regla: los cambios a lockfiles requieren revisión explícita.

Así que cuando una oleada de cambios no relacionados con el código llegó, la mayoría de builds acertaron en caché la instalación de dependencias y las capas base. El sistema de CI siguió construyendo y probando, pero no volvió a descargarse internet. Su cola de despliegue se mantuvo corta incluso con alto volumen de commits.

Esa práctica no luce impresionante en una demo. No aparece en una diapositiva. Pero evitó un modo de fallo muy predecible: “cambio pequeño, gran coste de build.” En operaciones, lo aburrido suele ser el mayor cumplido.

Errores comunes: síntoma → causa raíz → solución

1) Síntoma: “Cada build vuelve a ejecutar la instalación de dependencias”

Causa raíz: Copias todo el repo antes de instalar dependencias, así cualquier cambio de archivo invalida la capa.

Solución: Copia solo package-lock.json/requirements.txt/go.sum primero, ejecuta install/download y luego copia el resto.

2) Síntoma: “CI siempre es lento, local está bien”

Causa raíz: Los runners de CI son efímeros; la máquina local tiene caché caliente.

Solución: Usa docker buildx build con --cache-to/--cache-from (registro o almacenamiento de artifacts). El caché inline ayuda pero no siempre es suficiente.

3) Síntoma: “La transferencia del contexto de build tarda una eternidad”

Causa raíz: Contexto enorme (node_modules, dist, logs, .git) o archivos inestables incluidos.

Solución: Ajusta .dockerignore. Construye desde un directorio más estrecho. No envíes el universo al daemon.

4) Síntoma: “Hits de caché localmente, misses en CI incluso con caché remoto”

Causa raíz: Diferentes build args/plataformas/targets, o distintos digests de imagen base. Las claves de caché divergen.

Solución: Estandariza build args, la plataforma (--platform) y los targets. Fija imágenes base. Asegura que CI importe la misma referencia de caché que exporta.

5) Síntoma: “La caché funciona hasta que alguien ejecuta un job de limpieza”

Causa raíz: Podas agresivas eliminan la caché de build (docker system prune -a), o los runners reinician el almacenamiento.

Solución: Deja de eliminar caches a ciegas. Usa políticas de pruning dirigidas y confía en caché remoto para CI si los discos de los runners no son persistentes.

6) Síntoma: “La build es lenta aunque todo diga CACHED”

Causa raíz: Estás gastando tiempo en tirar imágenes base, exportar imágenes, comprimir capas o cargar al daemon.

Solución: Mide: busca tiempo en “exporting”, “writing image” y pulls. Considera usar --output type=registry en CI en lugar de --load si no necesitas la imagen localmente.

7) Síntoma: “Misses de caché aleatorios”

Causa raíz: Comandos no deterministas (apt-get update sin orden estable, dependencias sin fijar versiones), timestamps incrustados en salidas de build o archivos generados incluidos tempranamente.

Solución: Haz pasos deterministas cuando sea posible, fija versiones e aísla artefactos generados a etapas posteriores.

8) Síntoma: “Habilitamos inline cache y nada cambió”

Causa raíz: No importaste realmente la caché (--cache-from), o la imagen no está disponible/traída en el builder, o estás construyendo para otra plataforma.

Solución: En CI, importa explícitamente la caché. Verifica en los logs que los pasos están CACHED y que el builder puede alcanzar la referencia.

Listas de verificación / plan paso a paso

Checklist A: Acelerar builds locales (una sola máquina)

  1. Habilita BuildKit y usa --progress=plain para ver el comportamiento de la caché.
  2. Recorta el contexto de build al mínimo con .dockerignore.
  3. Reestructura el Dockerfile: copiar manifiestos → instalar dependencias → copiar código fuente.
  4. Usa cache mounts para gestores de dependencias (npm, pip, go, apt si corresponde).
  5. Mueve args/estampados volátiles al final.
  6. Ejecuta dos builds consecutivos y verifica que los pasos costosos estén CACHED.

Checklist B: Acelerar builds en CI (runners efímeros)

  1. Confirma que CI usa buildx y salida BuildKit (no heredada).
  2. Elige un backend de caché remoto: el caché en registro suele ser lo más simple operacionalmente.
  3. Añade --cache-to y --cache-from a las builds de CI.
  4. Estandariza --platform entre jobs de CI; no mezcles caches amd64 y arm64 a menos que quieras.
  5. Fija imágenes base a etiquetas estables (o digests donde se requiera).
  6. Observa el tiempo gastado en exportar imágenes; prefiere hacer push directo desde buildx en lugar de --load en CI.
  7. Establece una política para retención/pruning de caché en el registro; el crecimiento descontrolado del caché es una falla lenta.

Checklist C: Cuándo vale la pena el trabajo de performance

  • Si tu build es <30 segundos y sucede raramente, no empieces una cruzada de caché.
  • Si tu build bloquea merges, despliegues o mitigaciones on-call, trata la caché como trabajo de fiabilidad.
  • Si la instalación de dependencias se ejecuta cada vez, arregla eso primero; es el ROI más alto en la mayoría de stacks.

Preguntas frecuentes

1) ¿Por qué cambiar un README desencadena una reconstrucción completa?

Porque copiaste todo el repositorio en la imagen antes de los pasos costosos. Las claves de caché incluyen entradas de archivos. Copia menos y más tarde.

2) ¿La caché de BuildKit es lo mismo que la caché de capas de Docker?

La caché de capas es un mecanismo. BuildKit generaliza las builds en un grafo y añade cache mounts, exportación/importación de caché remota y mejor paralelismo.

3) ¿Debería usar siempre --mount=type=cache?

Úsalo para cachés de gestores de dependencias y caches de compilador. No lo uses para ocultar no determinismo ni para “acelerar tests” reusando salidas obsoletas.

4) ¿Cuál es la diferencia entre inline cache y registry cache?

El caché inline guarda metadatos de caché en la propia imagen. El caché en registro exporta la caché como un artifact separado. El caché en registro suele ser más flexible y eficaz para CI.

5) Mi CI usa docker build. ¿Necesito buildx?

Puedes obtener algunos beneficios con docker build si BuildKit está habilitado, pero buildx facilita mucho la administración de caché remota y builds multiplataforma.

6) ¿Por qué desaparecen mis hits de caché después de una limpieza de Docker?

Porque alguien está podando la caché de build o el filesystem del runner es efímero. Arregla la política de limpieza o confía en caché remoto en lugar del estado local.

7) ¿Usar secrets deshabilita la caché?

Los secrets en sí no forman parte de la clave de caché. Pero los comandos que usas con esos secrets pueden ser no deterministas o depender de objetivos móviles, lo que causa reconstrucciones.

8) ¿Puedo compartir caché entre arquitecturas (amd64 y arm64)?

No directamente. Las claves de caché incluyen la plataforma. Puedes almacenar caches para múltiples plataformas bajo la misma referencia, pero son entradas separadas.

9) ¿Por qué es lento exportar la imagen aunque todo esté cacheado?

Porque exportar aún tiene que ensamblar capas, comprimirlas y escribir/empujar. Si CI no necesita una imagen local, empuja directamente y evita --load.

10) ¿Cuándo debería fijar imágenes base por digest?

Cuando la reproducibilidad y el control de la cadena de suministro importan más que la conveniencia. Los digests reducen sorpresas por busts de caché y resultados “mismo Dockerfile, imagen distinta”.

Conclusión: próximos pasos que se pagan solos

Haz esto aburrido. Lo aburrido es rápido.

  1. Ejecuta una build con --progress=plain y anota qué paso es lento y si está CACHED.
  2. Arregla el tamaño del contexto de build primero. Es el impuesto que pagas antes de que la caché tenga voz.
  3. Reestructura Dockerfiles para que las instalaciones de dependencias dependan de lockfiles, no de todo el repo.
  4. Añade cache mounts para gestores de dependencias para dejar de volver a descargar y recompilar.
  5. Si CI es efímero, implementa exportación/importación de caché remota. Si no, estarás optimizando una caché que se evapora al finalizar.
  6. Estandariza build args, plataforma y política de imágenes base para que las claves de caché coincidan entre entornos.

Si haces esas seis cosas, dejarás de “optimizar builds Docker” y empezarás a ejecutar un sistema de builds que se comporta como producción: predecible, medible y rápido por las razones correctas.

← Anterior
WireGuard VPN: Configura tu propio servidor sin abrir agujeros innecesarios
Siguiente →
x86 después de 2026: cinco escenarios futuros (y el más probable)

Deja un comentario