← blogEShome

AWS · Troubleshooting · 2026-09-03 · 5 min read

A 599 MiB image, 91 minutes, and the region next door

A deploy on a client's on-prem Kubernetes cluster was stuck in ImagePullBackOff. The image — a 599 MiB Node.js build hosted in Amazon ECR — eventually pulled. It took 1 hour, 31 minutes and 30 seconds.

The pattern

Sustained TCP transfers from the datacenter's egress IP to ECR in us-east-2 were dying mid-flight after transferring between 1.3 and 3.1 MB, intermittently, with a failure rate between 40% and 83%. Reproducible from three separate internal network segments, on a server with no container runtime at all, with bare curl. The only common factor was the public egress IP.

The region gradient was the interesting part: us-east-2 failed 40–83% of the time, us-west-2 around 20%, and us-east-1 never failed. That shape rules out a policy targeting "AWS" as a whole, and reproduction from independent internal segments rules out a per-segment firewall rule. Everything pointed at transit degradation toward that specific region's prefixes — somewhere between the datacenter's edge and the carrier. In other words: a root cause we didn't own and couldn't fix.

Why big images are disproportionately punished

The cutoff sits around 2.5 MB per TCP session, and container images aren't transferred as one stream — they're transferred per layer. Layers under the threshold almost never fail. The 599 MiB image had 4 layers above it (223, 197, 149 and 29 MiB).

With roughly a 60% chance of a large layer completing on a given attempt, the probability of all four passing in one round is about 0.6⁴ ≈ 13%. containerd retries per layer, so the pull converges eventually — but each failure wastes up to 223 MiB of transfer and the backoff accumulates. That's the whole 91 minutes, no exotic failure mode required.

It also explains why the app team's Dockerfile fix — which cut the image from 599 to 279 MiB, a 53% reduction — didn't solve anything: it still left the same four layers above the threshold. Shrinking the total reduces the cost of each failure, not the number of dice rolls. What matters here isn't image size; it's the layer size distribution.

Can't fix the path? Move the endpoint

Since us-east-1 was clean, the workaround was to serve the image from there: ECR cross-region replication plus seeding. Each step had a trap:

The measurement traps

Two things nearly produced false conclusions, and they're worth more than the fix itself:

containerd's cache makes cross-region pull tests lie. Because digests are preserved, a node that already has the layers from region A "pulls" from region B in under a second, transferring nothing. An early test reported 334 MB in 796 ms — 420 MB/s, physically impossible over that uplink. Before measuring: remove the image, confirm via the kubelet's .status.images that the node is actually cold, then pull. We forced scheduling onto the one node that had never held the image.

A 401 from the registry is invisible to CloudTrail. The failed us-east-1 pull never showed up as an API event — because an unauthenticated request to the registry's /v2/ endpoint never becomes an authenticated AWS API call. During diagnosis, "CloudTrail is empty" was briefly read as "nobody tried to pull", when it's exactly what a credential failure predicts. Know which failures your telemetry cannot see.

Bonus trap: iam simulate-principal-policy returned implicitDeny for every action on both principals — including actions explicitly allowed on Resource: "*". The tell was MatchedStatements: []: the simulator had loaded no policies at all (insufficient read permissions) and reports that as a deny rather than an error. It contradicted CloudTrail, which showed both identities actively pulling and pushing. When a simulator and reality disagree, believe reality.

Result

Same image, same digest, same node, real cold pull:

us-east-2:  1h 31m 30s   (~3.9 MB/s, 40–83% of sessions cut, many retries)
us-east-1:  1m 22s       (7.6 MB/s, zero retries)

67× faster. With the honest caveat that a single clean pull is n=1 — us-east-2 completed 6 of 10 attempts in the original measurements, so one good pull from there is possible too. But 82 seconds against 5,490 isn't variance; it's the difference between a clean pass and a pile of backoffs. A four-cell interleaved measurement harness (2 tags × 2 regions, classified by exit code) was left behind for anyone who wants confidence intervals.

And it's a workaround, not a fix: the CI runners still push through the degraded path, and the actual escalation — to the network team and the carrier that owns the egress prefix — is where the root cause lives.

Takeaways

ECRcontainerdKubernetesAWSnetworking