11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CloudFormationのドリフトを自動でチケット化する仕組みを作ってみた

11
Posted at

はじめに

こんにちは、佐藤です。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を参考に設定ください。

構成図

awsArchitecture.png

通知確認

チケット発行時に、対象のアカウントやリソースが一目で分かるようにし、該当のAWSコンソールにアクセスできるURLも含めています。ドリフトの詳細はAWSコンソールで確認する運用です。

ticket.png

デプロイするコード

以下のコードは長いため、折りたたみ表示にしています。必要に応じて展開してください。
drift_detection.yaml
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のドリフト検出をトリガーにチケット化する仕組みについて、通知が埋もれて対応が滞らないための工夫と、定期検知・通知(チケット化)の設計および運用で得た気付きを紹介しました。ドリフトは検知して終わりではなく、確実に対応へつなげる仕組みづくりが重要です。定期検知とチケット化を組み合わせることで、ノイズを抑えつつ運用可能な通知を実現できると考えていますが、他の手法と連携した通知も有効だと思います。気になる点や運用の工夫があれば、ぜひコメントで教えてください!

ご一読いただき、ありがとうございました。
やるぞ!やるぞ!やるぞ!

参考サイト

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

  2. cloudformation-stack-drift-detection-check ↩ ↩2

  3. コンソール AWS Config で をセットアップする ↩ ↩2

  4. Amazon EventBridge 入力変換 ↩

  5. Amazon EventBridge イベントの詳細リファレンス ↩

11
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?