AMIの登録解除と連動してスナップショットを削除する2024夏(～北の国から～)

北の国ではないですが（関東民）。
不要なイメージを削除したいがために、AMI登録解除をしても、スナップショット（EBS）は消えないという事を最近思い出した。思い出したそのころには大量のAMIがあり、もはやこんなの全部やってれんない、、、という状態に。

以下のリンクを見るも、どうも2024年はこの形だとなかなかうまくいかず。

https://dev.classmethod.jp/articles/ami-and-snapshot-delete/

https://www.scriptlife.jp/contents/programming/2021/09/04/aws-ami-deregister-image-delete-snapshot/

https://www.seeds-std.co.jp/blog/creators/2022-10-18-191421/


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229539/2564a9d3-8273-eeb7-c141-cedb26cf134e.png)
↑こんな顔になりました。

というわけでClaudeさんに聞いてコード作成＆テストを実施。
2024年は以下でAMI登録解除を行えば、自動で連携するスナップショットも削除できるようになる。

## Lambda作成
Python3.12で動作確認済み。
```python
import boto3
import logging
from botocore.exceptions import ClientError
import time
import json

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def wait_for_snapshot_deletion(ec2_client, snapshot_id, max_attempts=20, wait_time=15):
    # 最初の待機を追加
    time.sleep(wait_time)
    for _ in range(max_attempts):
        try:
            ec2_client.describe_snapshots(SnapshotIds=[snapshot_id])
            logger.info(f"Snapshot {snapshot_id} still exists. Waiting...")
            time.sleep(wait_time)
        except ClientError as e:
            if e.response['Error']['Code'] in ['InvalidSnapshot.NotFound', 'InvalidSnapshotID.NotFound']:
                logger.info(f"Snapshot {snapshot_id} has been deleted.")
                return True
            else:
                logger.error(f"Error checking snapshot {snapshot_id}: {str(e)}")
                return False
    logger.warning(f"Timed out waiting for snapshot {snapshot_id} to be deleted.")
    return False

def delete_snapshot(ec2_client, snapshot_id):
    try:
        ec2_client.delete_snapshot(SnapshotId=snapshot_id)
        logger.info(f"Deletion initiated for snapshot: {snapshot_id}")
        if wait_for_snapshot_deletion(ec2_client, snapshot_id):
            return True
        else:
            # スナップショットが見つからない場合も成功とみなす
            try:
                ec2_client.describe_snapshots(SnapshotIds=[snapshot_id])
                logger.warning(f"Snapshot {snapshot_id} still exists after deletion attempt.")
                return False
            except ClientError as e:
                if e.response['Error']['Code'] in ['InvalidSnapshot.NotFound', 'InvalidSnapshotID.NotFound']:
                    logger.info(f"Snapshot {snapshot_id} not found after deletion attempt. Considering it as deleted.")
                    return True
                else:
                    logger.error(f"Unexpected error checking snapshot {snapshot_id} after deletion attempt: {str(e)}")
                    return False
    except ClientError as e:
        if e.response['Error']['Code'] == 'InvalidSnapshot.InUse':
            logger.warning(f"Snapshot {snapshot_id} is currently in use. Skipping deletion.")
            return False
        else:
            logger.error(f"Error deleting snapshot {snapshot_id}: {str(e)}")
            return False

def lambda_handler(event, context):
    try:
        logger.info(f"Received event: {json.dumps(event)}")
        
        image_id = event['detail']['ImageId']
        state = event['detail']['State']
        
        logger.info(f"Processing AMI: {image_id}, State: {state}")

        ec2_client = boto3.client('ec2')

        # スナップショットの検索を実行
        paginator = ec2_client.get_paginator('describe_snapshots')
        snapshot_iterator = paginator.paginate(
            Filters=[
                {
                    'Name': 'description',
                    'Values': [f'Created by CreateImage(*) for {image_id}']
                }
            ]
        )

        deleted_snapshots = []
        failed_snapshots = []

        for page in snapshot_iterator:
            for snapshot in page.get('Snapshots', []):
                snapshot_id = snapshot['SnapshotId']
                logger.info(f"Found snapshot: {snapshot_id} for AMI: {image_id}")
                if delete_snapshot(ec2_client, snapshot_id):
                    deleted_snapshots.append(snapshot_id)
                else:
                    failed_snapshots.append(snapshot_id)

        if not deleted_snapshots and not failed_snapshots:
            logger.info(f"No snapshots found for AMI: {image_id}")
            return {
                'statusCode': 200,
                'body': f'No snapshots found for AMI: {image_id}'
            }

        result_message = (f"Processed AMI: {image_id}. "
                          f"Deleted snapshots: {deleted_snapshots}. "
                          f"Failed to delete snapshots: {failed_snapshots}.")
        logger.info(result_message)

        return {
            'statusCode': 200,
            'body': result_message
        }

    except KeyError as ke:
        error_message = f"Key Error: Unable to find key in event structure: {str(ke)}"
        logger.error(error_message)
        logger.error(f"Event structure: {json.dumps(event)}")
        return {
            'statusCode': 400,
            'body': error_message
        }
    except Exception as e:
        error_message = f"Unexpected error processing AMI: {str(e)}"
        logger.error(error_message)
        logger.error(f"Event structure: {json.dumps(event)}")
        return {
            'statusCode': 500,
            'body': error_message
        }
```

「スナップショットが見つからない場合も成功とみなす」と書いてますが、消す処理が早すぎて、消去確認の処理がエラー判定してしまったため、スナップショットが見つからない場合は成功という処理となった。

ハマったポイントは、どうやら過去の先人の記事だと、スナップショットの説明欄に記載があるAMI-IDをキーにしてスナップショットIDを検索していたが、この数年でAMI作成時に自動でスナップショットの説明欄に記載される文言が変わった模様。ここでスナップショットが検索されず、AMIを登録解除してもスナップショットが消えずに何度も試行錯誤して時間が溶けた。

```python:過去(他者サイトより引用)
        response = client.describe_snapshots(
            Filters=[
                {
                    'Name': 'description',
                    'Values': [
                        'Created by CreateImage(*) for ' + imageID + ' from *',
                    ]
                }
            ]
        )
```

```python:現在
            Filters=[
                {
                    'Name': 'description',
                    'Values': [f'Created by CreateImage(*) for {image_id}']
                }
            ]

```

スナップショットの説明欄descriptionの「from」が今はない模様。ここはご自身の環境で変更なり、条件分岐を追加していただきたい。
また、EventBridgeに変わってから、送られてくるイベントの形式も変わっているので、そこも対応している。

LambdaはIAMロールにAWS管理ポリシーのEC2FullAccessを付与している。
タイムアウト値は念のため3分程度に指定。

Lambdaのログ設定はありったけ有効にしておく。トラブル時にログを見れるように。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229539/5f4f4488-ac07-1ca5-f5ae-f7daafd13c7f.png)


## EventBridgeルール作成
EventBridgeのルールを新規作成し、以下のイベントパターンを作成。
```json
{
  "source": ["aws.ec2"],
  "detail-type": ["EC2 AMI State Change"],
  "detail": {
    "State": ["deregistered"]
  }
}
```


ターゲットは先に作ったLambdaを指定する。


## AMI登録解除
解除予定のAMI-IDをコピーしておく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229539/4743883b-efef-7db8-d0b7-d4fbd5356f65.png)

コピーしたAMI-IDを、スナップショットで検索。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229539/590cd7b6-5995-8da8-b6b8-a3f768460d32.png)

↑このスナップショットが消えることを確認する。
<br>

AMIに戻り、登録解除を実施。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229539/a9a90a4c-a04c-8d14-964a-ac3cc4d6ee04.png)

LambdaのCloudwatchを確認すると、正常終了していることがわかり、スナップショットを削除したと記載があり。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229539/5ea134d1-0093-43da-6f51-6a8aef1ae452.png)

スナップショット画面に戻り、AMIIDで検索した状態のまま更新ボタンをクリックすると、スナップショットが消えている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229539/0227abec-8682-d850-e7d6-88083a8f9d10.png)


簡単に思えたことが地味に時間かかった。。。