💸

【AWSコスト最適化】ECR コンテナイメージの定期削除

2024/04/01に公開

こんにちは。シンプルフォーム株式会社 にてインフラエンジニアをしています、山岸です。

当社では直近約半年間、普段の開発業務と並行して、開発組織全体で AWS コスト削減にも注力してきました。今回はそんなコスト削減に関する小ネタ其の1です。(其の2は こちら

概要

  • 当社では、プロダクトの機能強化や開発環境の増加に伴い、AWS 利用にかかるコストの増加が目立つようになってきていました。そこで、開発組織全体でコスト削減施策に関する議論を行い、コストインパクトの大きいものについて集中的に施策を実行してきました。
  • 本記事では、施策の一つである「ECR コンテナイメージ(以下、イメージ)のコスト削減」についてご紹介します。不要イメージの自動削除を実装する場合、まず検討に上がるのは ECR の ライフサイクルポリシー かと思いますが、本機能では実現できないポリシー要件への対応について述べたいと思います。

Before

まず、施策実施前がどのような状況だったかを確認しておきます。

以下は AWS Organizations 組織全体の ECR コストについて、2023年4月 ~ 2023年12月の期間を Cost Explorer 上で月次表示したものです。

ご覧の通り、分かりやすく単調増加しています...。

一部 I/O にかかるコストもありますが、ほとんどはストレージコストのようです。CI でコンテナイメージがどんどんプッシュされていきますが、古いイメージを削除する仕組みがなかったため、無駄なコストとして累積してきています。

コストの多寡もさることながら、時間の経過とともに無駄なコストが増えている状態であったため、早めに対処しておく必要がありました。

施策検討・実施

ECR ライフサイクルポリシーについて

ではどのように不要イメージの自動削除を実装するかですが、一般的によく利用されるのは、冒頭でも言及した ECR リポジトリライフサイクルポリシーではないかと思います。[1] [2]

https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/LifecyclePolicies.html

こちらの機能を利用してルールを定義することで、例えば以下のようなポリシーを設定できます。

  • プッシュされてから一定期間経過したイメージの自動削除
  • 最新 N 個以外のイメージの自動削除
  • タグがついていないイメージの自動削除

また、タグプレフィックスによるフィルタリングや、複数ルールの組合せも可能です。

課題感

個々の ECR リポジトリについてルールを定義するのは現実的ではなかったため、全 ECR リポジトリに適用しても問題のないポリシーを検討しました。アプリケーション開発メンバーの意見も聞き、以下のようなポリシーを実装することにしました。

  • 「イメージのプッシュから X 日以上が経過しており、かつ最新 N イメージに該当しないイメージを自動削除する」

意図としては、「開発サイクルの速いリポジトリで、直近 N イメージを保持していても数日前の状態に戻せなくなる」「開発サイクルの遅いリポジトリで、最終プッシュから X 日経過してイメージが一つもなくなる」という事態を避けたいというものです。

しかし、仕様を確認してみたところ ECR ライフサイクルポリシーでは複数ルールを AND 条件で削除対象とするポリシーを実装できませんでした。(2024/4/1 現在)

AWS サポートにも確認したところ、イメージが削除対象か否かを評価し、削除するための Lambda 関数を定期的に実行するなどして、要件に合うポリシーを実装してくださいとのことでした。ということで Lambda 関数を実装し、これを呼び出すための Step Functions (SFN) ステートマシンを定期的に実行するという方針にしました。

実装

以下のようなグラフ構造を持つ SFN ステートマシンを作成しました。

各ステップの処理概要は以下の通りです。

  • GetContainerRepositories ... アカウントに存在する リポジトリの一覧を取得する Lambda 関数。各 ECR リポジトリの情報を JSON 要素とするリストを返す。
  • DeleteExpiredContainerImages-Map ... 受け取ったリストの各 JSON 要素について処理するための Map ステート。
  • DeleteExpiredContainerImages ... JSON として受け取った特定の ECR リポジトリについて、その中に存在するイメージの評価と削除を行う Lambda 関数。

DeleteExpiredContainerImages ステップの詳細

実装の細部まで言及しませんが、本旨に関わる部分だけ補足しておきます。

DeleteExpiredContainerImages ステップで呼び出す Lambda 関数では、まず ECR リポジトリに存在するイメージ情報のリストを取得します。1回のリクエストで取得できるイメージ数に制限があるため、NextToken が None になるまで取得します。

def get_images(repository_name: str) -> List[Any]:
    images = []
    next_token = None
    count = 0

    while (count < 100):
        count += 1
        if next_token:
            response = ecr_client.describe_images(
                repositoryName=repository_name,
                nextToken=next_token,
            )
        else:
            response = ecr_client.describe_images(
                repositoryName=repository_name,
            )

        images.extend(response["imageDetails"])
        if "nextToken" in response:
            next_token = response["nextToken"]
        else:
            break

    return images

続いて、取得したイメージリストをもとに削除対象か否かを評価し、該当するものは削除する処理です。ここでは、ECR イメージベース Lambda 関数 [3] のコンテナに pandas をインストールしておくことで、データフレーム上で削除対象のイメージを抽出しています。

def filter_expired_images(images: List[Any]) -> bool:
    RETAIN_IMAGE_COUNT = 10
    RETAIN_SINCE_IMAGE_PUSHED_DAYS = 30

    def __check_retain_since_image_pushed_days(row) -> bool:
        now = datetime.now(pytz.timezone("Asia/Tokyo"))
        delta = timedelta(days=RETAIN_SINCE_IMAGE_PUSHED_DAYS)
        retain = (now - row["imagePushedAt"]) <= delta
        return retain

    # 最新 N 件のイメージを保持
    df = pd.DataFrame.from_dict(data=images, orient="columns")
    df = df.sort_values(by="imagePushedAt", ascending=False)
    df = df.reset_index(drop=True)
    df["retainImageCount"] = df.index < RETAIN_IMAGE_COUNT

    # プッシュから N 日以内のイメージを保持
    df["retainSinceImagePushedDays"] = df.apply(
        __check_retain_since_image_pushed_days, axis=1
    )

    # いずれの保持条件にも該当しないイメージを削除対象とする
    df["isExpired"] = ~df["retainImageCount"] & ~df["retainSinceImagePushedDays"]
    df = df[df["isExpired"]]

    # 後処理
    df = df.reset_index(drop=True)
    df = df.drop(columns=["retainImageCount", "retainSinceImagePushedDays", "isExpired"])

    return df.to_dict(orient="records")

最後に、抽出されたイメージに対して削除を実行して完了です。

def delete_images(repository_name: str, image_ids: List[Dict[str, str]]) -> None:
    DELETE_BATCH_SIZE = 50
    try:
        for i in range(0, len(image_ids), DELETE_BATCH_SIZE):
            ecr_client.batch_delete_image(
                repositoryName=repository_name,
                imageIds=image_ids[i:i + DELETE_BATCH_SIZE],
            )
        return
    except Exception as e:
        raise e

定期実行トリガー

上記の SFN ステートマシンを、スケジュール実行する EventBridge ルールを作成します。当社の場合は、金曜の夜間に週一回の頻度で実行させています。

After

2024年2月初旬から定期実行を開始してみて、しばらく経過した後の結果がこちらです。

ご覧の通り、ポリシー要件を満たしつつ大幅に削減することに成功しました 🎉
コストが最も高かった 2023年12月と比較して、$500 /月ほどの削減効果になっています。


今回は以上です!最後まで読んで頂きありがとうございました。

脚注
  1. ECRのライフサイクルポリシー設定によるリポジトリ容量の節約 - DeveopersIO ↩︎

  2. Amazon ECRライフサイクルポリシーを試してみる - サーバーワークスエンジニアブログ ↩︎

  3. Lambda のコンテナイメージを使用する - AWS Lambda 開発者ガイド ↩︎

SimpleForm Tech Blog

Discussion