EventBridge と CloudTrail で EC2 と EBS に owner タグを自動付与してみた

EventBridge と CloudTrail で EC2 と EBS に owner タグを自動付与してみた

EC2起動時に`owner`タグを付け忘れても大丈夫。CloudTrailとEventBridge、Lambdaを組み合わせて、インスタンスとEBSボリュームの両方に自動でタグを付ける仕組みをCloudFormationで実装してみました。
2026.08.31

はじめに

クラウド事業本部、あきやまです。

EC2 を起動したときに owner タグを付け忘れると、あとから「誰のインスタンスか」が分からなくなります。
コンソールではインスタンスにだけタグを付けて、アタッチされる EBS には付けない、という取りこぼしもよくあります。
SCPなどでタグを必須などにすることも可能ですが、企業によってはSCPの管理は別部署で担当されており簡単に変更できない場面などあると思います。

ということで今回は、CloudTrail の管理イベントを EventBridge で受けて Lambda を起動し、EC2 とアタッチ済み EBS の両方へ owner タグを付ける仕組みを CloudFormation で組んで検証しました。

環境

項目
OS macOS
AWS CLI 2.32.32
リージョン ap-northeast-1
IaC CloudFormation
Lambda ランタイム Python 3.13(arm64)
CloudTrail 既存のマルチリージョントレイル(管理イベントを記録)

※ Lambda のコードは CloudFormation の ZipFile にインラインしています。
※ 検証アカウントには、すでに管理イベントを記録するトレイルがありました。

結論

CloudTrail の RunInstances を EventBridge で拾い、Lambda から CreateTags すれば、起動直後の EC2 とアタッチ済み EBS に owner を付けられました。

タグの値は、CloudTrail の userIdentity.principalId のうち、コロンより後ろの部分です。
ロールセッションなら AROAxxxx:test-cmtest-cm になります。
IAM ユーザーのようにコロンが無い場合は userName に倒しています。

最初の実装では、インスタンスにすでに owner があると Lambda が何もしないため、EBS だけタグ無しのまま残りました。
インスタンスは触らず、owner の無いボリュームだけ付与するように直して、意図どおり両方に付きました。

項目 結果
起動イベントの検知 EventBridge の AWS API Call via CloudTrailRunInstances をマッチできた
タグの値 principalId のコロン以降(検証では test-cm
EC2 への付与 できた
EBS への付与 初回はスキップされた。判定を直したあと付いた
既存トレイル 新規トレイルは作らず、既存トレイルでイベントが届いた

やってみた

Step 1: 構成を決める

やりたいことは次のとおりです。

  1. 誰かが EC2 を起動する
  2. CloudTrail が RunInstances を記録する
  3. EventBridge がそれを Lambda に渡す
  4. Lambda がインスタンスとアタッチ済み EBS を見て、owner が無ければ付ける

AWS 側の流れは次のとおりです。
トレイル自体は、今回のスタックでは作っていません。

EventBridge が AWS API Call via CloudTrail を default bus に載せるには、対象リージョンで管理イベントを記録するトレイルが必要です。
公式にも、CloudTrail 経由の API 呼び出しイベントを扱う前提としてトレイルの有効化が書かれています。

読み取り専用の管理イベントを全部拾う場合はルールの state を ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS にする必要がありますが、RunInstances は書き込みなので通常の ENABLED で足ります。

Step 2: EventBridge ルールを CloudFormation で定義する

ルールは default bus に置き、成功した RunInstances だけに絞りました。
失敗した API 呼び出しには errorCode が付くので、exists: false で外しています。

template.yaml
OwnerTaggerRule:
  Type: AWS::Events::Rule
  Properties:
    Description: Invoke owner tagger on successful EC2 RunInstances API calls.
    EventBusName: default
    EventPattern:
      source:
        - aws.ec2
      detail-type:
        - AWS API Call via CloudTrail
      detail:
        eventSource:
          - ec2.amazonaws.com
        eventName:
          - RunInstances
        errorCode:
          - exists: false
    State: ENABLED
    Targets:
      - Id: OwnerTaggerLambda
        Arn: !GetAtt OwnerTaggerFunction.Arn
        RetryPolicy:
          MaximumRetryAttempts: 2
          MaximumEventAgeInSeconds: 3600
        DeadLetterConfig:
          Arn: !GetAtt OwnerTaggerDlq.Arn

EventBridge から Lambda への呼び出しは非同期です。
Invoke API が受け付けた時点で EventBridge 側は配信成功になるため、関数が例外を出したあとのリトライは Lambda の EventInvokeConfig が担当します。
配信そのものが失敗したとき用に、ルールのターゲットにも SQS DLQ を付けています。

Step 3: owner の値を principalId から取る

CloudTrail の userIdentity では、ロールを Assume した呼び出しの principalId が次の形になります。

AROAxxxxxxxxxxxxxxxxx:セッション名

今回欲しいのはコロンより後ろのセッション名です。
検証時の値は test-cm でした。

src/index.py
def _extract_owner(user_identity):
    principal_id = str(user_identity.get("principalId") or "")
    if ":" in principal_id:
        owner = principal_id.split(":", 1)[1]
    else:
        owner = str(user_identity.get("userName") or principal_id)
    owner = owner.strip()
    return owner[:256] if owner else None

IAM ユーザーの principalIdAIDA... だけでコロンがありません。
その場合は人が見て分かる userName を使い、それも無ければ principalId そのものを使います。

インスタンス ID は、CloudTrail の responseElements.instancesSet.items から取ります。
EventBridge が渡すイベントでは、CloudTrail 本体が detail に入っています。

src/index.py
def _parse_instance_ids(detail):
    items = ((detail.get("responseElements") or {}).get("instancesSet") or {}).get("items") or []
    return [item["instanceId"] for item in items if item.get("instanceId")]

Step 4: インスタンスとボリュームの欠けだけを埋める

アプリケーション側の判定は次のとおりです。

起動直後は DescribeInstancesInvalidInstanceID.NotFound になったり、EBS の VolumeId がまだ空だったりします。
そのため、最大 6 回、2 秒間隔で待ちます。

CreateTags の対象は「owner が無いリソース」だけです。
インスタンスにすでに owner がある場合は、その値をボリュームへコピーします。
無い場合は principalId から取った値を両方へ付けます。
既存の owner は上書きしません。

src/index.py
def _tag_if_missing(instance_id, owner):
    instance = _wait_for_instance(instance_id)
    tags = {t["Key"]: t.get("Value") or "" for t in instance.get("Tags") or []}
    owner_value = tags.get(OWNER_TAG_KEY) or owner
    volume_ids = [
        m["Ebs"]["VolumeId"]
        for m in instance.get("BlockDeviceMappings") or []
        if m.get("Ebs", {}).get("VolumeId")
    ]
    resources = [] if tags.get(OWNER_TAG_KEY) else [instance_id]
    resources.extend(_volumes_missing_owner(volume_ids))
    if not resources:
        logger.info("%s already tagged", instance_id)
        return {"instanceId": instance_id, "skipped": True}

    ec2.create_tags(Resources=resources, Tags=[{"Key": OWNER_TAG_KEY, "Value": owner_value}])
    logger.info("Tagged %s with %s=%s", resources, OWNER_TAG_KEY, owner_value)
    return {"instanceId": instance_id, "resources": resources, "skipped": False}

IAM も owner 以外のタグは触れないようにしています。
aws:TagKeysForAllValues:StringEquals で絞り、キーが空のリクエストを通さないよう Null 条件も付けました。

template.yaml
- Sid: TagOwnerOnly
  Effect: Allow
  Action: ec2:CreateTags
  Resource:
    - !Sub arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/*
    - !Sub arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:volume/*
  Condition:
    "ForAllValues:StringEquals":
      aws:TagKeys:
        - !Ref OwnerTagKey
    "Null":
      aws:TagKeys: "false"

YAML では Null が空キーとして解釈されるため、条件演算子は引用符で囲む必要があります。
ここは CloudFormation のテンプレートを lint したときに踏みました。

Step 5: デプロイして起動してみる

既存トレイルがある前提で、トレイル作成パラメータはデフォルトの false のままデプロイしました。

aws cloudformation deploy \
  --template-file template.yaml \
  --stack-name ec2-owner-tagger \
  --capabilities CAPABILITY_IAM \
  --region ap-northeast-1

スタックは CREATE_COMPLETE になりました。
そのあと、コンソールからテスト用の EC2 を起動しました。

インスタンス i-0123456789abcdef0 には owner=test-cm が付いていました。
しかしルートボリューム vol-0123456789abcdef0Tagsnull でした。

Lambda のログは次のとおりです。
起動から数秒でイベントは届いていますが、インスタンスに owner があるので処理を打ち切っています。

i-0123456789abcdef0 already has owner=test-cm

コンソール起動では、タグの対象としてインスタンスだけを選ぶと、ボリュームにはコピーされません。
「EC2 に付け忘れたときだけ動かす」と読むと、このログは仕様どおりです。
ただし今回欲しかったのは「インスタンスにあっても、EBS が空なら埋める」でした。

Step 6: EBS だけ欠けるケースに合わせて直す

判定を変え、インスタンスに owner があってもボリュームを DescribeVolumes し、欠けている ID だけ CreateTags するようにしました。
再デプロイ後、同じインスタンスに対して Lambda を一度呼ぶと、戻り値は次のとおりでした。

{
  "owner": "test-cm",
  "results": [
    {
      "instanceId": "i-0123456789abcdef0",
      "resources": ["vol-0123456789abcdef0"],
      "skipped": false
    }
  ]
}

ボリューム側にも owner=test-cm が付きました。
インスタンスの既存タグは変わっていません。

注意点と制約

EventBridge に RunInstances を流すには、そのリージョンで管理イベントを記録する CloudTrail トレイルが必要です。
トレイルが無いアカウントでは、スタックの CreateCloudTrailTrail=true で書き込み専用のリージョナルトレイルを作れますが、すでにトレイルがあるアカウントで二重に作ると管理イベントの課金が増えます。

このスタックはリージョン単位です。
東京以外で起動したインスタンスには、そのリージョンへ同じスタックを出す必要があります。

Auto Scaling や CloudFormation 経由の RunInstances も同じルールに掛かります。
その場合の owner は、人がコンソールから起動したときのようなユーザー名ではなく、サービスロールのセッション名になります。
対象を人間の起動だけに絞るなら、userIdentitytypeinvokedBy で除外する必要があります。
今回は入れていません。

Lambda のインラインコードは ZipFile が 4096 文字までです。
処理を足していくとすぐ上限に届くので、本格運用ならデプロイパッケージを S3 に置くほうが楽です。

CreateTags は冪等ですが、すでに別の値が入っている owner は上書きしません。
付け直しが必要なら、別途運用で消すか、上書きする条件を明示したほうがよいです。

参考

この記事をシェアする

関連記事