Skip to main content
orphan · aws

ECR Unused Images

resource types
1
rule IDs covered
1
severity
low

What does ZopNight detect here?

ECR images not pulled (or, if never pulled, not pushed) within the 90-day horizon are dead storage regardless of tag status. ZopNight sums their bytes as an upper bound, prices savings as the repository's cost times the prunable share capped at 1, and stands down where a pull-age lifecycle policy or RC-023 already covers the repository.

Signal and threshold

How ZopNight evaluates ECR Unused Images.
Field Value
Rule IDsRC-022
Categoryorphan
Severitylow
Metricnone — pure configuration read
Sourceecr_lifecycle.go

Pull time as the signal, not tag status

Tag status is not a usage signal: an untagged image can be in use (multi-arch child), and a tagged image nobody pulls is dead cost. So the reclaimable set is keyed on LastRecordedPullTime, not on being untagged. The rule does not gate on lifecycle_policy_present: a native ECR lifecycle policy prunes by tag + age, not pull activity, so a repo with a policy can still hold unused images; and the remediation is deleting the unused images, not adding a policy.

  • prunable_image_bytes (Metadata float): sum of ImageSizeInBytes for images unused by pull: last pulled (or, if never pulled, pushed) longer ago than the 90d ecrUnusedHorizon. Tag status is not the signal. Never-pulled images count only once pushed > 90d ago (so a freshly pushed image isn’t flagged before it has had a chance to be pulled). Always an upper bound: ECR is content-addressed, so this sum counts a shared layer once per referencing image, measured at a 1.09x median and 24.35x worst case against the true reclaim on one estate. Every string RC-022 renders from it says “up to”.
  • prunable_refs_resolved (Metadata bool): a strict trigger, stamped true alongside the bytes. RC-022 requires it, so it abstains on data from a producer that predates it rather than over-count. > Producer reads metadata only: ecr:DescribeImages and nothing else. Manifest-list children used to be resolved with ecr:BatchGetImage; that call retrieves a manifest, which ECR records as a pull under lastRecordedPullTime, the exact field this rule reads. Scanning every image stamped every image as freshly pulled, so the next pass found nothing past the horizon and RC-022 went from 77 recommendations on one org to zero fleet-wide the day it shipped (v1.33.9, #2494). It also overwrote real customer pull history, which AWS gives no way to restore. The call fetches the manifest only, no layer bytes, and ECR counts it as a pull regardless; AWS has acknowledged this since 2024 (containers-roadmap#2390). The grant has been removed from docs/permissions/zopnight/aws.json, along with ecr:BatchCheckLayerAvailability. If manifest reads are ever needed again they must be paired with ECR’s own opt-out: pull-time update exclusions, which exempt a registered IAM principal ARN from pull-time updates (ecr:RegisterPullTimeUpdateExclusion; the user guide’s Create/Delete action names do not exist, so take them from AWS’s machine-readable service reference); that is a registry-level setting in the customer’s account, per region, so it is an onboarding grant plus a setup step and fixes nothing retroactively. > > Two fail-closed exclusions replace resolution, both decided from imageManifestMediaType + imageTags: an index is never offered (deleting one frees the manifest document only, while AWS reports its size as “the max size of all manifests in the list”), and in a repository holding an index an untagged image is not offered (it may be a child of a live parent). A tagged image nobody pulls is still offered, since tag status is a safety filter where an index exists and never the signal. The cost is under-claiming on multi-arch repositories.
  • Already self-managing. AWS shipped a sinceImagePulled lifecycle countType (containers-roadmap#921), so a policy can now expire images by exactly the signal RC-022 fires on. Where one exists, ECR is already scheduled to delete those images, and to do it more safely than our advice can, because its engine skips manifest-list children (failureCode: ImageReferencedByManifestList) which the producer cannot identify at all. RC-022 abstains on lifecycle_prunes_by_pull_age (Metadata bool, stamped by ecrPolicyExpiresByPullAge). It deliberately does not gate on lifecycle_policy_present: a policy that only expires untagged images by push age says nothing about pull activity, and abstaining on mere presence would silence the rule across most of the fleet. An absent key means the policy could not be read (throttling, AccessDenied), which is unknown rather than unmanaged, so it does not abstain.
  • Superseded by RC-023. If RepositoryPullCount shows zero pulls across a full 90-day window then every image is cold, and RC-022 would price ~the whole repository as a prunable share while RC-023 prices that same repository for deletion: the same dollars twice. RC-023 wins: the whole bill is the more accurate figure, and its remediation removes the cost, where pruning images out of a repository nobody pulls leaves the empty repository behind. An absent metric is a failed fetch and does not suppress; a series shorter than the horizon cannot suppress either.
  • prunable_image_count (Metadata int) + last_image_pull_time (Metadata unix seconds, absent = never pulled): usage evidence rendered in the “why” text: “N image(s) (X GB) not pulled in over 90 days … last pulled YYYY-MM-DD / with no recorded pull”.
  • cost basis (r.pricing[uid] > 0) The same four facts, plus the repository name, are also emitted as structured NonMetricEvidence (kind: “property”): Repository, Images not pulled in 90+ days (omitted when the count is absent, meaning unknown rather than zero), Reclaimable storage, Last pulled (No recorded pull when the timestamp is absent), Manifest references. lifecycle_policy_present is excluded by design: the rule does not gate on it, so surfacing it as “why this fired” would assert a signal the rule refused to use. Built at rec-construction only, below every abstain gate; see the 2026-07-28 entry at the top of this document.

The reclaimable share of repository storage spend

savings = cost × (prunable_image_bytes / total_bytes), the reclaimable share of the repository’s own measured spend. ECR storage bills linearly in GB-month, so deleting that share removes exactly that fraction of the bill, and the figure stays correct under private pricing, non-commercial regions and any AWS rate change. The share is capped at 1, so the saving can never exceed the cost by construction. Abstains when prunable_image_bytes is absent, when prunable_refs_resolved is not true, when pricing is absent, or when the repository’s total size is unknown. Without a denominator there is no proportion, and falling back to a list rate would be the fabricated figure this rule exists to avoid (orphan is exempt from the central $5 floor, so a $0 would otherwise survive). The invariant holds: SavingsUSD > 0, OptimizedCostUSD = cost − savings ∈ [0, cost), so cost never increases.

Deleting the cold images

  1. Review the images not pulled in 90+ days (verify none are kept for rollback / DR)
  2. Delete the unused images: aws ecr batch-delete-image or the ECR console
  3. Consider automation to expire images by pull age going forward

Repository pull count, read only to stand down

RepositoryPullCount, the one usage series ECR publishes: read only to stand down when RC-023 supersedes (see below); never used to fire

See it fire on your bill.

Connect an account read-only. The first findings land in minutes.

417 rule families across 353 resource types on 22 platforms. Every threshold, metric, and IAM action is documented on these pages before you grant anything.

417 rule families documented
353 resource types covered
read-only default access level
Multi-cloud automation· Production-ready in 30 min· SOC 2 · ISO 27001· 20–60% off the bill, first month· 4 platforms · 1 console· Multi-cloud automation· Production-ready in 30 min· SOC 2 · ISO 27001· 20–60% off the bill, first month· 4 platforms · 1 console·