AWS · Troubleshooting · 2026-09-03 · 6 min de lectura
Una imagen de 599 MiB, 91 minutos y la región de al lado
Un deploy en el cluster Kubernetes on-premise de un cliente estaba clavado en ImagePullBackOff. La imagen — un build de Node.js de 599 MiB alojado en Amazon ECR — eventualmente bajó. Tardó 1 hora, 31 minutos y 30 segundos.
El patrón
Las transferencias TCP sostenidas desde la IP de egreso del datacenter hacia ECR en us-east-2 morían en pleno vuelo tras transferir entre 1,3 y 3,1 MB, de forma intermitente, con una tasa de fallo de entre 40% y 83%. Reproducible desde tres segmentos de red internos distintos, en un servidor sin runtime de contenedores, con curl a secas. El único factor común era la IP pública de salida.
El gradiente por región era la parte interesante: us-east-2 fallaba el 40–83% de las veces, us-west-2 alrededor del 20%, y us-east-1 no fallaba nunca. Esa forma descarta una política que apunte a "AWS" como conjunto, y la reproducción desde segmentos internos independientes descarta una regla de firewall por segmento. Todo apuntaba a degradación de tránsito hacia los prefijos de esa región específica — en algún punto entre el borde del datacenter y el carrier. En otras palabras: una causa raíz que no era nuestra y no podíamos arreglar.
Por qué las imágenes grandes sufren desproporcionadamente
El corte ronda los 2,5 MB por sesión TCP, y las imágenes de contenedor no se transfieren como un solo stream — se transfieren por capa. Las capas por debajo del umbral casi nunca fallan. La imagen de 599 MiB tenía 4 capas por encima (223, 197, 149 y 29 MiB).
Con aproximadamente un 60% de probabilidad de que una capa grande complete en un intento dado, la probabilidad de que las cuatro pasen en una sola ronda es de 0,6⁴ ≈ 13%. containerd reintenta por capa, así que el pull converge eventualmente — pero cada fallo desperdicia hasta 223 MiB de transferencia y el backoff se acumula. Eso son los 91 minutos completos, sin necesidad de ningún modo de falla exótico.
También explica por qué el fix de Dockerfile del equipo de la app — que bajó la imagen de 599 a 279 MiB, un 53% — no resolvió nada: dejó las mismas cuatro capas por encima del umbral. Achicar el total reduce el costo de cada fallo, no la cantidad de tiradas de dado. Lo que importa acá no es el tamaño de la imagen; es la distribución de tamaños de capa.
¿No podés arreglar el camino? Mové el endpoint
Como us-east-1 estaba limpia, el workaround fue servir la imagen desde ahí: replicación cross-region de ECR más el sembrado. Cada paso tenía su trampa:
- La replicación no hace backfill. Solo copia imágenes pusheadas después de que existe la regla (docs de AWS), así que los tags existentes hubo que sembrarlos a mano.
- Sembrar con
crane cp, no condocker pull/push. crane copia los blobs tal cual y preserva los digests; el daemon de Docker recomprime capas y puede cambiarlos, lo que habría roto la premisa de "mismo contenido, otra región" de todo el test. - Scopear el filtro de replicación al nombre completo del repo.
PREFIX_MATCHes un prefijo: un filtro más corto se habría llevado silenciosamente dos repositorios hermanos. - Los tokens de auth de ECR son por host de registry. El pull secret del cluster solo tenía la entrada del registry de us-east-2, así que el primer pull desde us-east-1 falló con
no basic auth credentials. El kubelet mergea losauthsde todos los secrets enimagePullSecrets, así que un segundo secret específico de región en el único deployment afectado lo resolvió — sin tocar el secret cluster-wide que un CronJob distribuye a ~60 namespaces. En un incidente, el blast radius importa más que la elegancia.
Las trampas de medición
Dos cosas casi producen conclusiones falsas, y valen más que el fix en sí:
El caché de containerd hace mentir a cualquier test de pull entre regiones. Como los digests se preservan, un nodo que ya tiene las capas de la región A "pullea" desde la región B en menos de un segundo, sin transferir nada. Un test temprano reportó 334 MB en 796 ms — 420 MB/s, físicamente imposible por ese enlace. Antes de medir: borrar la imagen, confirmar vía .status.images del kubelet que el nodo está realmente frío, y recién ahí pullear. Forzamos el scheduling al único nodo que nunca había tenido la imagen.
Un 401 del registry es invisible para CloudTrail. El pull fallido de us-east-1 nunca apareció como evento de API — porque un request no autenticado al endpoint /v2/ del registry nunca llega a ser una llamada autenticada a la API de AWS. Durante el diagnóstico, "CloudTrail está vacío" se leyó por un momento como "nadie intentó pullear", cuando es exactamente lo que predice un fallo de credencial. Hay que saber qué fallas tu telemetría no puede ver.
Trampa extra: iam simulate-principal-policy devolvió implicitDeny para todas las acciones de los dos principals — incluidas acciones explícitamente permitidas sobre Resource: "*". La pista era MatchedStatements: []: el simulador no había cargado ninguna policy (permisos de lectura insuficientes) y reporta eso como deny en vez de error. Lo contradecía CloudTrail, que mostraba a las dos identidades pulleando y pusheando activamente. Cuando un simulador y la realidad no coinciden, creele a la realidad.
Resultado
Misma imagen, mismo digest, mismo nodo, cold pull real:
us-east-2: 1h 31m 30s (~3,9 MB/s, 40–83% de sesiones cortadas, muchos reintentos)
us-east-1: 1m 22s (7,6 MB/s, cero reintentos)
67× más rápido. Con la salvedad honesta de que un pull limpio es n=1 — us-east-2 completaba 6 de 10 intentos en las mediciones originales, así que un pull bueno desde ahí también es posible. Pero 82 segundos contra 5.490 no es varianza; es la diferencia entre un pase limpio y una pila de backoffs. Quedó un arnés de medición de cuatro celdas (2 tags × 2 regiones, intercaladas, clasificando por código de salida) para quien quiera intervalos de confianza.
Y es un workaround, no un fix: los runners de CI siguen pusheando por el camino degradado, y la escalación real — al equipo de redes y al carrier dueño del prefijo de egreso — es donde vive la causa raíz.
Lecciones
- 1. La distribución de tamaños de capa le gana al tamaño total. Una imagen es tan frágil como su capa más grande. Multi-stage builds y partir los
COPYgordos hacen más por la confiabilidad del pull que limar MiB del total. - 2. Cuando la causa raíz no es tuya, cambiá lo que sí es tuyo. No podíamos arreglar el tránsito de un carrier; sí podíamos servir los bytes desde una región cuyo camino funcionaba.
- 3. Matá el caché antes de medir. Las copias que preservan digests hacen que un cache hit sea indistinguible de un throughput milagroso.
- 4. Mapeá los puntos ciegos de tu telemetría. Los 401 del registry no llegan a CloudTrail; un simulador de policies roto reporta denies. Los dos te mandan a cazar fantasmas.
- 5. Bajo presión de incidente, preferí el menor blast radius. Un secret extra en un namespace le ganó a editar el secret del que dependen 60.