Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save mrjones-plip/bf258fc0e1b6157873d9e808aa4449bc to your computer and use it in GitHub Desktop.

Select an option

Save mrjones-plip/bf258fc0e1b6157873d9e808aa4449bc to your computer and use it in GitHub Desktop.
AWS: Calculate the size of all ECR repositories

AWS ECR find and delete images bash scripts

Intro

This repo holds bash scripts I used to report on ECR images and then delete them. They're a bit crude, but get the job done!

Requirements

  • AWS CLI tool - I used brew install aws for Bluefin
  • Suite of CLI utils, mainly jq
  • AWS account that you are logged into and has access to ECR

Reporting

To get a report of all images, call:

./aws_ecr_stats.sh

This will generate two cache directories cache_private and cache_private. From the JSON files in there, it will output one table for PRIVATE and one for PUBLIC image stats in your ECR repo:

PUBLIC_REPOSITORY              SIZE         IMAGE_COUNT  TAGGED/UNTAGGED
bar-quux-nouveau               <no-images>  0            0/0
bar-smang-healthcheck          <no-images>  0            0/0
foo/bar-healthcheck            <no-images>  0            0/0
foo/curator                    40.82MB      1            1/0
foo/http-redirector            53.81MB      1            1/0
foo/bar-mcp-server             53.86MB      1            1/0
foo/ci-images                  78.62MB      1            1/0
bar-interop/mediator           88.78MB      1            1/0
foo/auto-ssh                   232.85MB     6            3/3
foo/volume-monitor             354.26MB     1            1/0
foo/corge                      405.79MB     2            2/0
foo/bar-smang/healthcheck      406.86MB     9            9/0
bar-interop/configurator       474.85MB     1            1/0
foo/devops                     532.01MB     1            1/0
foo/bar-listener               609.64MB     1            1/0
foo/foo-impact                 852.95MB     4            2/2
foo/bar-upgrade-service        2.68GB       19           17/2
foo/grault                     3.65GB       32           32/0
foo/baz                        7.10GB       9            1/8
foo/moh-quuux-dashboard        12.14GB      40           38/2
foo/foo-os                     14.27GB      95           81/14
foo/bar-quux-nouveau           25.62GB      177          59/118
foo/bar                        446.87GB     25176        12245/12931
foo/bar-smang-healthcheck      677.46GB     25229        12292/12937
foo/bar-quux                   2.82TB       25373        12269/13104
foo/bar-sentinel               3.10TB       25472        12306/13166
foo/bar-api                    3.38TB       25494        12312/13182
foo/bar-smang                  3.99TB       25267        12323/12944
-----------------------------  --------     --------     --------
TOTAL                          14.45TB      152413       74000/78413

If for any reason this process gets interrupted and you have empty cache files, be sure to delete all the files in cache_private and cache_private and re-run the script.

Ad hoc exploring

Now that you have the cache_private and cache_private filled with JSON files, you can explore them with grep and jq. For example, I wanted generate a CSV list of all images that were created more than a month ago. Today was 2026-06-11, so this is aprox 30 days:

jq -r \
  '.imageDetails[] | select(.imageTags == null)   | [.imagePushedAt, .imageDigest, .repositoryName, .imageTags[]?]|@csv' foo_bar-smang.json\
  | sort | grep -vE '2026-06-|2026-05-3|2026-05-2' 

After verifying this is correct, I can then save this CSV for further processing and run it for a number or repos saving to the same file:

jq -r \
  '.imageDetails[] | select(.imageTags != null)   | [.imagePushedAt, .imageDigest, .repositoryName, .imageTags[]?]|@csv' foo_bar-smang.json\
  | sort | grep -vE '2026-06-|2026-05-3|2026-05-2' >> tagged.images.to.delete.csv
jq -r \
  '.imageDetails[] | select(.imageTags != null)   | [.imagePushedAt, .imageDigest, .repositoryName, .imageTags[]?]|@csv' foo_bar-api.json\
  | sort | grep -vE '2026-06-|2026-05-3|2026-05-2' >> tagged.images.to.delete.csv
jq -r \
  '.imageDetails[] | select(.imageTags != null)   | [.imagePushedAt, .imageDigest, .repositoryName, .imageTags[]?]|@csv' foo_bar-quux.json\
  | sort | grep -vE '2026-06-|2026-05-3|2026-05-2' >> tagged.images.to.delete.csv

Optionally, you may want to keep images that are not a branch. For those that follow SemVer, this regex should work [0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}. Adding it in the jq mix per above, it looks like:

jq -r \
  '.imageDetails[] | select(.imageTags != null)   | [.imagePushedAt, .imageDigest, .repositoryName, .imageTags[]?]|@csv' foo_bar-smang.json \
  | sort | grep -vE '2026-06-|2026-05-3|2026-05-2' \ 
  |grep -vE '"[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{3,20}"' \
  >> tagged.images.to.delete.csv
jq -r \
  '.imageDetails[] | select(.imageTags != null)   | [.imagePushedAt, .imageDigest, .repositoryName, .imageTags[]?]|@csv' foo_bar-api.json\
  | sort | grep -vE '2026-06-|2026-05-3|2026-05-2' \ 
  | grep -vE '"[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{3,20}"' \
  >> tagged.images.to.delete.csv
jq -r \
  '.imageDetails[] | select(.imageTags != null)   | [.imagePushedAt, .imageDigest, .repositoryName, .imageTags[]?]|@csv' foo_bar-quux.json\
  | sort | grep -vE '2026-06-|2026-05-3|2026-05-2' \ 
  | grep -vE '"[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{3,20}"' \
  >> tagged.images.to.delete.csv

NB - I found that I would get an error when deleting untagged images that were used in tagged images (and couldn't easy tell when there was a dependency):

{
    "imageIds": [],
    "failures": [
        {
            "imageId": {
                "imageDigest": "sha256:0008d08a3b8903e403c15474b77b16e1c2cd7068c17b5a8e1b49c83517c3eee2"
            },
            "failureCode": "ImageReferencedByManifestList",
            "failureReason": "Requested image referenced by manifest list: [sha256:2c2163ff123133cc36f7ab5d1df2e30b39feccf20d42fb823ed468b35ac2d629]"
        }
    ]
}

To get around this I generated a list of tagged images select(.imageTags != null) first and then a list of untagged images select(.imageTags == null). I could then delete all the tagged images first, and then delete all the untagged images.

Deleting images

Backup releases first

To backup your releases, you should hard code all your repositories and release numbers into the batch.delete.images.sh script and run it. It will call docker pull for each of your repositories and releases and cache it locally. You'll need to have enough disk space to do this.

You'll likely get errors from pulling too many images too fast, so log into ECR so docker can make authenticated calls to avoid the rate limitting errors:

aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/medic

Delete the cached credentials when you're done backing up - they're stored in plaintext.

Careful

Be sure to verify the CSV files first! - Even though you backed up your releases, be sure to verify the images and CSV and hashes are correct before deleting.

Actually delete

After you have verified and backed up, you can delete the images with the delete script. It's dependent on CSV files with the format from the jq calls above which looks like this:

"2022-11-03T15:44:49-07:00","sha256:eee7ea954a41ab93fe5f66f7006f39ab4d94955a31d9a3df180cd64cb120c6d8","foo/bar-quux"
"2022-11-08T09:56:50-08:00","sha256:9871dced96f088ffcfdcaa5dd1d4e0405359990bd75aba8961c0014bf84c2394","foo/bar-quux"
"2022-11-08T21:43:05-08:00","sha256:92c2759c660cd33108dbbdb7151586994851c42108e8f59de9b83bc85e617e2d","foo/bar-quux"

The script accepts the name of the CSV file and public or private as input:

./batch.delete.images.sh CSV_FILE_NAME PUBLIC_PRIVATE > LOG_FILE

For example:

./batch.delete.images.sh tagged.images.to.delete.csv private > tagged.images.to.delete.log

Be sure to > the output to a log file, as the aws ecr batch-delete-image command outputs a bunch of JSON that you should save in case images fail to delete. That way you'll have the imageDigest hashes to debug and re-process as needed:

If you think the repo exists, but you're getting RepositoryNotFoundException errors (see below), check that you called with the correct public or private flag.

aws: [ERROR]: An error occurred (RepositoryNotFoundException) when calling the BatchDeleteImage operation: The repository with name 'cht-api' does not exist in the registry with id '123456789012345'

Reprocess

If you get an error like this when deleting tagged images:

{
  "imageIds": [
    {
      "imageDigest": "sha256:404719106333411ddb04ffb33e27fb8c515e6d58da09026ea50f3f25cc1c821c"
    }
  ],
  "failures": [
    {
      "imageId": {
        "imageDigest": "sha256:79ef40190ca521dbff988da81608db3e187add13eebacab0f5f1f28b92bdc226"
      },
      "failureCode": "ImageReferencedByManifestList",
      "failureReason": "Requested image referenced by manifest list: [sha256:3046381e02fbf5cd1d7c59ba8d9c0ef0d604926369b6b2bee2964b02b56526cb]"
    },
    {
      "imageId": {
        "imageDigest": "sha256:1e71a852ee8a12f6fcbc564d0d39c7da8a3e5a5c16178e2a7f7be57fed68a673"
      },
      "failureCode": "ImageReferencedByManifestList",
      "failureReason": "Requested image referenced by manifest list: [sha256:3046381e02fbf5cd1d7c59ba8d9c0ef0d604926369b6b2bee2964b02b56526cb]"
    }
  ]
}

You can try to re-run the script in hopes that the image manifests using this image were deleted. Alternately, you can call aws ecr batch-get-image to research which image it is. Again, this is why it's critical to save the output of the delete command in a file.

Recalculate

To check how many images you delete/space you cleared up, you can move the cached JSON files mv cache_private cache_privateBAK and mv cache_private cache_privateBAK and rerun the ./aws_ecr_stats.sh script. Compare the output to what you ran before deleting the images.

.idea/
cache/
cache_public/
cache_private/
*BAK
*.txt
*.csv
*.log
#!/bin/bash
check_login() {
# Check if user is logged in
if ! aws sts get-caller-identity &> /dev/null; then
echo "ERROR: Seems like your SSO session is invalid. Please run"
printf "\n $ aws sso login\n\n"
echo "before you run the script."
exit 1
fi
}
generate_repo_list() {
pp=$1
cache_dir=./cache_$pp
mkdir -p "$cache_dir"
if [[ "$pp" == "public" ]]; then
command_start="aws ecr-public --region us-east-1"
else
command_start="aws ecr"
fi
repo_list_cache=$cache_dir/all.repos.json
if [ ! -f "$repo_list_cache" ]; then
eval "$command_start describe-repositories --output json > $repo_list_cache"
fi
}
generate_stats() {
if [[ "$pp" == "public" ]]; then
command_start="aws ecr-public describe-images --region us-east-1"
output_title="PUBLIC_REPOSITORY"
else
command_start="aws ecr describe-images"
output_title="PRIVATE_REPOSITORY"
fi
pp=$1
cache_dir=./cache_$pp
index=1
data=$(jq .repositories $repo_list_cache)
repo_count=$(echo "$data" | jq length)
for name in $(echo $data | jq -r .[].repositoryName); do
# Get repo images
clean_name=$(echo $name|sed "s/\//_/g")
repo_cache=$cache_dir/$clean_name.json
if [ ! -f "$repo_cache" ]; then
echo -en "\033[K"
echo -n "[$index/$repo_count] CREATING $pp $name" $'\r'
# shellcheck disable=SC2086
eval "$command_start --repository-name $name --output json > $repo_cache"
fi
repo_images=$(cat "$repo_cache")
# Get size
size=$(echo "$repo_images" | jq .imageDetails[].imageSizeInBytes | awk '{s+=$1}END{OFMT="%.0f";print s}')
# Get image count
image_count=$(echo "$repo_images" | jq '.imageDetails | length')
if [ -n "$size" ]; then
raw_size="$size"
size=$(numfmt --to=iec --suffix=B --format "%.2f" "$size")
else
raw_size="0"
size="<no-images>"
fi
tag_count=$(grep -c imageTags "$repo_cache")
tagless_count=$((image_count-tag_count))
repos="${repos}$name $size $image_count $tag_count/$tagless_count\n"
sizes="${sizes}$raw_size\n"
name_lens="${name_lens}${#name}\n"
image_counts="${image_counts}$image_count\n"
t_tagged=$(($t_tagged+tag_count))
total_untagged=$((total_untagged+tagless_count))
index=$((index + 1))
done
# Sort repos by size
repos=$(printf "$repos" | sort -k2 -h)
# Add separator before total
max_name_len=$(printf "$name_lens" | sort -n | tail -1)
repos="${repos}\n$(printf -- '-%.0s' $(seq ${max_name_len})) -------- -------- --------\n"
# Add total size and image count
t_size=$(printf "$sizes" | awk '{s+=$1}END{OFMT="%.0f";print s}')
t_images=$(printf "$image_counts" | awk '{s+=$1}END{print s}')
repos="${repos}TOTAL $(numfmt --to=iec --suffix=B --format "%.2f" "$t_size") $t_images $t_tagged/$total_untagged\n"
# Print final table
# shellcheck disable=SC2059
printf "$output_title SIZE IMAGE_COUNT TAGGED/UNTAGGED\n$repos" | awk '
BEGIN {
FS=" ";
OFS="\t";
}
{
if (NR == 1) {
header1 = $1;
header2 = $2;
header3 = $3;
header4 = $4;
} else {
$2 = sprintf("%10s", $2);
$3 = sprintf("%11s", $3);
$4 = sprintf("%12s", $4);
}
print $0;
}' | column -t
}
main() {
repos=""
sizes=""
name_lens=""
image_counts=""
t_tagged=0
total_untagged=0
check_login
options="public private"
for pp in $options; do
generate_repo_list "$pp"
generate_stats "$pp"
echo
done
}
main
#!/bin/sh
services="foo/bar-quux foo/bar-api foo/bar-smang"
versions="4.20.1 4.21.0 4.21.1 4.22.0 5.0.0 5.0.1 5.0.3 5.1.0 5.1.1 5.1.2 5.1.3 "
for s in $services; do
echo Archiving images for $s
for v in $versions; do
image=public.ecr.aws/$s:$v
docker pull -q $image
done
done
#!/usr/bin/env bash
# delete images based on SHA256 hash, repository and if it's a public or private repo in ECR
#
# signature is:
#
# ./batch.delete.images.sh CSV_FILE PUBLIC_OR_PRIVATE
#
# for example:
#
# ./batch.delete.images.sh jun22.images.to.delete.csv public
#
# Best practice is to capture output to a file for error analysis after the run:
#
# ./batch.delete.images.sh jun22.images.to.delete.csv public > delete.results.log
#
# expects a CSV with lines in this format:
#
# "DATE","sha256:HASH","repo/path"
#
# So for example:
#
# "2018-08-24T10:28:12-07:00","sha256:43348e8cd8b809213565447b773ff1abc7e2919503880d9cad4549f313b88f84","foo-bar/quux-smang"
# "2020-08-12T07:10:32-07:00","sha256:a06f9a68ee807827cadb7887cdeac9abd8376f9e2711475ecec8edbbc0a3ddb6","foo-bar/bash-dashboard"
#
# Requires `aws` CLI tool to be installed and logged in to your account
set -e
MAX_IMAGES=99
delete_images() {
repo=$1
id_collection=$2
pp=$3
if [[ "$pp" == "public" ]]; then
command_start="aws ecr-public --region us-east-1"
else
command_start="aws ecr"
fi
# disable globbing (SC2086) because other wise id_collection gets passed in as one
# argument instead of N arguments (and we want N)
# shellcheck disable=SC2086
eval "$command_start" batch-delete-image --repository-name "$repo" --image-ids $id_collection
}
read_file() {
csv_file=$1
repo=$2
pp=$3
count=1
id_collection=''
echo "start $repo $csv_file "
csv_data=$(grep "$repo" "$csv_file")
while read -r line; do
line=${line//\"/}
id=$(echo "$line"|cut -d, -f2)
repo=$(echo "$line"|cut -d, -f3)
id_collection="imageDigest=$id $id_collection"
if [ $count -gt $MAX_IMAGES ];then
delete_images "$repo" "$id_collection" "$pp"
count=1
id_collection=''
else
count=$((count + 1))
fi
done <<< "$csv_data"
}
loop_through_repos(){
csv_file=$1
pp=$2
repos=$(cut -d, -f3 "$csv_file"|sort|uniq)
while read -r repo; do
read_file "$csv_file" "$repo" "$pp"
done <<< "$repos"
}
csv_file=$1
pp=$2
loop_through_repos "$csv_file" "$pp"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment