Lambda 関数を利用して EBS スナップショットの差分サイズを計測し、その値を CloudWatch に出力してみた

Lambda 関数を利用して EBS スナップショットの差分サイズを計測し、その値を CloudWatch に出力してみた

Clock Icon2025.05.09

はじめに

テクニカルサポートの 片方 です。
EBS スナップショットは増分スナップショットのため、最後のスナップショットが作成された後に変更されたデータブロックと新規のデータブロックのみが、次のスナップショットの取得対象となります。
そのため、Lambda 関数を活用して、スナップショット間の差分サイズを自動的に計測し、CloudWatch メトリクスとして出力してみました。

https://docs.aws.amazon.com/ja_jp/ebs/latest/userguide/how_snapshots_work.html

ボリュームから作成する最初のスナップショットは、常に完全なスナップショットです。これには、スナップショットの作成時にボリュームに書き込まれたすべてのデータブロックが含まれます。同じボリュームの後続スナップショットは、増分スナップショットです。それらには、最後のスナップショットが作成されてからボリュームに書き込まれた、変更されたデータブロックと新規のデータブロックのみが含まれます。

取得方法

スナップショットから取得された差分バックアップサイズを確認する方法として、
EBS direct API の ListChangedBlocks でスナップショット間で差分のあるブロックインデックスの数を求め、そちらに 512 KiB を乗算することで差分のバックアップサイズを求めることが可能です。
なお、作成した Lambda 関数は、EBS ボリュームの最新 2 つのスナップショット間の差分サイズを計算し、CloudWatch メトリクスとして記録します。

https://docs.aws.amazon.com/ja_jp/ebs/latest/userguide/ebs-accessing-snapshot.html#ebsapi-elements

https://docs.aws.amazon.com/ja_jp/ebs/latest/userguide/ebsapi-elements.html

ブロックインデックスは、512 KiB ブロック単位の論理インデックスです。

やってみた

以下の順番で実装します。

  • 実行ロール作成
  • Lambda 関数作成

実行ロール

信頼関係

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "lambda.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

アタッチするポリシー例

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeSnapshots",
                "ec2:DescribeVolumes"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ebs:ListChangedBlocks",
                "ebs:ListSnapshotBlocks"
            ],
            "Resource": "arn:aws:ec2:*::snapshot/*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "cloudwatch:PutMetricData"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        }
    ]
}

※ 適宜修正してください。

Lambda 関数

Python 3.13 で作成しました。
実行ロールでは、既存のロールを使用するを選択し、先ほど作成したロールを指定します。

※ 適宜修正してください。

Lambda 関数例
import boto3
import os
import json
from datetime import datetime, timezone

def lambda_handler(event, context):
    """
    メイン関数: EBSボリュームの最新スナップショット間の差分サイズを計算し、CloudWatchに送信する

    Parameters:
    event (dict): Lambda呼び出しイベント。volumeIdまたはvolumeIdsパラメータを含むことができる
    context (object): Lambda実行コンテキスト

    Returns:
    dict: 処理結果を含むレスポンス
    """
    # 環境変数からボリュームIDのリストを取得
    volume_ids_str = os.environ.get('VOLUME_IDS', '')
    volume_ids = [vid.strip() for vid in volume_ids_str.split(',') if vid.strip()]

    if not volume_ids:
        # イベントからボリュームIDを取得する試み
        if 'volumeId' in event:
            volume_ids = [event['volumeId']]
        elif 'volumeIds' in event:
            volume_ids = event['volumeIds']
        else:
            raise ValueError("ボリュームIDが指定されていません")

    results = []

    for volume_id in volume_ids:
        try:
            # このボリュームの最新スナップショットペアを分析
            result = analyze_latest_snapshot_pair(volume_id)
            if result:
                results.append(result)
        except Exception as e:
            print(f"ボリューム {volume_id} の処理中にエラー: {str(e)}")

    return {
        'statusCode': 200,
        'body': json.dumps(results, default=str)
    }

def analyze_latest_snapshot_pair(volume_id):
    """
    指定されたボリュームの最新2つのスナップショット間の差分を分析する

    Parameters:
    volume_id (str): 分析対象のEBSボリュームID

    Returns:
    dict: 分析結果を含む辞書、またはスナップショットが不足している場合はNone
    """
    ec2_client = boto3.client('ec2')
    ebs_client = boto3.client('ebs')
    cloudwatch = boto3.client('cloudwatch')

    # このボリュームの全スナップショットを取得
    response = ec2_client.describe_snapshots(
        Filters=[
            {'Name': 'volume-id', 'Values': [volume_id]},
            {'Name': 'status', 'Values': ['completed']}
        ]
    )

    snapshots = response['Snapshots']
    if len(snapshots) < 2:
        print(f"ボリューム {volume_id} には比較可能なスナップショットが不足しています")
        return None

    # 作成日時でソート
    snapshots.sort(key=lambda x: x['StartTime'])

    # 最新の2つを取得
    older_snapshot = snapshots[-2]
    newer_snapshot = snapshots[-1]

    # 差分ブロックをカウント
    total_blocks = count_changed_blocks(
        ebs_client,
        older_snapshot['SnapshotId'],
        newer_snapshot['SnapshotId']
    )

    # 差分サイズを計算(1ブロック = 512 KiB)
    diff_size_kb = total_blocks * 512
    diff_size_mb = diff_size_kb / 1024
    diff_size_gb = diff_size_mb / 1024

    # 時間差を計算(時間単位)
    time_diff = (newer_snapshot['StartTime'] - older_snapshot['StartTime']).total_seconds() / 3600

    # 結果を作成
    result = {
        'volume_id': volume_id,
        'first_snapshot_id': older_snapshot['SnapshotId'],
        'second_snapshot_id': newer_snapshot['SnapshotId'],
        'first_date': older_snapshot['StartTime'],
        'second_date': newer_snapshot['StartTime'],
        'time_difference_hours': time_diff,
        'changed_blocks': total_blocks,
        'diff_size_kb': diff_size_kb,
        'diff_size_mb': diff_size_mb,
        'diff_size_gb': diff_size_gb
    }

    # CloudWatchにメトリクスを送信
    timestamp = datetime.now(timezone.utc)
    cloudwatch.put_metric_data(
        Namespace='EBS/SnapshotDiff',
        MetricData=[
            {
                'MetricName': 'DiffSizeGiB',
                'Value': diff_size_gb,
                'Unit': 'Gigabytes',
                'Dimensions': [
                    {'Name': 'VolumeId', 'Value': volume_id}
                ],
                'Timestamp': timestamp
            }
        ]
    )

    print(f"ボリューム {volume_id} の分析結果: 差分サイズ {diff_size_gb:.2f} GiB")

    return result

def count_changed_blocks(ebs_client, first_snapshot_id, second_snapshot_id):
    """
    2つのスナップショット間で変更されたブロック数をカウントする

    Parameters:
    ebs_client: boto3 EBSクライアント
    first_snapshot_id (str): 比較元のスナップショットID(古い方)
    second_snapshot_id (str): 比較先のスナップショットID(新しい方)

    Returns:
    int: 変更されたブロックの総数
    """
    total_blocks = 0
    next_token = None

    # ページネーション処理を行いながら全ての変更ブロックを取得
    while True:
        params = {
            'FirstSnapshotId': first_snapshot_id,
            'SecondSnapshotId': second_snapshot_id
        }

        if next_token:
            params['NextToken'] = next_token

        try:
            # EBS Direct APIを使用して変更されたブロックを取得
            response = ebs_client.list_changed_blocks(**params)

            # 現在のページの変更ブロック数をカウント
            if 'ChangedBlocks' in response:
                total_blocks += len(response['ChangedBlocks'])

            # 次のページがあるかチェック
            if 'NextToken' in response:
                next_token = response['NextToken']
            else:
                break

        except Exception as e:
            print(f"ブロック取得中にエラー: {str(e)}")
            break

    return total_blocks

環境変数の設定

VOLUME_IDS: 監視対象の EBS ボリューム ID をカンマ区切りで指定します。
例: vol-0123456789abcdef0,vol-0abcdef1234567890
もちろん、単一のボリューム ID のみを指定することも可能です。

003

タイムアウト設定

デフォルトのタイムアウト値(3秒)では短すぎるため、延長します。
タイムアウトを「30秒」に設定しました。

004

これで実装は終了です。お疲れ様でした。

検証してみた

EC2 で Windows Server 2022 を起動させた後に初回スナップショットを取得。その後適当なファイルを設置して増分スナップショットを取得しました。

001
002

Lambda 関数をテストします。
005

CloudWatch にカスタムメトリクスとして出力されているか確認します。

  • 名前空間: EBS/SnapshotDiff
  • メトリクス名: DiffSizeGiB
  • ディメンション: VolumeId

006

取得された差分が表示されています。成功です!

008

007

その後も引き続き試しましたが、成功しました!

補足

検証では、ゼロデータのみの 5GB ファイルを作成した場合と、実際のランダムデータを含む 1GB ファイルを作成した場合の 2 つのシナリオをテストしました。
結果、5GB のゼロデータファイルを作成した場合の差分は約 0.13GB しか検出されませんでしたが、1GB のランダムデータファイルでは約 1.21 GB の差分が検出されました。ドキュメントに記載はないものの EBS スナップショットがゼロデータブロックを効率的に最適化しているのではないかと推察しました。
※ gp3 のボリュームタイプで検証

まとめ

本ブログが誰かの参考になれば幸いです。

参考資料

アノテーション株式会社について

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。少しでもご興味あれば、アノテーション株式会社WEBサイトをご覧ください。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.