Invalidación de caché en compilaciones Docker: por qué las builds son lentas y cómo acelerarlas

¿Te fue útil?

Todo equipo tiene ese momento: cambias una línea de código de la aplicación y tu “reconstrucción rápida” se convierte en una peregrinación de 12 minutos entre instalaciones de dependencias, actualizaciones de paquetes del SO y una cantidad sospechosa de “Sending build context”.

Rara vez es Docker «lento». Es tu caché siendo invalidada—a veces con razón, a veces por accidente, a veces porque tu sistema CI trata las cachés como secretos embarazosos. Hagamos que las compilaciones sean predictivamente rápidas sin practicar flags al azar por fe.

Tabla de contenidos

El modelo mental: qué es realmente la caché

El caché de compilación de Docker parece magia hasta que lo tratas como un sistema de archivos y un compilador al mismo tiempo. La caché no es «una memoria general de tu build». Es un conjunto de resultados de compilación inmutables direccionados por entradas. Cambias entradas, obtienes una salida distinta. Sin drama. Sin excepciones. Solo lágrimas.

Las capas son resultados direccionados por contenido de las instrucciones

Las compilaciones Docker tradicionales ejecutan tu Dockerfile línea por línea. Cada instrucción produce una instantánea del sistema de archivos («capa») y metadatos. La clave de caché para esa instrucción es efectivamente un digest de:

  • La propia instrucción (incluyendo cadenas exactas, valores de ARG en alcance, variables de entorno en alcance).
  • El digest de la capa padre (lo que vino antes).
  • Para COPY/ADD, el contenido y los metadatos de los archivos copiados al build.

Si alguno de esos cambia, esa instrucción es un cache miss, y todas las instrucciones posteriores también fallarán porque su capa padre cambió. Ese es el efecto de «invalidar el resto del build».

El contexto de build es parte de la superficie de entrada

Cuando ejecutas docker build ., Docker envía un contexto de build al builder. Si tu contexto es enorme, el build puede ser lento incluso antes de empezar a compilar. Además, cuando haces COPY . ., estás diciendo a Docker: «hashéalo prácticamente todo». Un timestamp cambiado o un archivo generado puede envenenar tu caché.

BuildKit cambia el juego, pero no la física

BuildKit es el motor de compilación más nuevo. Añade paralelización, mejor caching, export/import de caché y características como cache mounts y secretos. No convierte mágicamente Dockerfiles malos en buenos. Solo hace que las consecuencias lleguen más rápido.

Idea parafraseada (atribución): Gene Kim suele argumentar que la fiabilidad viene de bucles de retroalimentación y recuperación rápida, no de héroes. El caché de Docker también es un problema de bucles de retroalimentación.

Hechos interesantes y contexto histórico (útil en reuniones)

  1. El motor de build temprano de Docker (el «classic builder») era monohilo y bastante literal; BuildKit introdujo más tarde un grafo de build basado en DAG que puede paralelizar pasos independientes.
  2. El caching de capas precede a Docker; sistemas de ficheros en unión y snapshots copy-on-write se usaban en varias formas mucho antes de que los contenedores fueran tendencia.
  3. RUN --mount=type=cache de BuildKit cambió cómo tratamos los caches de gestores de paquetes: puedes conservarlos sin hornearlos en la imagen final.
  4. Históricamente, muchos sistemas CI ejecutaban builds Docker en modo privilegiado con caches locales; los runners efímeros modernos convirtieron la «reutilización de caché» en una decisión deliberada en lugar de un accidente.
  5. El archivo .dockerignore existe porque la gente seguía enviando gigabytes de basura (como node_modules) al daemon y luego culpaban a Docker.
  6. Los builds multi-stage popularizaron una separación clara entre «dependencias en tiempo de build» y «imagen en tiempo de ejecución», lo que también hace la estrategia de caché más intencional.
  7. Las especificaciones OCI hicieron las imágenes más portables, pero portabilidad no significa que tus cachés te sigan—la localidad del caché sigue siendo una limitación práctica.
  8. Las claves de caché de Docker son sensibles al orden de las instrucciones; un pequeño reordenamiento puede llevarte de «segundos» a «minutos» sin cambiar lo que hace la imagen.
  9. La exportación/importación de caché remota (por ejemplo, vía Buildx) es efectivamente «caché de artefactos de build», similar en espíritu a Bazel o caches de compilador, pero con capas de contenedor como artefactos.

Por qué tienes misses de caché: la mecánica real

1) Cambiaste algo antes de lo que pensabas

La sorpresa más común de builds lentos es cambiar un archivo que se usa en un COPY temprano. Si haces COPY . . antes de instalar dependencias, cualquier cambio en el código fuerza reinstalaciones. No es Docker siendo mezquino. Es tu Dockerfile siendo ingenuo.

2) Tu contexto de build es inestable

Archivos generados. Metadatos de Git. Artefactos locales de build. Archivos swap del editor. Diferencias de checkout en CI. Todo eso puede cambiar el hash de contenido de las entradas de COPY. Si esos archivos están en tu contexto y no están ignorados, forman parte de la clave de caché.

3) Usas instrucciones que «cambian siempre»

RUN apt-get update es una clásica trampa de caching. Aunque Docker intente reutilizar caché, probablemente no quieras reutilizar una capa creada con un índice de paquetes de hace tres semanas. Tienes objetivos contrapuestos: velocidad versus frescura. Elige deliberadamente.

4) Los cambios en ARG/ENV invalidan más de lo que crees

Los valores de ARG en alcance contribuyen a las claves de caché. También lo hacen las configuraciones de ENV para instrucciones posteriores. Si defines ENV BUILD_DATE=... temprano, felicitaciones: invalidaste tu caché en cada build, como está diseñado.

5) Diferentes builders, diferentes cachés

Las cachés locales de la máquina del desarrollador no son las de tu CI. Incluso en CI, distintos runners no comparten cachés a menos que exportes/importes explícitamente. La gente asume que «la caché está en el registro». No. La imagen está. La caché puede que no lo esté.

6) Estás construyendo para múltiples plataformas

Los builds multi-arch (linux/amd64 y linux/arm64) generan capas distintas. La reutilización de caché entre arquitecturas es limitada, y algunos pasos (como compilar dependencias nativas) son inherentemente específicos de la plataforma.

Broma #1: El caching de Docker es como la memoria: increíble cuando funciona, y de alguna manera olvida exactamente lo que necesitabas hace cinco segundos.

Guía rápida de diagnóstico (primeros/segundos/terceros chequeos)

Este es el flujo de «deja de adivinar». Ejecútalo cuando las compilaciones se vuelvan lentas y Slack empiece a oler a pánico.

Primero: identifica dónde se va el tiempo (contexto vs pasos de build)

  • Revisa el tiempo de subida del contexto: si «Sending build context» es lento, tienes un problema de contexto, no de dependencias.
  • Activa progreso en modo plano: lee qué paso es lento y si fue cacheado.

Segundo: confirma que el caché se está usando

  • Busca «CACHED» (BuildKit) o «Using cache» (classic builder).
  • Confirma el builder y la configuración de BuildKit: puede que estés compilando con motores distintos localmente y en CI.
  • Confirma import/export de caché en CI: los runners efímeros empiezan vacíos a menos que les des caché.

Tercero: encuentra el primer cache miss y arregla el orden de capas

  • Encuentra el paso más temprano que falla; todo lo posterior es daño colateral.
  • Busca un COPY . . temprano o ARGs cambiantes.
  • Arregla con una capa de dependencias estable: copia solo los lockfiles primero, instala dependencias y luego copia el resto.

Cuarto: si sigue lento, busca cuellos de botella en almacenamiento y red

  • ¿Las pulls/pushes del registry están limitadas? ¿DNS inestable? ¿Proxy corporativo reescribiendo TLS?
  • ¿El disco del builder está lleno o en almacenamiento lento? Overlay2 en un disco VM pequeño es un hobby caro.

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

Cada tarea abajo tiene: un comando, qué significa una salida típica, y qué decisión tomar a continuación. Ejécútalas en una máquina de desarrollador o en un runner CI (cuando sea posible). Los comandos son intencionalmente aburridos. Aburrido es bueno.

Task 1: Verify BuildKit is enabled

cr0x@server:~$ docker version --format '{{.Server.Version}}'
27.3.1
cr0x@server:~$ echo $DOCKER_BUILDKIT
1

Qué significa: Las versiones modernas de Docker soportan BuildKit; DOCKER_BUILDKIT=1 indica que la CLI lo usará para builds.

Decisión: Si BuildKit no está habilitado, actívalo (localmente y en CI) antes de hacer cualquier otra cosa. Si no, estarás optimizando el motor equivocado.

Task 2: Run a build with plain progress to see cache hits

cr0x@server:~$ docker build --progress=plain -t demo:cache-test .
#1 [internal] load build definition from Dockerfile
#1 DONE 0.0s
#2 [internal] load .dockerignore
#2 DONE 0.0s
#3 [internal] load metadata for docker.io/library/alpine:3.20
#3 DONE 0.6s
#4 [1/6] FROM docker.io/library/alpine:3.20@sha256:...
#4 CACHED
#5 [2/6] RUN apk add --no-cache bash
#5 CACHED
#6 [3/6] COPY . /app
#6 DONE 0.3s
#7 [4/6] RUN make -C /app build
#7 DONE 24.8s

Qué significa: Los pasos marcados como CACHED reutilizaron caché; los que no lo están se ejecutaron. Aquí, COPY no fue cacheado y el paso de build tomó 24.8s.

Decisión: Arregla el primer paso no cacheado que no debería cambiar a menudo (usualmente la instalación de dependencias o la descarga de toolchain).

Task 3: Measure build context size (the silent killer)

cr0x@server:~$ docker build --no-cache --progress=plain -t demo:nocache .
#1 [internal] load build definition from Dockerfile
#1 DONE 0.0s
#2 [internal] load .dockerignore
#2 DONE 0.0s
#3 [internal] load build context
#3 transferring context: 1.42GB 12.1s done
#3 DONE 12.2s

Qué significa: Estás enviando 1.42GB al builder cada vez. Eso no es un build; es una mudanza.

Decisión: Añade/repara .dockerignore. Si no puedes controlar el contexto, ningún truco de caché te salvará.

Task 4: Confirm what is in your build context (quick and dirty)

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

Qué significa: Esto aproxima el tamaño del contexto comprimido. Si es enorme, probablemente incluiste node_modules, salidas de build o directorios vendor.

Decisión: Ajusta .dockerignore y evita COPY . . hasta que hayas separado capas estables.

Task 5: Check Docker disk usage and whether the cache is being evicted

cr0x@server:~$ docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          48        11        19.3GB    12.7GB (65%)
Containers      7         1         412MB     388MB (94%)
Local Volumes   23        8         6.1GB     2.4GB (39%)
Build Cache     214       0         18.9GB    18.9GB (100%)

Qué significa: Existe mucho build cache pero nada está activo; puede estar obsoleto, o tus builds no lo referencian debido a cambios en entradas o builder.

Decisión: Si el disco está limitado, establece una política de caché en vez de eliminar todo periódicamente con docker system prune -a. Si la caché nunca está activa, arregla el orden del Dockerfile o el caching en CI.

Task 6: Inspect builder instances (buildx) and confirm which one you use

cr0x@server:~$ docker buildx ls
NAME/NODE       DRIVER/ENDPOINT             STATUS    BUILDKIT   PLATFORMS
default         docker
  default       default                     running   v0.16.0    linux/amd64,linux/arm64
ci-builder*     docker-container
  ci-builder0   unix:///var/run/docker.sock running   v0.16.0    linux/amd64

Qué significa: Tienes múltiples builders. Cada builder puede tener su propia caché. Si CI usa ci-builder pero tu dev usa default, el comportamiento de caché difiere.

Decisión: Estandariza un builder para CI y documenta su uso. Si necesitas caché remota, prefiere un builder con driver container y export/import de caché explícito.

Task 7: Identify the first cache miss precisely (rebuild twice)

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

Qué significa: Los pasos de dependencias fueron cacheados, pero el paso de tests corrió. Eso es normal si los tests dependen del código.

Decisión: Si npm ci está cacheado, ya ganaste la mayor parte de la batalla. Si no lo está, reordena copias y aísla los lockfiles.

Task 8: Confirm which files invalidate your dependency layer

cr0x@server:~$ git status --porcelain
 M src/app.js
?? dist/bundle.js
?? .DS_Store

Qué significa: Salidas generadas (dist/) y basura del SO (.DS_Store) están en el arbol de trabajo.

Decisión: Ignora artefactos generados en .dockerignore (y probablemente en .gitignore) para que no provoquen misses de caché cuando haces copias amplias.

Task 9: Test that .dockerignore is actually applied

cr0x@server:~$ printf "dist\nnode_modules\n.git\n.DS_Store\n" > .dockerignore
cr0x@server:~$ docker build --progress=plain -t demo:ignore-test .
#3 [internal] load build context
#3 transferring context: 24.7MB 0.3s done
#3 DONE 0.3s

Qué significa: La transferencia del contexto bajó de «dolor» a «aceptable».

Decisión: Mantén .dockerignore revisado como código de producción. Es código de producción.

Task 10: See image layer history to spot cache-busting patterns

cr0x@server:~$ docker history --no-trunc demo:cache-test | head -n 8
IMAGE          CREATED        CREATED BY                                      SIZE
sha256:...     2 minutes ago  RUN /bin/sh -c make -C /app build               312MB
sha256:...     2 minutes ago  COPY . /app                                     18.4MB
sha256:...     10 minutes ago RUN /bin/sh -c apk add --no-cache bash          8.2MB
sha256:...     10 minutes ago FROM alpine:3.20                                7.8MB

Qué significa: Una gran capa RUN make sugiere que estás produciendo artefactos de build dentro de la imagen. Eso está bien para etapas de build, cuestionable para etapas de runtime.

Decisión: Usa multi-stage builds para que las capas grandes de compilación permanezcan en una etapa builder y no contaminen las imágenes de runtime (y las pushes).

Task 11: Validate cache export/import in CI (buildx)

cr0x@server:~$ docker buildx build --progress=plain \
  --cache-from=type=local,src=/tmp/buildkit-cache \
  --cache-to=type=local,dest=/tmp/buildkit-cache,mode=max \
  -t demo:cache-export --load .
#10 [4/8] RUN npm ci
#10 CACHED
#11 exporting cache
#11 DONE 0.8s

Qué significa: Tu build reutilizó un directorio de caché local y exportó actualizaciones de vuelta en él.

Decisión: En CI, persiste ese directorio entre ejecuciones usando el mecanismo de caché de tu CI. Si no puedes persistir disco, exporta a un registry como caché en su lugar.

Task 12: Detect if your build is pulling base images every time

cr0x@server:~$ docker images alpine --digests
REPOSITORY   TAG    DIGEST                                                                    IMAGE ID       CREATED       SIZE
alpine       3.20   sha256:4bcff6...                                                          11f7b3...      3 weeks ago   7.8MB

Qué significa: La imagen base existe localmente con un digest específico.

Decisión: Si CI está repullando capas base constantemente, considera una imagen runner pre-calentada con las bases comunes, o confía en caches del registry más cercanos al runner.

Task 13: Confirm whether --no-cache is being used accidentally

cr0x@server:~$ grep -R --line-number "docker build" .github/workflows 2>/dev/null | head
.github/workflows/ci.yml:42:      run: docker build --no-cache -t org/app:${GITHUB_SHA} .

Qué significa: Alguien forzó builds en frío en CI. A veces es por «frescura». A menudo es superstición.

Decisión: Elimina --no-cache a menos que tengas una razón específica de seguridad/compliance y hayas aceptado el coste.

Task 14: Check for cache-busting build arguments

cr0x@server:~$ grep -nE 'ARG|BUILD_DATE|GIT_SHA|CACHE_BUST' Dockerfile
5:ARG BUILD_DATE
6:ARG GIT_SHA
7:ENV BUILD_DATE=${BUILD_DATE}

Qué significa: Si BUILD_DATE cambia en cada build y se usa temprano, invalidas caché para todo lo posterior.

Decisión: Mueve metadatos volátiles al final, o ponlos como labels solo en la etapa final.

Diseño de Dockerfile que mantiene la caché viva

La caché no es algo que «enciendes». Es algo que te ganas haciendo que las entradas sean estables. El Dockerfile más rápido suele ser el que admite qué cambia con frecuencia y qué no.

Regla 1: Separa la definición de dependencias del código de la aplicación

Si las dependencias se definen mediante lockfiles, cópialos primero e instala dependencias antes de copiar todo el repositorio. Así, los cambios de código no fuerzan reinstalaciones de dependencias.

Patrón malo: copiar todo y luego instalar. Garantiza misses de caché en la instalación de dependencias.

Patrón bueno: copiar solo los manifiestos de dependencias, instalar, y luego copiar el resto.

Regla 2: Mantén args volátiles fuera de capas tempranas

Sí, quieres el Git SHA en la imagen. No, no quieres que destruya el caching de toda la cadena de dependencias. Pon las labels al final, idealmente solo en la etapa final.

Regla 3: Deja de hornear caches dentro de imágenes; móntalos en su lugar

Los cache mounts de BuildKit permiten reutilizar caches de gestores de paquetes sin incluirlos en las capas finales. Aquí es donde BuildKit es realmente transformador.

Regla 4: Usa multi-stage builds como herramienta de caching, no solo para reducir tamaño

Los multi-stage builds te permiten fijar operaciones pesadas y lentas (compiladores, builds de dependencias) en una etapa que cambia raramente, manteniendo la etapa runtime mínima. También mantiene los pushes más pequeños y rápidos, lo cual importa más de lo que la gente admite.

Regla 5: Fija las imágenes base deliberadamente

Si usas tags flotantes como ubuntu:latest, acabarás con churn de caché, actualizaciones sorpresa y arqueología de «funcionaba en mi máquina». Fija a un digest para reproducibilidad cuando importe; fija a un tag menor estable cuando quieras actualizaciones controladas.

Regla 6: Usa .dockerignore en serio

Ignora .git, salidas de build, caches locales y directorios de dependencias que deberían instalarse en imagen. Tu contexto de build debería parecer código fuente, no la historia de vida de tu laptop.

BuildKit: el motor moderno de caché y cómo usarlo

BuildKit es donde las compilaciones Docker dejaron de ser puramente secuenciales «ejecutar instrucciones» y se convirtieron en algo más parecido a un sistema de build. Pero necesitas usar sus características de forma intencional.

Las mejores armas de BuildKit para caching

  • Cache mounts: reutiliza caches de gestores de paquetes sin comprometerlos en capas.
  • Secret mounts: obtiene dependencias privadas sin filtrar tokens en las capas de imagen (también ayuda al caching evitando hacks de «token cambió»).
  • Export/import de caché: hacer los builds en CI rápidos incluso en runners nuevos.
  • Mejor salida de progreso: diagnosticar el comportamiento de caché es menos de adivinanza.

Cache mounts: builds rápidos, imágenes limpias

Docker clásico enseñó a la gente a borrar caches de gestores de paquetes para mantener imágenes pequeñas. Eso está bien para imágenes runtime. Es terrible para la velocidad de build si recompilas con frecuencia. Los cache mounts permiten mantener el cache fuera de las capas de imagen.

Si compilas lenguajes como Go, Rust, Java, Node, Python, puedes cachear descargas de módulos y caches de compilación. Los mounts exactos varían, pero el principio es el mismo: mantiene caches mutables fuera de capas inmutables.

Caché remota: tu runner CI tiene amnesia

Los runners CI efímeros empiezan desde cero. Si tus builds son lentos en CI pero rápidos localmente, suele ser porque tu máquina local tiene la caché caliente y el runner no.

Exportar caché a almacenamiento persistente local es lo más simple. Cuando eso no está disponible, exporta a una caché respaldada por registry. No es gratis: aumenta el tráfico al registry. Pero a menudo es más barato que pagar a ingenieros para que miren barras de progreso.

Broma #2: Lo único más efímero que un runner CI es la confianza de quien acaba de añadir --no-cache «para estar seguro».

Realidades de CI: cachés remotas, runners efímeros y cordura

CI es donde las estrategias de caché van a morir—si no diseñas para ello. La diferencia clave entre builds locales y CI no es la velocidad. Es la persistencia. Los desarrolladores tienen discos persistentes. Los runners CI a menudo no.

Elige una de tres estrategias de caché para CI

  1. Caché persistente local del runner: funciona cuando los runners son de larga vida. Fácil, rápido, pero menos reproducible ante cambios de pool.
  2. Caché de artefactos del CI: almacena el directorio local de BuildKit como artefacto de caché del CI. Funciona bien; depende del tamaño y políticas de evicción del cache del CI.
  3. Caché en registry: exporta/importa caché vía registry. Portable, funciona entre runners, pero aumenta tráfico push/pull y puede estresar registries.

No confundas capas de imagen con capas de caché

Hacer push de una imagen no significa automáticamente que puedas reutilizar su caché en la siguiente ejecución. Parte del caché puede inferirse de imágenes existentes (especialmente si reconstruyes exactamente el mismo Dockerfile y base), pero la reutilización fiable en CI típicamente necesita export/import explícito de caché.

La red es parte del build

Cuando los builds son lentos, la gente culpa al «caché de Docker». Luego miras y ves que el paso lento es descargar dependencias por Internet a través de un proxy que hace inspección TLS y a veces olvida cómo funcionan los certificados.

En esos entornos, mirrors locales y proxies de dependencias no son un lujo. Son infraestructura de build.

Tres micro-historias del mundo corporativo

Micro-historia #1: El incidente causado por una suposición errónea

El equipo tenía un objetivo razonable: mantener las imágenes base actualizadas. Usaban FROM debian:stable y ejecutaban apt-get update && apt-get upgrade -y durante los builds. Alguien preguntó por la caché; la respuesta fue: «Está bien, Docker cachea capas.» Esa suposición entró en producción como si fuera dueña del lugar.

Luego se desplegó un nuevo cluster CI con runners efímeros. De la noche a la mañana, los tiempos de build se dispararon. La pipeline empezó a hacer timeout, los ingenieros reiniciaban jobs y los picos de concurrencia saturaron el repositorio de artefactos y los mirrors de paquetes del SO. Los mirrors limitaron, los builds reintentaron y el bucle de retroalimentación empeoró: builds lentos provocaban más reintentos, lo que hacía los builds aún más lentos.

La raíz del problema no era Docker. Era tratar «stable» como estable y tratar la caché como global. debian:stable avanzó. apt-get update cambió la salida. Y los runners no tenían caché caliente. Cada build fue un arranque en frío más una actualización completa de la distro.

La solución fue poco glamorosa: fijar imágenes base a un digest para la rama de release, dejar de actualizar todo el SO durante el build, y reconstruir imágenes base con un calendario y despliegues controlados. También exportaron la caché de BuildKit a un backend compartido. Los tiempos de build volvieron a ser predecibles, y «predecible» es más importante que «rápido» cuando el reloj de despliegue está en marcha.

Micro-historia #2: La optimización que salió mal

Otra organización intentó acelerar builds colapsando pasos del Dockerfile. Tomaron cinco instrucciones RUN y las fusionaron en un mega-comando para «reducir capas». La imagen se veía más limpia y alguien publicó una captura de docker history como si fuera una transformación de fitness.

La primera semana estuvo bien. Luego cambió una dependencia—un bump de versión. Porque todo estaba en un solo RUN, el cache miss forzó re-ejecutar una larga cadena: paquetes del sistema, instalación del runtime, descargas de dependencias, setup de tooling. La caché tenía menos puntos de entrada, así que era menos reutilizable. Optimizaron por conteo de capas, no por tiempo de reconstrucción.

En CI fue peor. El mega-paso era difícil de diagnosticar. Con pasos más pequeños, los logs hubieran mostrado «descargar toolchain» o «instalar deps» como la parte lenta. Con el mega-paso era simplemente un script de 9 minutos que a veces fallaba por errores de red transitorios. Cuando fallaba, fallaba tarde.

Finalmente revertieron: mantén capas significativas y estables; fusiona solo cuando mejore el caching o la corrección, no la estética. La imagen final siguió siendo delgada usando multi-stage builds, y los builds fueron más rápidos porque la caché tenía más límites reutilizables.

Micro-historia #3: La práctica aburrida pero correcta que salvó el día

Un equipo de plataforma mantenía una plantilla de build «golden path». No era sofisticada. Hacía cumplir higiene de .dockerignore, multi-stage builds y la instalación de dependencias basada en lockfiles. También exigía que los metadatos volátiles (timestamp de build, git SHA) se aplicaran como labels solo en la etapa final.

Cuando una incidencia de la cadena de suministro afectó a la industria y todos empezaron a reconstruir imágenes con frecuencia, las pipelines de este equipo se mantuvieron estables. No fue suerte—porque sus rebuilds eran incrementales. Las imágenes base estaban fijadas y se reconstruían con un cron planificado; los builds de apps reutilizaban capas de dependencias; las cachés CI se exportaban a almacenamiento persistente.

Otros equipos reconstruían desde cero y competían por el ancho de banda para descargar las mismas dependencias. Los builds de este equipo mayormente usaron caché y descargaron solo lo que cambió. Durante la crisis, desplegaron parches de seguridad más rápido y con menos distracciones de «pipeline en rojo».

La práctica que los salvó no fue un flag secreto. Fue tratar el Dockerfile como código de producción y tratar el caching como un sistema ingenieril: entradas, salidas y ciclo de vida. Aburrido. Correcto. Efectivo.

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

1) Síntoma: «Sending build context» tarda una eternidad

Causa raíz: contexto inflado (node_modules, dist, .git), .dockerignore débil, o construir desde la raíz del repo cuando solo se necesita un subdirectorio.

Solución: ajusta .dockerignore, construye desde un directorio más específico, o reestructura el repo para que el contexto Docker sea pequeño y estable.

2) Síntoma: la instalación de dependencias se ejecuta en cada cambio de código

Causa raíz: COPY . . antes de instalar dependencias; lockfiles no aislados; la capa de dependencias depende de todo el árbol de código.

Solución: copia solo los manifiestos de dependencias primero (lockfile, lista de paquetes), instala dependencias y luego copia el código.

3) Síntoma: CI siempre está frío aunque local sea rápido

Causa raíz: runners efímeros sin persistencia de caché; sin export/import de caché BuildKit.

Solución: exporta/importa caché con buildx; persiste el directorio local de caché como artefacto CI; o usa caché en registry.

4) Síntoma: los builds se volvieron lentos tras una «limpieza»

Causa raíz: alguien añadió docker system prune -a en un cron, o redujo la retención de caché, evictando caché constantemente.

Solución: establece presupuestos de disco; limpia selectivamente; conserva la caché de builders para ramas activas; evita prunes completos en runners compartidos.

5) Síntoma: la caché falla cuando solo cambia metadata

Causa raíz: ARG/ENV volátiles (timestamp de build, git SHA) definidos temprano y usados en claves de caché.

Solución: mueve metadatos al final; usa LABEL en la etapa final; no hornees tiempo en capas tempranas.

6) Síntoma: builds multi-arch son dolorosamente lentos

Causa raíz: compilar dependencias nativas para cada plataforma; no hay cachés por plataforma; sobrecarga de emulación QEMU si se cross-build.

Solución: usa builders nativos por arquitectura cuando sea posible; exporta cachés separados por plataforma; reduce compilación nativa dentro del Docker build cuando proceda.

7) Síntoma: los pushes de imagen son lentos aunque los builds sean rápidos

Causa raíz: la etapa runtime incluye artefactos de build; capas grandes cambian con frecuencia; uso incorrecto de multi-stage.

Solución: mantén la imagen runtime mínima; copia solo los outputs construidos; asegura que caches y tooling de build queden en la etapa builder.

8) Síntoma: misterio «estaba cacheado ayer»

Causa raíz: instancia builder distinta, versión Docker cambiada, digest de base cambiado, o contexto cambiado por archivos generados.

Solución: estandariza el builder; fija bases; verifica .dockerignore; revisa qué archivos cambiaron; evita tags flotantes en rutas críticas.

Listas de verificación / plan paso a paso

Checklist A: Arregla un build lento hoy (30–90 minutos)

  1. Re-ejecuta el build con --progress=plain e identifica el primer paso lento no cacheado.
  2. Mide el tamaño de transferencia del contexto; si es >100MB, trátalo como un bug.
  3. Añade/repara .dockerignore para excluir: .git, node_modules, dist, target, build, basura de editores, artefactos de test.
  4. Refactoriza el Dockerfile para que los manifiestos de dependencias se copien primero; instala dependencias; luego copia el código.
  5. Mueve args/labels volátiles a la etapa final y lo más tarde posible.
  6. Reconstruye dos veces; la segunda ejecución debería ser mucho más rápida y mostrar CACHED para los pasos pesados.

Checklist B: Hacer real el caching en CI (medio día)

  1. Confirma que CI usa BuildKit y un builder consistente (docker buildx).
  2. Elige estrategia de persistencia de caché: artefacto CI o caché en registry.
  3. Añade --cache-from y --cache-to en los pasos de build del CI.
  4. Asegura que las claves de caché incluyan estrategia por rama o mainline (para evitar envenenar caché entre cambios incompatibles).
  5. Establece políticas de retención/evicción para que la caché no se borre diariamente.
  6. Añade una métrica de pipeline: desglose de duración del build (tiempo de contexto vs tiempo de build vs tiempo de push).

Checklist C: Mantenerlo rápido meses a meses (disciplina operativa)

  1. Revisa cambios de Dockerfile como revisas config de producción: diff buscando riesgo de invalidación de caché.
  2. Fija imágenes base para ramas de release; actualízalas en calendario.
  3. Mantén una plantilla estándar de .dockerignore por ecosistema de lenguaje.
  4. Audita periódicamente uso de disco del builder; prune con intención, no con ira.
  5. Ejecuta builds en un entorno controlado: versiones estables de Docker/BuildKit en la flota de CI.

Preguntas frecuentes

1) ¿Por qué al cambiar un archivo se reconstruye todo lo posterior a cierto paso?

Porque el primer cache miss cambia el digest de la capa padre. Cada paso posterior depende de ese digest, así que todos fallan también. Arregla el primer miss reordenando y estrechando las entradas de COPY.

2) ¿Afecta .dockerignore solo al tamaño del contexto o también al caching?

Ambas cosas. Reduce el tiempo de transferencia y reduce el conjunto de archivos que pueden invalidar pasos COPY. Menos entropía de entrada significa más aciertos de caché.

3) ¿Es malo usar apt-get update en Dockerfiles?

No, pero a menudo se usa mal. Combina update e install en la misma capa, y no esperes caching estable si quieres índices frescos. Para reproducibilidad, fija paquetes o parte de imágenes base curadas.

4) ¿Por qué mi build local es rápido pero CI es lento?

Tu laptop tiene un caché de builder persistente. Los runners CI normalmente no. Exporta/importa caché BuildKit o proporciona almacenamiento persistente para el builder.

5) ¿Los multi-stage builds hacen los builds más rápidos?

Pueden. Los multi-stage builds permiten cachear pasos costosos en una etapa dedicada y mantener imágenes runtime pequeñas. Imágenes runtime más pequeñas también hacen pushes/pulls más rápidos, lo que a menudo domina el tiempo en CI.

6) ¿Debería fusionar pasos RUN para reducir capas?

Sólo cuando mejore la corrección o el caching. Menos capas pueden significar menos puntos de reutilización de caché, lo que puede hacer las reconstrucciones más lentas. Optimiza para tiempo de rebuild y debuggabilidad, no estética.

7) ¿Cuál es la diferencia entre una imagen y una caché?

Una imagen es un artefacto ejecutable que empujas y despliegas. Una caché son metadatos de build y resultados intermedios usados para acelerar builds futuros. Se solapan a veces, pero confiar en ese solapamiento es poco fiable en CI.

8) ¿Cambiar la etiqueta de la imagen base invalida todas las cachés?

Si el digest resuelto cambia, sí: la capa padre cambia, por lo que todo lo downstream falla. Fija digests para estabilidad cuando necesites caché y despliegues predecibles.

9) ¿Los cache mounts son seguros? ¿Se filtrarán en la imagen final?

Los cache mounts no se comprometen en las capas de imagen por defecto. Ese es el punto. El caché vive en el host builder (o en la caché exportada), no en la instantánea del sistema de archivos runtime.

10) ¿Cuál es la solución de mayor ROI para builds Docker lentos?

Deja de copiar el repo entero antes de instalar dependencias. Aísla lockfiles, instala dependencias en una capa estable, luego copia el código. Todo lo demás es secundario.

Siguientes pasos que realmente mueven la aguja

Si quieres builds más rápidos, deja de tratar el caching de Docker como vibras y empieza a tratarlo como hashing de entradas.

  1. Ejecuta un build con --progress=plain y anota el primer cache miss caro.
  2. Mide el tamaño de tu contexto. Si es grande, arregla .dockerignore primero. Siempre.
  3. Refactoriza tu Dockerfile para que la instalación de dependencias dependa solo de lockfiles, no de todo el árbol de código.
  4. Habilita BuildKit en todas partes, luego añade export/import de caché para CI para que los runners fríos dejen de arruinarte el día.
  5. Mueve metadatos volátiles al final y mantén imágenes runtime pequeñas con multi-stage builds.

Haz esas cinco cosas y tus builds no solo serán más rápidos: serán predecibles. Los builds predecibles son cómo entregas a tiempo sin sobornar a los dioses de la pipeline.

← Anterior
Docker Compose: depends_on te mintió — Preparación correcta sin trucos
Siguiente →
ZFS vs btrfs: Dónde btrfs resulta agradable y dónde duele

Deja un comentario