はじめに
こんにちは、佐藤です。CloudFormationスタックのドリフトを継続的に検知し、対応が円滑に進むよう自動で通知(チケット化)の仕組みを構築し、数年間運用してきました。本記事では、通知が埋もれて対応が滞らないための工夫と、ドリフトの定期検知および通知(チケット化)の設計と運用して気づいたことを紹介します。なお、ドリフトの修復方法に関しては本記事では扱いません。
ドリフト検出とは
IaC(Infrastructure as Code)で管理しているCloudFormationスタックに対して、テンプレートで定義された望ましい状態と、クラウド上の実際の状態がズレることをドリフトと呼びます。ドリフトの原因としては、テンプレートで管理しているリソースに対してコンソール等から手作業変更を行い、テンプレートと実リソースの設定に差分が生じるためです。
ドリフトを放置すると、設定の不整合によって定義されたコードと実際のインフラ状態が一致しなくなるため、システム構成が正確に把握できなくなってしまいます。このような意図しない変更が積み重なることで、インフラの管理は次第に困難になります。詳しくは公式ドキュメント1にありますので、参考にしていただければ幸いです。
これらを防ぐには、クラウドインフラの構成変更は必ずIaCツールを用いて実施し、コンソールや手作業での更新を避ける必要があります。ただし、「IaCツール利用の徹底」を試みても、例えば緊急の障害対応などで管理者が直接リソースを変更せざるを得ない場面があり、その瞬間にコードで定義した状態と実環境が乖離する可能性があるため、IaCツール利用だけではドリフトを完全には防げません。そこで、クラウドインフラの状態を常に監視し、発生したドリフトを速やかに検知・通知できる仕組みが必要になります。本記事では、AWS CloudFormation環境でドリフトを定期的に検知し、チケット化する仕組みの設計と運用内容を紹介します。
構築するリソースと構成図
構築するリソース
- AWS Config(Managed Rule: CLOUDFORMATION_STACK_DRIFT_DETECTION_CHECK2)
- AWS Lambda(チケット作成用)
- Amazon SNS(Lambda失敗時の通知)
- Amazon EventBridge(ConfigのNON_COMPLIANTイベントをトリガーにLambdaを実行)
※前提
- 本記事はタスク管理ツールがVPC内にある場合を想定し、VPC内にあるタスク管理ツールへ直接送信するため、VPC・Subnet・Security Group等が構築済みであることが必要です。
- AWS Configが対象リージョンで有効化されてないと利用できません。未有効の環境に本記事のテンプレートを適用すると、関連リソースが作成されずエラーになります。まずはAWS Configの有効化が前提でも触れていますが、公式ドキュメント3を参考に設定ください。
構成図
通知確認
チケット発行時に、対象のアカウントやリソースが一目で分かるようにし、該当のAWSコンソールにアクセスできるURLも含めています。ドリフトの詳細はAWSコンソールで確認する運用です。
デプロイするコード
以下のコードは長いため、折りたたみ表示にしています。必要に応じて展開してください。
AWSTemplateFormatVersion: "2010-09-09"
Description: "AWS CloudFormation Stack Drift Detector"
Metadata:
AWS::CloudFormation::Interface:
ParameterGroups:
- Label:
default: "General Configuration"
Parameters:
- AppName
- Label:
default: "AWS Config Configuration"
Parameters:
- MaximumExecutionFrequency
- Label:
default: "Amazon SNS Configuration"
Parameters:
- EmailAddress
- Label:
default: "Amazon Lambda Configuration"
Parameters:
- LambdaRuntime
- LambdaArchitectures
- VpcConfigSubnetId
- VpcConfigSecurityGroupId
- Label:
default: "Issue Tracker Configuration"
Parameters:
- IssueTrackerBaseUrl
- IssueTrackerApiKey
- IssueProjectId
- IssueTypeId
- IssueStatusId
- IssueAssigneeId
- IssuePriorityId
Parameters:
# General Configuration
AppName:
Type: String
Description: "App name"
Default: "AWS-Drift-Detection"
# AWS Config Configuration
MaximumExecutionFrequency:
Type: String
Description: "The MaximumExecutionFrequency specifies the maximum frequency at which the rule is evaluated."
AllowedValues:
- "One_Hour"
- "Three_Hours"
- "Six_Hours"
- "Twelve_Hours"
- "TwentyFour_Hours"
Default: TwentyFour_Hours
# Amazon SNS Configuration
EmailAddress:
Default: "user@example.com"
Type: String
Description: "Email address to receive failure notifications for the Lambda function."
# Amazon Lambda Configuration
LambdaRuntime:
Default: "python3.11"
Type: String
Description: "The runtime environment for the AWS Lambda function."
LambdaArchitectures:
Default: "x86_64"
Type: String
Description: "The architectures environment for the AWS Lambda function."
VpcConfigSubnetId:
Default: "subnet-aaaaaaaaaaaaaaaaa"
Type: String
Description: "The subnetId environment for the AWS Lambda function."
VpcConfigSecurityGroupId:
Default: "sg-aaaaaaaaaaaaaaaaa"
Type: String
Description: "The SG environment for the AWS Lambda function."
# Issue Tracker Configuration (externalized parameters)
IssueTrackerBaseUrl:
Type: String
Description: "Redmine base URL"
Default: "https://tracker.example.com/issues.json"
IssueTrackerApiKey:
Type: String
NoEcho: true
Description: "Redmine API key"
Default: "aaaaaaaaaaaabbbbbbbbb"
IssueProjectId:
Type: Number
Description: "Redmine project ID"
IssueTypeId:
Type: Number
Description: "Redmine type ID"
IssueStatusId:
Type: Number
Description: "Redmine status ID"
IssueAssigneeId:
Type: Number
Description: "Redmine assignee ID"
IssuePriorityId:
Type: Number
Description: "Redmine priority ID"
Resources:
# AWS Config Configuration
DriftGuardConfigManagedPolicy:
Type: AWS::IAM::ManagedPolicy
DependsOn: DriftGuardTicketFunction
Properties:
ManagedPolicyName: !Sub "${AppName}-config-policy"
Description: "IAM Policy for AWS CloudFormation Stack Drift Detector"
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Action:
- "cloudformation:DetectStackResourceDrift"
- "cloudformation:DescribeStackDriftDetectionStatus"
- "cloudformation:DetectStackDrift"
- "cloudformation:BatchDescribeTypeConfigurations"
Resource: "*"
DriftGuardConfigRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub "${AppName}-config-role"
Description: "IAM Role for AWS CloudFormation Stack Drift Detector"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Principal:
Service:
- "config.amazonaws.com"
Action:
- "sts:AssumeRole"
ManagedPolicyArns:
- "arn:aws:iam::aws:policy/ReadOnlyAccess"
- !Ref DriftGuardConfigManagedPolicy
DriftGuardConfigRule:
Type: AWS::Config::ConfigRule
Properties:
ConfigRuleName: !Sub "${AppName}-config-rule"
InputParameters:
cloudformationRoleArn: !GetAtt DriftGuardConfigRole.Arn
Scope:
ComplianceResourceTypes:
- "AWS::CloudFormation::Stack"
Source:
Owner: "AWS"
SourceIdentifier: "CLOUDFORMATION_STACK_DRIFT_DETECTION_CHECK"
MaximumExecutionFrequency: !Ref MaximumExecutionFrequency
DriftGuardLambdaRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub "${AppName}-Lambda-role"
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: "Allow"
Principal:
Service: "lambda.amazonaws.com"
Action: "sts:AssumeRole"
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Path: "/"
Policies:
- PolicyName: !Sub "${AppName}-Lambda-sns-policy"
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- sns:Publish
Resource: !Ref DriftGuardLambdaFailureTopic
- PolicyName: !Sub "${AppName}-Lambda-ec2-policy"
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- ec2:CreateNetworkInterface
- ec2:DescribeNetworkInterfaces
- ec2:DeleteNetworkInterface
Resource: "*"
# Amazon Lambda Configuration
DriftGuardTicketFunction:
Type: AWS::Lambda::Function
Properties:
Description: "When drift in the stack is detected, issue a ticket to Redmine via REST API."
FunctionName: !Sub "${AppName}-ticket-creation"
Handler: "index.lambda_handler"
Environment:
Variables:
ISSUE_TRACKER_API_KEY: !Ref IssueTrackerApiKey
ISSUE_TRACKER_BASE_URL: !Ref IssueTrackerBaseUrl
ISSUE_PROJECT_ID: !Ref IssueProjectId
ISSUE_TYPE_ID: !Ref IssueTypeId
ISSUE_STATUS_ID: !Ref IssueStatusId
ISSUE_ASSIGNEE_ID: !Ref IssueAssigneeId
ISSUE_PRIORITY_ID: !Ref IssuePriorityId
Architectures:
- !Ref LambdaArchitectures
Code:
ZipFile: |
import urllib.request
import json
import datetime
import os
def lambda_handler(event, context):
aws_account_id = event.get('aws_account_id')
aws_region = event.get('aws_region')
cfn_stack_arn = event.get('cfn_stack_arn')
evaluation_time = event.get('evaluation_time')
cfn_console_url = event.get('cfn_console_url')
noncompliance_reason = event.get('noncompliance_reason', None)
return post_issue(
aws_account_id,
aws_region,
cfn_stack_arn,
evaluation_time,
cfn_console_url,
noncompliance_reason
)
# スタック名を ARN から抽出する関数
def stack_name_from_arn(arn):
parts = arn.split(':')
stack_info = parts[-1]
stack_name = stack_info.split('/')[1]
return stack_name
class DateTimeEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime.datetime):
return obj.isoformat()
return super().default(obj)
# 環境変数から設定値を読み込む
def load_issue_tracker_config():
return {
'api_key': os.environ['ISSUE_TRACKER_API_KEY'],
'base_url': os.environ['ISSUE_TRACKER_BASE_URL'],
'project_id_int': int(os.environ['ISSUE_PROJECT_ID']),
'tracker_id_int': int(os.environ['ISSUE_TYPE_ID']),
'status_id_int': int(os.environ['ISSUE_STATUS_ID']),
'assigned_to_id_int': int(os.environ['ISSUE_ASSIGNEE_ID']),
'priority_id_int': int(os.environ['ISSUE_PRIORITY_ID']),
'project_id_str': os.environ['ISSUE_PROJECT_ID']
}
# UTC日付を取得(開始日と3日後の期限)
def get_issue_dates():
now_utc = datetime.datetime.utcnow()
formatted_date = now_utc.strftime("%Y-%m-%d")
due_date_3d = (now_utc + datetime.timedelta(days=3)).strftime("%Y-%m-%d")
return formatted_date, due_date_3d
# 説明文の作成
def build_description(aws_account_id, aws_region, cfn_stack_arn, evaluation_time, cfn_console_url, noncompliance_reason=None):
lines = [
"ドリフトを検知しました!対応をお願いします。",
f"アカウントID:{aws_account_id}",
f"リージョン:{aws_region}",
f"リソースID:{cfn_stack_arn}",
f"時間:{evaluation_time}",
f"コンソールURL:{cfn_console_url}"
]
if noncompliance_reason:
lines.append(f"理由:{noncompliance_reason}")
return "\n".join(lines)
# Issue payloadの構築
def build_issue_payload(config, subject, description, start_date, due_date):
payload = {
'project_id': config['project_id_int'],
'tracker_id': config['tracker_id_int'],
'status_id': config['status_id_int'],
'subject': subject,
'description': description,
'assigned_to_id': config['assigned_to_id_int'],
'priority_id': config['priority_id_int'],
'start_date': start_date,
'due_date': due_date,
}
return payload
# JSONバイト列の生成
def to_json_bytes(data):
return json.dumps(data, cls=DateTimeEncoder).encode('utf-8')
# リクエストの生成
def build_request(endpoint_url, json_bytes, api_key):
request = urllib.request.Request(endpoint_url, data=json_bytes, method='POST')
request.add_header('Content-Type', 'application/json')
request.add_header('Accept', 'application/json')
request.add_header('X-Redmine-API-Key', api_key)
return request
# リクエスト送信
def send_request(request):
with urllib.request.urlopen(request) as response:
return response.read().decode('utf-8')
def post_issue(aws_account_id, aws_region, cfn_stack_arn, evaluation_time, cfn_console_url, noncompliance_reason=None):
config = load_issue_tracker_config()
start_date, due_date = get_issue_dates()
description = build_description(
aws_account_id,
aws_region,
cfn_stack_arn,
evaluation_time,
cfn_console_url,
noncompliance_reason
)
subject = f"AWS_CFN_DRIFTED!({stack_name_from_arn(cfn_stack_arn)})"
issue_payload = build_issue_payload(
config=config,
subject=subject,
description=description,
start_date=start_date,
due_date=due_date
)
data = {'issue': issue_payload}
endpoint_url = config['base_url']
json_bytes = to_json_bytes(data)
request = build_request(endpoint_url, json_bytes, config['api_key'])
return send_request(request)
MemorySize: 128
Role: !GetAtt DriftGuardLambdaRole.Arn
Runtime: !Ref LambdaRuntime
Timeout: 3
TracingConfig:
Mode: "PassThrough"
VpcConfig:
SubnetIds:
- !Ref VpcConfigSubnetId
SecurityGroupIds:
- !Ref VpcConfigSecurityGroupId
EphemeralStorage:
Size: 512
AllowEventsInvokeLambda:
Type: AWS::Lambda::Permission
Properties:
Action: 'lambda:InvokeFunction'
FunctionName: !GetAtt DriftGuardTicketFunction.Arn
Principal: events.amazonaws.com
SourceArn: !Sub "arn:aws:events:${AWS::Region}:${AWS::AccountId}:rule/${AppName}-event-rule"
DriftGuardLambdaErrorNotification:
Type: AWS::Lambda::EventInvokeConfig
Properties:
DestinationConfig:
OnFailure:
Destination: !Ref DriftGuardLambdaFailureTopic
FunctionName: !Ref DriftGuardTicketFunction
Qualifier: $LATEST
# Amazon SNS Configuration
DriftGuardLambdaFailureTopic:
Type: AWS::SNS::Topic
Properties:
DisplayName: !Sub "${AppName}-lambda-failure-topic"
TopicName: !Sub "${AppName}-lambda-failure-topic"
DriftGuardLambdaFailureEmailSubscription:
Type: AWS::SNS::Subscription
Properties:
Endpoint: !Ref EmailAddress
TopicArn: !Ref DriftGuardLambdaFailureTopic
Protocol: "email"
DriftGuardLambdaFailureTopicPolicy:
Type: AWS::SNS::TopicPolicy
Properties:
Topics:
- !Ref DriftGuardLambdaFailureTopic
PolicyDocument:
Statement:
- Effect: "Allow"
Principal:
Service: "events.amazonaws.com"
Action: "sns:Publish"
Resource: !Ref DriftGuardLambdaFailureTopic
# Amazon EventBridge Configuration
DriftGuardEventRule:
Type: AWS::Events::Rule
Properties:
Name: !Sub "${AppName}-event-rule"
EventPattern:
source:
- "aws.config"
detail-type:
- "Config Rules Compliance Change"
detail:
messageType:
- "ComplianceChangeNotification"
newEvaluationResult:
complianceType:
- "NON_COMPLIANT"
configRuleName:
- !Ref DriftGuardConfigRule
Targets:
- Arn: !GetAtt DriftGuardTicketFunction.Arn
Id: "cfn-drift"
InputTransformer:
InputPathsMap:
"aws_account_id": "$.detail.awsAccountId"
"aws_region": "$.detail.awsRegion"
"cfn_stack_arn": "$.detail.resourceId"
"evaluation_time": "$.detail.newEvaluationResult.resultRecordedTime"
"noncompliance_reason": "$.detail.newEvaluationResult.annotation"
InputTemplate: |
{
"aws_account_id": "<aws_account_id>",
"aws_region": "<aws_region>",
"cfn_stack_arn": "<cfn_stack_arn>",
"evaluation_time": "<evaluation_time>",
"cfn_console_url": "https://<aws_region>.console.aws.amazon.com/cloudformation/home?#/stack/detail?stackId=<cfn_stack_arn>",
"noncompliance_reason": "<noncompliance_reason>"
}
実装ポイント
通知手法
メールやチャット(SNS)通知は埋もれやすく、対応期限・担当の紐付けが曖昧になってしまうと考え、「チケット管理ツール(例はRedmine)」を選択しています。
期日(例:3日後)や優先度、担当者を自動セットし、エスカレーションの運用と合わせることで、ドリフトを放置せず、チケット化により「必ず対応」へつなげられるようにしています。
定期的な差分可視化
本システムはチケット管理ツール利用を前提としているため、EventBridgeのイベントソースにAWS Configを採用しています。具体的には、AWS Configルール「CLOUDFORMATION_STACK_DRIFT_DETECTION_CHECK」2の準拠状態の変化をトリガーに起動します。Configは準拠状態の変化(COMPLIANT→NON_COMPLIANT)が発生したときのみイベントが発火するため、すでにNON_COMPLIANTのスタックが次回以降の検知でもNON_COMPLIANTのままなら通知されません。これにより、1ドリフト=1チケットの運用ができています。
一方、EventBridgeのイベントパターンをaws.configからaws.cloudformationに変更することで、定期的なドリフト検出が完了するたびにイベントが発生します。スタックがDRIFTEDのままなら状態が変わらなくても毎回イベントが発火するため、チケット管理では同内容のチケットが大量に作成されてしまうため、採用していません。
注: なお、CloudFormationイベントに切り替える場合は、イベントのペイロード構造がAWS Configと異なります。InputTransformerのマッピングはそのまま流用できないため、InputPathsMapで設定する値の見直しが必要です。CloudFormationイベントを用いる場合は、公式ドキュメント45等を参考に実イベントのdetail構造に合わせてマッピングを調整してください。
DriftGuardEventRule:
Type: AWS::Events::Rule
Properties:
Name: !Sub "${AppName}-event-rule"
EventPattern:
source:
- "aws.cloudformation"
detail-type:
- "CloudFormation Drift Detection Status Change"
detail:
status-details:
stack-drift-status:
- "DRIFTED"
detection-status:
- "DETECTION_COMPLETE"
実装後に気づいたこと
まずはAWS Configの有効化が前提
AWS Configが有効化されていない環境に本記事のテンプレートを適用しても、関連リソースが作成されずエラーとなります。AWS Configを有効化するには、Configuration Recorderの作成、配信先S3バケットの用意、IAMロールなどの準備が必要です。記録対象(全リソースか特定タイプか)やグローバルリソースの扱いは要件に合わせて個別に設定してください。詳しい手順は公式ドキュメント3を参照してください。
AWS Configの実行頻度について
AWS ConfigのMaximumExecutionFrequencyをOne_Hourに設定すると、オートスケール等の自動変更に伴う構成差異の通知が増え、チケット発行件数が増加しました。監視間隔(実行頻度)をTwelve_HoursやTwentyFour_Hoursに延長した結果、検知件数は落ち着きました。短い監視間隔では自動変更に起因する通知が増え、実質的に対応不要なアラートが多くなる傾向があると分かりました。
まとめ
AWS CloudFormationのドリフト検出をトリガーにチケット化する仕組みについて、通知が埋もれて対応が滞らないための工夫と、定期検知・通知(チケット化)の設計および運用で得た気付きを紹介しました。ドリフトは検知して終わりではなく、確実に対応へつなげる仕組みづくりが重要です。定期検知とチケット化を組み合わせることで、ノイズを抑えつつ運用可能な通知を実現できると考えていますが、他の手法と連携した通知も有効だと思います。気になる点や運用の工夫があれば、ぜひコメントで教えてください!
ご一読いただき、ありがとうございました。
やるぞ!やるぞ!やるぞ!

