ECR images not pulled in 90 days, priced as a share of the repository bill
What does ZopNight detect here?
ZopNight flags ECR repositories holding images whose `lastRecordedPullTime`, or push time if never pulled, is more than 90 days old. The saving is up to the repository's measured cost multiplied by the stale images' share of total bytes, capped at the full cost, and it skips repositories already governed by a `sinceImagePulled` lifecycle rule.
Signal and threshold
| Field | Value |
|---|---|
| Rule IDs | RC-022 |
| Category | orphan |
| Severity | low |
| Metric | none — pure configuration read |
| Threshold | last pull (or push) older than 90 days |
| Evaluation window | 90d |
| Source | ZopNight |
| Permissions used | ecr:DescribeRepositories · ecr:DescribeImages · ecr:GetLifecyclePolicy |
Where it applies
Old images pay the same storage rate as live ones
Every image pushed to ECR stays in private repository storage until something deletes it. CI pipelines push a new image per build, so a busy repository can hold thousands of images of which a handful are ever pulled again. Tag status is a poor guide: an untagged image may be the live child of a multi-architecture index, while a tagged release from two years ago may never be pulled again.
Listing images by last pull
describe-images returns lastRecordedPullTime, which the
ImageDetail reference
says ECR refreshes at least once every 24 hours:
aws ecr describe-images --repository-name my-repo \ --query 'imageDetails[].[imageDigest,imageTags[0],imagePushedAt,lastRecordedPullTime,imageSizeInBytes]' \ --output tableImages whose last pull, or push if there is no pull, is more than 90 days old are candidates. Read
the metadata only. Calling batch-get-image to inspect manifests is recorded as a pull and resets
lastRecordedPullTime, a behaviour tracked in the public
containers roadmap.
How the reclaimable share is built
ZopNight adds up the size of every image not pulled in over 90 days, counting a never-pulled image only once it was pushed more than 90 days ago. Two safety filters apply. An image index is never offered, because deleting one frees only the manifest while AWS reports its size as the largest manifest in the list. In a repository that contains an index, untagged images are not offered, since they may belong to a live parent. The result is always an upper bound: layers shared between images are counted once per image.
When the check holds back
If the repository has a lifecycle rule using sinceImagePulled, ECR is already scheduled to
expire these images, so there is no finding. A policy that expires only by push age does not count.
If the whole repository shows zero pulls for 90 days,
ECR Repository Never Pulled
takes it instead. No finding is raised when the stale bytes, the repository’s total size or its
cost are unknown.
Pricing the stale share
saving = repository monthly cost x (stale image bytes / total repository bytes), capped at 1cost after fix = repository cost - savingUsing the repository’s own measured spend keeps the figure right under private pricing and in any Region, and every figure is shown as “up to”.
Pruning images by pull age
- Review the listed images and keep anything held for rollback or disaster recovery.
- Delete the rest by digest:
aws ecr batch-delete-image --repository-name my-repo --image-ids imageDigest=sha256:.... - Add a lifecycle rule with the
sinceImagePulledcount type so ECR expires cold images on its own; the lifecycle policy parameters list it alongsidesinceImagePushed.