💻

【AWS】Lambdaによるタグ自動付与 スナップショットも迷子にさせない!

に公開

こんにちは!株式会社EDUCOM、とよだです。 「AWSコスト管理奮闘記(自分で名付けた)」も今回でついに完結です!

前回は AWS Config を使って、EBSボリュームへの自動タグ付けを実現しました。 今回は、”念の為”の権化である 「スナップショット」 のタグ付け自動化と、コスト管理及びリソースタグ管理シリーズのまとめをお送りします。

1. スナップショットの課題:作成された瞬間に「誰の?」が消える

スナップショットは、元のボリュームの「バックアップ」として作成されますが、元のボリュームに付いている「Owner」タグは、スナップショットには自動で引き継がれません。

「バックアップコストが嵩んでいるけど、これ誰がいつ作ったやつ?」 そんな状況を防ぐため、今回は EventBridge Scheduler + Lambda で解決しました。

2. 構成図:EventBridge Scheduler × Lambda

今回は、毎週決まった時間に「タグの付け忘れ」をまとめてチェックする仕組みを導入しました。

仕組みのポイント

  1. EventBridge Scheduler: 毎週金曜日の朝(9:00 JST)に Lambda を起動。
  2. Lambda: 全スナップショットをスキャン。
  3. 特定: スナップショットの VolumeId から元のボリュームを特定。
  4. 付与: 元のボリュームの Owner タグをコピーしてスナップショットに付与。

なぜこの構成で、定期スキャン なのか。

今回の構成を決めるにあたり、いくつかの代替案と比較検討しました。

1. AWS Config での検知(NG)

前回EBSボリュームで採用した Configルール required-tags でスナップショットも監視できないか検討しましたが、残念ながらスナップショットは評価対象リソース外だったため断念しました。

※上記ドキュメントの「Supported Resource Types」に Snapshot が含まれていません。

2. EventBridge Rule によるリアルタイム検知(不採用)

CreateSnapshot APIをトリガーに、作成直後にLambdaを動かす「イベント駆動型」も検討しました。技術的には可能ですが、以下の理由で見送りました。

  • 要件の再確認: 今回のゴールは「週次のコスト通知のタイミングでタグが付いていれば良い」こと。

結論:EventBridge Scheduler による定期スキャン

「リアルタイム性は不要」と割り切り、週に1回まとめて処理するバッチ処理のような形を採用しました。これにより、Lambdaの起動回数を抑えつつ、確実にコスト通知に間に合わせる構成となりました。

サービス組み合わせ 対応 備考
Config + SSMオートメーション NG Configルールの監視対象にSnapshotが含まれていないため不可。
EventBridge (リアルタイム) + Lambda OK 検知後すぐにタグ付け可能だが、実行回数が多くなる懸念あり。
EventBridge (定期実行) + Lambda 採用 「週次通知に間に合えばOK」という要件にマッチし、管理もシンプル。

3. CloudFormationによるデプロイ

今回の仕組みは、一貫性を保つために CloudFormation (Cfn) で管理しています

デプロイしたCfnテンプレート
AWSTemplateFormatVersion: "2010-09-09"
Description: Lambda for saving Automation execution result

Resources:
  #######################################
  # IAM Role
  #######################################
  IAMRoleRoleforOwnertagAttachtoSnapshot:
    Type: "AWS::IAM::Role"
    Properties:
      ManagedPolicyArns:
      - "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"
      RoleName: "Role-for-OwnertagAttachtoSnapshot-by-cfn"
      Description: "Allows Lambda functions to call AWS services on your behalf."
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Action: "sts:AssumeRole"
            Effect: "Allow"
            Principal:
              Service: "lambda.amazonaws.com"

  IAMPolicyforOwnertagAttachtoSnapshot:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: Policy-for-Lambda-OwnertagAttachtoSnapshot-by-cfn
      Roles:
        - !Ref IAMRoleRoleforOwnertagAttachtoSnapshot
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - ec2:DescribeSnapshots
              - ec2:CreateTags
              - ec2:DescribeVolumes
            Resource: "*"

  #######################################
  # Lambda
  ####################################### 

  LambdaFunctionOwnertagAttachtoSnapshot:
    UpdateReplacePolicy: "Delete"
    Type: "AWS::Lambda::Function"
    DeletionPolicy: "Delete"
    Properties:
      MemorySize: 128
      Timeout: 120
      Handler: "index.lambda_handler"
      Role:
        Fn::GetAtt:
        - "IAMRoleRoleforOwnertagAttachtoSnapshot"
        - "Arn"
      FunctionName: "OwnertagAttachtoSnapshot-by-cfn"
      Runtime: "python3.14"
      PackageType: "Zip"
      Code:
        ZipFile: |
          import boto3
          import json

          ec2 = boto3.client("ec2")

          OWNER_KEY = ["Owner"]

          def lambda_handler(event, context):
              def list_all_snapshots():
                  snapshots = []
                  token = None

                  while True:
                      if token:
                          resp = ec2.describe_snapshots(
                              OwnerIds=["self"],
                              NextToken=token
                          )
                      else:
                          resp = ec2.describe_snapshots(
                              OwnerIds=["self"]
                          )

                      snapshots.extend(resp["Snapshots"])
                      token = resp.get("NextToken")

                      if not token:
                          break

                  return snapshots

              snapshots = list_all_snapshots()

              added = []
              skipped = []
              no_volume_tag = []
              no_volume = []

              for snap in snapshots:
                  print("[DEBUG] RAW SNAPSHOT DATA:", json.dumps(snap, default=str))

                  snap_id = snap["SnapshotId"]
                  raw_tags = snap.get("Tags") or []
                  tags = {t["Key"]: t["Value"] for t in raw_tags}

                  print(f"[DEBUG] Snapshot {snap_id} tags = {tags}")

                  # Ownerタグが既についているものはスキップ
                  if any(k in tags for k in OWNER_KEY):
                      skipped.append(snap_id)
                      continue

                  volume_id = snap.get("VolumeId")
                  if not volume_id:
                      no_volume.append(snap_id)
                      continue

                  # ボリューム情報取得
                  try:
                      vol_resp = ec2.describe_volumes(VolumeIds=[volume_id])
                      if len(vol_resp["Volumes"]) == 0:
                          no_volume.append(snap_id)
                          continue
                      vol = vol_resp["Volumes"][0]
                  except Exception as e:
                      print(f"[ERROR] DescribeVolumes failed for {volume_id}: {e}")
                      no_volume.append(snap_id)
                      continue

                  vol_tags = {t["Key"]: t["Value"] for t in (vol.get("Tags") or [])}
                  print(f"[DEBUG] Volume {volume_id} tags = {vol_tags}")

                  # Volume に Ownerタグがない場合
                  if not any(k in vol_tags for k in OWNER_KEY):
                      no_volume_tag.append(snap_id)
                      continue

                  # Volume の Owner を抽出
                  for k in OWNER_KEY:
                      if k in vol_tags:
                          owner_value = vol_tags[k]
                          break

                  ec2.create_tags(
                      Resources=[snap_id],
                      Tags=[{"Key": "Owner", "Value": owner_value}]
                  )

                  added.append(snap_id)

              return {
                  "added": added,
                  "skipped": skipped,
                  "no_volume": no_volume,
                  "no_volume_tag": no_volume_tag,
                  "count_added": len(added)
              }

  #######################################
  # IAM Role for Scheduler (Lambda実行権限)
  #######################################
  IAMRoleForScheduler:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: "Role-for-Scheduler-InvokeLambda"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: "scheduler.amazonaws.com"
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: InvokeLambdaPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: "lambda:InvokeFunction"
                Resource: !GetAtt LambdaFunctionOwnertagAttachtoSnapshot.Arn


  #######################################
  # EventBridge Scheduler
  ####################################### 
  SchedulerScheduleInvokeOwnertagAttachtoSnapshotLambda:
    UpdateReplacePolicy: "Delete"
    Type: "AWS::Scheduler::Schedule"
    DeletionPolicy: "Delete"
    Properties:
      GroupName: "default"
      ScheduleExpression: "cron(00 9 ? * FRI *)"
      Target:
        Arn: !GetAtt LambdaFunctionOwnertagAttachtoSnapshot.Arn
        RoleArn: !GetAtt IAMRoleForScheduler.Arn
        RetryPolicy:
          MaximumEventAgeInSeconds: 86400
          MaximumRetryAttempts: 0
      State: "ENABLED"
      FlexibleTimeWindow:
        Mode: "OFF"
      ScheduleExpressionTimezone: "Asia/Tokyo"
      Name: "ScheduleInvokeOwnertagAttachtoSnapshotLambda"

Outputs:
  LambdaFunctionName:
    Value: !Ref LambdaFunctionOwnertagAttachtoSnapshot
    Description: Name of the Lambda function

IAM Role for Lambda (Lambda実行権限)

Lambdaの処理にはスナップショット/EBSボリュームの読み取り権限・タグをつける権限が必要です。
(デバック用にCloudWatchLogsへのログ出力を許可する権限も追加しています。)
以下がLambdaの実行権限に必要な許可設定を設けたロールとポリシーです。

IAM Role for Lambda (Lambda実行権限)
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "lambda.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Action": [
				"ec2:DescribeSnapshots",
				"ec2:CreateTags",
				"ec2:DescribeVolumes"
			],
			"Resource": "*",
			"Effect": "Allow"
		}
	]
}

Lambdaの処理内容

Lambdaの中身で行っている判定ロジックを整理すると以下のようになります。

  • Lambda内では、Python (Boto3) を使い、Ownerタグを持たないスナップショットに対して処理を行うようにしています。
  • Snapshotへのタグ情報は紐づいた取得元のVolumeと同一のOwnerタグを付与する処理としています。

IAMポリシー Lambda実行権限

IAM Role for Scheduler (Lambda実行権限)
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "scheduler.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Action": "lambda:InvokeFunction",
			"Resource": "arn:aws:lambda:***************:function:OwnertagAttachtoSnapshot-by-cfn",
			"Effect": "Allow"
		}
	]
}

EventBridge (Lambda起動用)

今回は毎週「金曜の午前9時」に動くよう設定しています。週末のリソース整理にぴったりのタイミングですが、環境に合わせて変更可能です!

EventBridge Scheduler
  SchedulerScheduleInvokeOwnertagAttachtoSnapshotLambda:
      ScheduleExpression: "cron(00 9 ? * FRI *)"
      ScheduleExpressionTimezone: "Asia/Tokyo"

4. 3回の連載を通した総括

これまで3回にわたり、以下の3段構えでコスト最適化に取り組んできました。

第1回:入り口を固める(IAMポリシーでタグなし作成を拒否)

第2回:漏れを直す(AWS Configでボリュームに即時タグ付与)

第3回:関連リソースを繋ぐ(Lambdaでスナップショットへタグ継承)

それぞれのAWSサービスを活用し、「予防」と「防ぎきれない場合の事後処置」の両面から仕組みを導入しました。
実際に取り組んでみて感じたのは、「100%完璧な自動化は難しい」 ということです。各サービスには制限があり、どうしても防ぎきれないケースは出てしまいます。大切なのは「どこまでを自動化し、どこを仕組みで補完するか」という視点でバランスを考えることだと学びました。

導入後の変化

Cost Explorerで「タグなし」の項目が劇的に減り、誰がどれだけコストを使っているかが一目でわかるようになりました!
「仕組み化」は最初こそ大変ですが、一度作ってしまえば自分も周囲もハッピーになります。皆さんのAWS環境でも、ぜひ自分たちに合った「自動化」を検討してみてください!
この記事が、目を通していただいた皆様の参考になれば幸いです。

EDUCOM Tech Blog

Discussion