Lunes por la mañana, café en mano, abro el reporte de salud del clúster y dice DEGRADED. Un pod, el bot de Telegram que uso para mis propias finanzas, llevaba ocho horas en ImagePullBackOff. En el mismo nodo, metallb-speaker se había reiniciado 13 veces y node-exporter 12 veces durante la noche.

Dos problemas que parecían uno solo. No lo eran.

El error que era demasiado limpio

El fallo del pull en containerd decía esto:

failed size validation: 722944794 != 361472397

Me quedé mirándolo un minuto antes de ver lo que importaba. 722.944.794 es exactamente dos veces 361.472.397. No aproximadamente. Exactamente.

Un blob corrupto es basura aleatoria. Un blob que mide exactamente el doble no es corrupción. Algo lo escribió dos veces.

Mi primera teoría fue el registry. Yo corro Nexus dentro del clúster como mi registry de Docker, y esa noche su chart de Helm se había actualizado con un reinicio a las 03:17. Un reinicio en mitad de un push podría dejar un blob a medio escribir. Sonaba lógico. Estaba equivocado.

Lo comprobé preguntándole a Nexus directamente:

curl -I https://nexus/v2/<imagen>/blobs/sha256:<digest>

El Content-Length volvió en 722.944.794. El manifest de la misma imagen decía que esa capa pesaba 361.472.397. O sea que el registry no estaba devolviendo basura. Había guardado un blob del doble de lo que prometía el manifest, y lo hizo en dos builds consecutivos, las ejecuciones #71 y #72. Un reinicio no hace eso dos veces.

Lo que decía el request log

Nexus guarda un request.log. Busqué el digest del blob y encontré dos 201 PUT para el mismo blob, con 70 segundos de diferencia.

Eso apuntaba de vuelta al job de CI, no al registry. El paso de build de ese repo se veía así:

docker buildx build \
  --cache-from type=registry,ref=${CACHE_REF} \
  --cache-to type=registry,ref=${CACHE_REF},mode=max \
  -t "${IMAGE_TAG}" \
  --push \
  .

Una sola invocación de buildx que hace push de la imagen y exporta el cache de build al registry al mismo tiempo. El push de la imagen y la exportación del cache comparten blobs de capas. BuildKit los sube en paralelo. Dos subidas del mismo blob llegaron a Nexus traslapadas en el tiempo, y Nexus las guardó concatenadas.

Ese bloque exacto lo había copiado yo en todos los repos que tengo. Llevaba meses funcionando. Solo hace falta un traslape con mala suerte.

Por qué relanzar el job no sirve

Mi instinto fue volver a correr el build. Eso lo empeoró, de una forma silenciosa. BuildKit hace un HEAD a cada blob antes de subirlo. El blob existía, así que BuildKit se lo saltó, y el blob roto de 722 MB se quedó exactamente donde estaba. La ejecución #72 falló igual que la #71.

Me tocó limpiar Nexus a mano por su API REST. Borrar el componente etiquetado, borrar los manifests sin tag, encontrar el asset del blob paginando /assets del repositorio porque la búsqueda por sha256 no encuentra blobs de capas, borrarlo, y correr la tarea de garbage collection de Docker.

Y una cosa más que casi se me pasa. El tag buildcache todavía referenciaba la capa que acababa de borrar. Con --cache-from apuntando ahí, el siguiente build intentó copiar una capa que ya no existía y falló con un error de “could not fetch content descriptor”. Borré también los tags viejos buildcache y latest.

El arreglo

Partir el build en dos invocaciones. Primero hacer push de la imagen solo con importación de cache. Después exportar el cache en una segunda llamada, cuando el push ya terminó. Los blobs que ya existen se saltan por el chequeo HEAD, así que la segunda llamada sube únicamente la metadata de cache que le falta.

docker buildx build \
  --cache-from type=registry,ref=${CACHE_REF} \
  -t "${IMAGE_TAG}" \
  --push \
  .

docker buildx build \
  --cache-from type=registry,ref=${CACHE_REF} \
  --cache-to type=registry,ref=${CACHE_REF},mode=max \
  .

El tag nuevo, 0.20261005.132702, salió con las 21 capas cuadrando con sus tamaños del manifest. Flux lo desplegó. Cero alertas.

Luego salí a buscar el mismo patrón en todo lo demás. Revisé 28 archivos de workflow en 21 repos. Diez tenían --push y --cache-to type=registry en una sola invocación, nueve en forma de shell y uno vía build-push-action. Un PR por repo, todos mergeados el mismo día. El workflow de release de este mismo sitio era uno de ellos.

El segundo problema

Los reinicios en ese nodo eran otra historia. Sus liveness probes contra localhost estaban dando timeout, y eso es falta de CPU, no un tema de red.

El nodo, talos-wmv-ufg, es una VM de 2 vCPU con el 85% de su CPU ya solicitado, promediando 50% y con picos del 97%. Vive en un host Proxmox con un Celeron N5105 de 4 núcleos que está completamente repartido entre dos VMs, al 94% de RAM y ya usando swap en zram. Y ahí era donde vivía mi runner de Gitea Actions. Un runner docker-in-docker con 2 CPUs de límite, corriendo cada build de imagen que yo disparo, en el nodo más flojo que tengo.

Moví el runner a los nodos etiquetados workload/heavy con un nodeSelector. El nodo bajó a más o menos 23% de CPU.

Esa misma tarde escalé el runner a dos réplicas con anti-affinity para que los builds no se peleen en un solo host. Eso me enseñó dos cosas. Escalar un StatefulSet reutiliza los PVCs viejos, y un archivo .runner viejo en uno de ellos hizo que el pod arrancara como “unregistered” en un crash loop hasta que lo moví a un lado. Y ojo, nunca mergear un cambio del runner mientras hay CI corriendo. El rollout mató un job de configuración de Talos a mitad de camino.

Con qué me quedo

Cuando containerd dice que un tamaño no cuadra, haz la cuenta. Si la proporción es un número entero, el registry guardó algo dos veces y el nodo es inocente. Revisa el blob con un HEAD antes de tocar nada.

Y cuando copias un bloque de CI en diez repos, copias su bug en diez repos. Me tomó un lunes de mala suerte encontrarlo, y una tarde arreglarlo en todas partes.