NRIネットコム Blog

NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

CloudFormation Stackの構成逸脱を定期監視する

本記事は  オブザーバビリティウィーク  5日目の記事です。
💻📄  4日目  ▶▶ 本記事 ▶▶  6日目  🔔🏢

はじめに

こんにちは、藤本です。オブザーバビリティウィーク5日目を担当させていただきます。

インフラの構築や管理において、「Infrastructure as Code(IaC)」の活用が一般的になっています。 AWSにおいては、AWS CloudFormationを用いたスタック管理が代表的な手法です。 IaCを導入することで、インフラ環境をコードとしてバージョン管理できるため、再現性や一貫性が飛躍的に向上します。 しかし、CloudFormationによる自動化・管理の仕組みが整っていても、実運用の現場では意図しないリソースの設定変更によって構成逸脱(ドリフト)を引き起こす可能性があります。 たとえば、CloudFormation Stackで展開されたリソースが、以下のような要因でテンプレートと異なる状態になることがあります。

  • 手動でのリソース設定変更
  • スクリプトや他サービスによるリソース操作

このような状況が発生するとCloudFormation テンプレートと実際の構成が乖離し、本来あるべき状態の検証が困難になります。また、壊滅的な設定・誤った設定に変更された場合に重大なリスクを招く恐れがあります。
CloudFormation ベストプラクティスでは、スタックのリソースを CloudFormation 以外の方法で変更しないように推奨されています。

docs.aws.amazon.com

今回は、オブザーバビリティ(可観測性)の観点から、構成逸脱(ドリフト)を定期監視する仕組みについて考えてみました。

ドリフト検出とは

AWSでは、CloudFormationのドリフトを監視する仕組みとして、「ドリフト検出」が提供されています。 「ドリフト検出」とは、スタックにより管理されているリソースが、CloudFormation テンプレートの定義から逸脱していないか(ドリフトしていないか)を確認する機能です。 検出対象として、スタック全体、またはスタック内の個々のリソースのドリフトを検出します。

CloudFormation ドリフト検出のマネジメントコンソール画面

「ドリフト検出」が実行されると、次の流れで処理が行われます。

  1. テンプレートに定義された各リソースについて、現在の実リソースの構成を取得
  2. その構成とテンプレート定義をプロパティ単位で比較
  3. 差異があるかどうかを判定し、結果を検出

検出結果はリソース毎に以下の4つのステータスが表示されます。

リソースのドリフトステータス 説明
DELETED リソースが削除されている
MODIFIED テンプレートと異なるプロパティがある
NOT_CHECKED まだドリフト検出を行っていない
IN_SYNC テンプレートと一致している

ドリフト検出を使用してスタックとリソースへのアンマネージド型設定変更を検出する - AWS CloudFormation

監視構成

CloudFormationのドリフト検出をAmazon EventBridgeとAWS Lambdaで定期実行し、構成の逸脱を自動でチェックします。 ドリフト検出が完了したイベントをEventBridgeで検知し、別のLambda関数が結果を取得してAmazon SNSで通知します。
これにより、構成逸脱(ドリフト)の状況を把握し、迅速な対応が可能になります。

アーキテクチャ図

実装手順

実装の流れ

  1. EventBridgeスケジュールでLambda①を定期実行
  2. Lambda①でCloudFormation ドリフト検出を実行
  3. ドリフト検出された結果をEventBridgeルールでキャッチし、Lambda②を実行
  4. Lambda②でドリフト検出の結果を取得し、SNSで通知

1. EventBridgeスケジュールでLambda①を定期実行

EventBridgeスケジュールでCloudFormation ドリフト検出を実行するLambda①をInvokeするように設定します。 今回は、1時間に1回ドリフト検出を行うため、定期的なスケジュールでcron ( 00 * * * ? * )と設定します。
また、他の設定は以下を参考にしてください。

  • ターゲット : Lambda①(StartDetectStackDrift)
  • サービス : AWS Lambda
  • API : Invoke
  • IAMロール : lambda:InvokeFunctionアクションを許可
  • フレックスタイムウィンドウ : オフ
  • Lambda①のリソースベースのポリシーステートメント(※Lambda①作成後)
    • サービス : EventBridge (CloudWatch Events)
    • プリンシパル : events.amazonaws.com
    • ソース ARN :Lambda①(StartDetectStackDrift)のARN
    • アクション : lambda:InvokeFunction


2. Lambda①でCloudFormation ドリフト検出を実行

EventBridgeスケジュールによってCloudFormation ドリフト検出を行うLambda①(StartDetectStackDrift)を実行します。 AWS SDKであるboto3を用いて、CloudFormation ドリフト検出を開始するdetect_stack_driftを実行します。 detect_stack_driftは、あくまでもドリフト検出を開始するだけで、検出完了までには時間がかかり、非同期に処理されます。
ドリフト検出の完了ステータスを確認したい場合は、describe_stack_drift_detection_statusのレスポンスDetectionStatusを取得する必要があります。レスポンスDetectionStatusには、ドリフト検出中である「DETECTION_IN_PROGRESS」、ドリフト検出が失敗した「DETECTION_FAILED」、ドリフト検出が完了した「DETECTION_COMPLETE」の3つの状態が存在します。

  • Lambda①コード
import boto3
import logging
import time

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

cf = boto3.client('cloudformation')

def lambda_handler(event, context):
    try: # Stackの一覧を取得
        stacks = cf.list_stacks(StackStatusFilter=['CREATE_COMPLETE', 'UPDATE_COMPLETE'])['StackSummaries']
    except Exception as e:
        logger.error(f"Failed to list stacks: {e}")
        return
    
    for stack in stacks:
        stack_name = stack['StackName']
        try: # ドリフト検出を開始
            detect_response = cf.detect_stack_drift(StackName=stack_name)
            detection_id = detect_response['StackDriftDetectionId']
            logger.info(f"Started drift detection for {stack_name}: {detection_id}")
        except Exception as e:
            logger.warning(f"Drift detection not started for {stack_name}: {e}")


Lambda関数内で利用するListStacks,DetectStackDrift,DetectStackResourceDriftに加えて、ドリフト検出の対象となるCloudFormation Stackによって展開されているリソースの参照権限が必要になります。 この権限が適切に設定されていないと、権限エラーによりドリフト検出が失敗した「DETECTION_FAILED」の状態となり、正しいドリフト検出を取得できません。 今回は後ほどテストの際に、参照するリソース(Amazon DynamoDB、Amazon SQS、Amazon SNS)の設定を参照できる権限をアタッチしています。

  • Lambda①で利用するIAMポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "cloudformation:ListStacks",
                "cloudformation:DetectStackDrift",
                "cloudformation:DetectStackResourceDrift",
                "dynamodb:DescribeTable",
                "sqs:GetQueueAttributes",
                "sns:GetTopicAttributes",
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        }
    ]
}


3. ドリフト検出された結果をEventBridgeルールでキャッチし、Lambda②を実行

detect_stack_driftは、非同期に実行されるため、CloudFormation ドリフト検出が完了したら発生するイベントCloudFormation Stack Drift Detection StatusをEventBridgeルールでキャッチします。

ドリフト検出が完了したイベントのみをキャッチするために、"detection-status": ["DETECTION_COMPLETE"]もイベントパターンに記載します。 この記載が無いと、ドリフト検出が進行中である「DETECTION_IN_PROGRESS」の状態もマッチするため、ドリフト検出結果を正しく取得できません。

ターゲットにLambda②('NotifyStackDriftViaSNS')を選択します。 Lambda①と同様に、Lambda②にもリソースベースのポリシーステートメントを設定することを忘れないようにしてください。

  • イベントパターン
{
  "source": ["aws.cloudformation"],
  "detail-type": ["CloudFormation Drift Detection Status Change"],
  "detail": {
    "status-details": {
      "stack-drift-status": ["DRIFTED"],
      "detection-status": ["DETECTION_COMPLETE"]
    }
  }
}


4. Lambda②でドリフト検出の結果を取得し、SNSで通知

EventBridgeルールで直接SNSをターゲットに設定することもできますが、Lambda②(NotifyStackDriftViaSNS)を挟むことで、SNSで通知する内容を整形することができます。 これにより、視認性を高め、ドリフトが発生しているリソースに対して迅速に対応できるようになります。 また、CloudFormationのドリフト検出イベントCloudFormation Drift Detection Status Changeでは、スタック全体のドリフト状態のみの取得になります。 今回はスタック単位のドリフトの確認ではなく、リソース単位でのドリフトとその詳細について確認したいため、Lambda②(NotifyStackDriftViaSNS)で describe_stack_resource_driftsを利用します。
通知例は以下のようになります。

⚠️ Drift detected in production-app-stack
- MyS3Bucket (AWS::S3::Bucket): MODIFIED
    - BucketEncryption.ServerSideEncryptionConfiguration[0].BucketKeyEnabled: expected `true`, actual `false`
- MyIAMRole (AWS::IAM::Role): DELETED

View in Console: https://console.aws.amazon.com/cloudformation/...

今回はテスト通知のため、SNSトピックのサブスクリプションにメールアドレスを登録しました。 このLambda②(NotifyStackDriftViaSNS)は、ドリフト検出があったスタックごとに通知が飛ぶような仕様になっているため、実際の運用では、Slackを用いた通知のほうが管理がしやすいと思います。 また、環境変数SNS_TOPIC_ARNからSNSトピックのARNを取得するようにしています。

  • Lambda②コード
import boto3
import logging
import json
import urllib.parse
import os

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

cf = boto3.client('cloudformation')

sns = boto3.client('sns')
SNS_TOPIC_ARN = os.environ.get("SNS_TOPIC_ARN")

def lambda_handler(event, context):
    logger.info("Received EventBridge event:")
    logger.info(json.dumps(event))

    # イベントからドリフトの検出結果取得
    stack_id = event["detail"]["stack-id"]
    stack_name = stack_id.split("/")[-2] 
    region = event["region"]

    drifted_resources = []
    next_token = None

    while True:
        params = {
            'StackName': stack_name,
            'MaxResults': 100
        }
        if next_token:
            params['NextToken'] = next_token

        response = cf.describe_stack_resource_drifts(**params)

        for res in response["StackResourceDrifts"]:
            if res["StackResourceDriftStatus"] != "IN_SYNC":
                resource = {
                    "logical_id": res["LogicalResourceId"],
                    "resource_type": res["ResourceType"],
                    "drift_status": res["StackResourceDriftStatus"],
                    "property_diffs": []
                }

                for diff in res.get("PropertyDifferences", []):
                    resource["property_diffs"].append({
                        "path": diff["PropertyPath"],
                        "expected": diff["ExpectedValue"],
                        "actual": diff["ActualValue"],
                        "type": diff["DifferenceType"]
                    })

                drifted_resources.append(resource)

        next_token = response.get("NextToken")
        if not next_token:
            break

    # 出力メッセージ作成
    if drifted_resources:
        lines = [f"⚠️ Drift detected in {stack_name}"]
        for r in drifted_resources:
            lines.append(f"- {r['logical_id']} ({r['resource_type']}): {r['drift_status']}")
            for pd in r.get("property_diffs", []):
                lines.append(f"    - {pd['path']}: expected `{pd['expected']}`, actual `{pd['actual']}`")

        # CloudFormationコンソールリンク
        encoded_stack_id = urllib.parse.quote(stack_id, safe='')
        cf_url = f"https://console.aws.amazon.com/cloudformation/home?region={region}#/stacks/stackinfo?stackId={encoded_stack_id}"
        lines.append("")
        lines.append(f"View in Console: {cf_url}")

        message = "\n".join(lines)
        logger.info(message)

        # SNS通知送信
        sns.publish(
            TopicArn=SNS_TOPIC_ARN,
            Subject=f"Drift Detected: {stack_name}",
            Message=message
        )

    else:
        logger.info(f"⚠️ Drift event received for {stack_name}, but no drifted resources found.")
  • Lambda②で利用するIAMポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents",
                "cloudformation:DescribeStackResourceDrifts",
                "sns:Publish"
            ],
            "Resource": "*"
        }
    ]
}


実際の通知を確認

ドリフトを意図的に検出させるためにテスト用に作成したCloudFormation テンプレートで定義しているリソースの設定を手動変更します。

  • テスト用のテンプレート
AWSTemplateFormatVersion: '2010-09-09'
Description: General purpose template to test CloudFormation drift detection

Resources:
  DriftTestSQSQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: DriftTestQueue
      VisibilityTimeout: 30
      DelaySeconds: 0

  DriftTestSNSTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: DriftTestTopic
      DisplayName: Drift Test Topic

  DriftTestDynamoDBTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: DriftTestTable
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH

下記の表に従い、各リソースのプロパティを手動変更します。

リソース 手動変更するプロパティ 変更方法 期待されるリソースのドリフトステータス
SNS トピック 表示名(DisplayName) SNSコンソール > 編集から、表示名(Display name) を 「Drift Test Topic」から 「Changed Display Name"」に変更 MODIFIED
SQS キュー 可視性タイムアウト(VisibilityTimeout) SQSコンソール > 編集から、可視性タイムアウト(VisibilityTimeout)を30から45に変更 MODIFIED
DynamoDB テーブル DynamoDBコンソールから「DriftTestTable」を削除 DELETED


手動変更によって構成逸脱が発生している状態で、Lambda①(StartDetectStackDrift)の定期実行を待ちます。
Lambda①(StartDetectStackDrift)が定期実行され、ドリフト検出が完了すると、CloudFormation マネジメントコンソールにも結果が反映されます。 想定通り、手動変更したリソースに対してドリフトが検出されています。

CloudFormation ドリフト検出のマネジメントコンソール画面

また、メール通知も確認できました。 View in ConsoleのURLからドリフト検出があったCloudFormation マネジメントコンソールに移動することができます。

メール通知


実運用のおすすめ

今回は、テスト環境での検証であったため、Lambdaが実行されるStackの対象が1つでしたが、実際に運用している環境では、複数のStackが対象となる場合が多いと思います。 そのような場合は、タグを利用することで監視対象を絞るような実装もできます。 通知の必要がない検証用Stackや、すべての環境に一律で適用しているガードレール設計に用いられるような設定のStackについては、通知の対象から除外することで、より重要な変更に集中できるようになります。 また、このようなガードレール用途のスタックについては、そもそも意図しない設定変更が行われないよう、SCP(Service Control Policy)などで変更自体を制限することも検討するとよいでしょう。
今回作成した構成逸脱(ドリフト)を監視する構成をCloudFormationでテンプレート化し、CloudFormation StackSetsによる展開で、マルチリージョンに対応することも可能です。

AWS Configルールとの違い

AWS Configルールに、ドリフト検出をチェックするcloudformation-stack-drift-detection-checkがあります。

docs.aws.amazon.com

このルールでは、DetectStackDriftオペレーションを定期実行し、スタックのドリフトステータスが DRIFTEDの場合、Config ルールは NON_COMPLIANT(非準拠)と見なします。 一方でスタックのドリフトステータスがIN_SYNCの場合はCOMPLIANT(準拠)となります。 準拠または非準拠の状態から、ドリフト検出の有無は確認することができますが、スタックレベルのドリフト検出となり、リソース単位の詳細は取得できません。
また、Configルールの評価タイミングは定期(1,3,6,12,24時間の中から設定) またはCloudFormationスタック変更時に実行されます。 今回のようにEventBridgeスケジュールによるドリフト検出実行であれば任意の期間を指定することができます。
Configルール自体マネージドで管理がいらず、簡単に設定ができる反面、タグベース除外やデータの整形の処理ができないなど柔軟性がないこともデメリットとして挙げることができます。 そのため、Configルールを利用する際は、監査目的での定期実行で利用するのが良いと思います。

終わりに

今回は、CloudFormation ドリフト検出機能を用いて、コードベースで構築したインフラが「いつの間にか変わっていた」問題を自動で検出する仕組みを構築してみました。 Amazon Bedrockと組み合わせて、ドリフトの検出結果から修復方法の手順を自動作成するなど、まだ改善の余地があると思います。
皆さんも、インフラの健全性を継続的に保つために、ドリフト検出を取り入れた仕組みづくりをぜひ試してみてください!

執筆者:藤本 匠海

インフラエンジニア

2025 Japan AWS Jr. Champions, 2025 Japan All AWS Certifications Engineers


執筆記事一覧:https://tech.nri-net.com/archive/author/nnc-t-fujimoto