2022/12/22

テクノロジー

AWS CloudFormation StackSetsの自動デプロイ結果通知を簡単に受け取れるようになったのでやってみた

この記事の目次

    副題: AWSのおかげでグリーンカレーが作れなくなった話

    はじめに

    ※本記事は、AWS Organizations + CloudFormation StackSetsで複数AWSアカウントへのリソース展開を管理している方向けの内容になっています。

    昨年、マイナビ Advent Calendar 2021にて、こんな記事を書かせていただきました。

    要約すると、

    • CloudFormation StackSetsで組織やOUに展開しているCFnスタックについて、
    • そのデプロイステータスを通知するようにしたかったので、
    • グリーンカレーを作るついでに強引にデプロイステータスを確認するサーバーレスアプリケーションを作った。

    という内容でした。

    当時、StackSetsの各スタックインスタンスのイベントを、StackSetsやOrganizationsの管理アカウント側で検知する手段が無かったため、Step Functionsを使って定期的にステータス変更をポーリングすることで解決したわけです。

    が、

    それから約1年の時を経て、2022年11月にAWSからこんなアナウンスが。

    Build event-driven applications with AWS CloudFormation StackSets event notifications in Amazon EventBridge 本日より、AWS CloudFormation StackSets で、Amazon EventBridge を介したイベント通知の提供が開始されました。イベント駆動型のアクションは、CloudFormation のスタックセットを作成、更新、削除した後に、トリガーすることができます。これを、CloudFormation スタックセットのデプロイで、CloudFormation API を介して定期的に変更をポーリングするカスタムのソリューションを開発または維持しなくても実行できます。

    ( ゚д゚)
    
    
    (つд⊂)ゴシゴシ

    定期的に変更をポーリングするカスタムのソリューションを開発または維持しなくても実行できます。

    (;゚д゚)
    
    
    (つд⊂)ゴシゴシ
    
     _, ._
    (;゚ Д゚) …!?

    つ、ついに待望の機能が実装されました!!!!!
    というわけで、とにかくイベント検知ができるようになった以上それを活かさない手はないだろうと、新しく各スタックインスタンスのデプロイステータスを検知する基盤を作り直しました。

    本題

    通知したいのは、「各スタックインスタンスのデプロイステータスの変更」です。
    構成は別に図にするまでもないんですが、一応こちらの青背景部分になります。
    SAM実装部分だけ見るとだいぶスッキリしました。

    Slack通知をするだけなら、Chatbotを使ったりEventBridgeのトランスフォーマーでSlackAPIを叩いたりすればLambdaを書かなくても良いんですが、

    といったことから、Lambdaを通してWebhookでSlackに投げる形にしています。
    ついでに、アカウントIDだけでなくアカウント名も通知結果に含めるため、OrganizationsのAPIを叩いています。

    また、StackSetsの管理自体は専用のアカウント(StackSets Account)に委任して行っていますが、その場合でもサービスマネージド型のStackSetsのイベントはOrganizationsの管理アカウントで検知されるので注意。

    実装

    SAMテンプレート

    何の変哲もない、EventBridgeルールとLambda関数を定義しているテンプレートです。
    EventBridgeで拾うイベントタイプは CloudFormation StackSet StackInstance Status Change、ステータスコードは公式リファレンスを見てとりあえずオペレーションの失敗・成功を拾うために SUCCEEDED / FAILED / CANCELLED の3つとしています。

    ▼template.yaml

    AWSTemplateFormatVersion: '2010-09-09'
    Transform: AWS::Serverless-2016-10-31
    Description: |
      Service-Managed StackSets' Stack Instance Status Notification to Slack(webhook)
    
    #-----------------------------------------------------------------------------------#
    Parameters:
    #-----------------------------------------------------------------------------------#
      SystemName:
        Type: String
        Description: input System Name.
        Default: stacksets-status-notify
      Env:
        Type: String
        Description: input Env Name.
        AllowedValues: 
          - dev
          - stage
          - prod
      SlackWebhookUrl:
        Type: String
        Description: input Slack Incoming Webhook URL
    
    #-----------------------------------------------------------------------------------#
    Conditions:
    #-----------------------------------------------------------------------------------#
      isProd: !Equals [ !Ref Env, "prod" ]
    
    #-----------------------------------------------------------------------------------#
    Resources:
    #-----------------------------------------------------------------------------------#
      #---------------------------------------------------------------------------------#
      # EventBridge Rule
      #---------------------------------------------------------------------------------#
      StackInstancesStatusChangeEventRule:
        Type: AWS::Events::Rule
        Properties:
          EventPattern:
            source:
              - aws.cloudformation
            detail-type:
              - CloudFormation StackSet StackInstance Status Change
            detail:
              status-details:
                detailed-status:
                  - SUCCEEDED
                  - FAILED
                  - CANCELLED
          Targets:
            - Id: !Ref NotifyFunction
              Arn: !GetAtt NotifyFunction.Arn
    
      #---------------------------------------------------------------------------------#
      # Notify Function
      #---------------------------------------------------------------------------------#
      NotifyFunction:
        Type: AWS::Serverless::Function
        Properties:
          Description: !Sub
            - Stack ${AWS::StackName} Function ${ResourceName}
            - ResourceName: NotifyFunction
          FunctionName: !Sub ${SystemName}-${Env}-NotifyFunc
          CodeUri: notify_function
          Handler: app.lambda_handler
          Runtime: python3.9
          MemorySize: 128
          Timeout: 30
          Role: !GetAtt NotifyFunctionExecRole.Arn
          Environment:
            Variables:
              SLACK_WEBHOOK_URL: !Ref SlackWebhookUrl
              IS_PROD: !If [isProd, true, false]
    
      NotifyFunctionExecRole:
        Type: AWS::IAM::Role
        Properties:
          RoleName: !Sub ${SystemName}-${Env}-NotifyFunc-Role
          ManagedPolicyArns:
            - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
          Policies:
            - PolicyName: NotifyFunctionExecRolePolicy
              PolicyDocument:
                Version: 2012-10-17
                Statement:
                  - Sid: AllowDescribeAccountName
                    Action:
                      - organizations:DescribeAccount
                    Resource: '*'
                    Effect: Allow
          AssumeRolePolicyDocument:
            Version: 2012-10-17
            Statement:
              - Action:
                  - sts:AssumeRole
                Effect: Allow
                Principal:
                  Service:
                    - lambda.amazonaws.com
    
      FunctionLogGroup:
        Type: AWS::Logs::LogGroup
        DeletionPolicy: Retain
        UpdateReplacePolicy: Retain
        Properties:
          LogGroupName: !Sub /aws/lambda/${NotifyFunction}
          RetentionInDays: 30
    
      StackInstancesStatusChangeEventRuleToNotifyFunctionPermission:
        Type: AWS::Lambda::Permission
        Properties:
          Action: lambda:InvokeFunction
          FunctionName: !GetAtt NotifyFunction.Arn
          Principal: !Sub events.${AWS::URLSuffix}
          SourceArn: !GetAtt StackInstancesStatusChangeEventRule.Arn

    余談ですが、今回のtemplate.yaml作成にあたっては先日プレビュー版が出たApplication Composerを使ってみました。
    ただ、

    • うっかり余計なLambda関数作っちゃってから消してもフォルダは消えてない
    • デフォルトのNode.jsからPythonに切り替えてもindex.jsとpackage.jsonが消えてない
    • handler.handlerをapp.handlerに書き換えてもディレクトリ名は変わらない
    • ポリシーが直書きのみで、IAMのマネージドポリシーを当てる手段が無い
    • そもそもParameterつけられない

    、、、と、まだ色々と粗く、結局大半はyamlを直接編集しました。
    Windowsじゃなかったらまた違うのかもしれませんが…
    今後に期待したいです。

    Lambda

    イベントを受け取って、OrganizationsのAPIで対象アカウント名取得して、整形してSlackに投げてるだけです。

    ▼notify_function/app.p

    import json
    import os
    import re
    from datetime import datetime  # noqa: F401
    from logging import getLogger, basicConfig, INFO, DEBUG
    import boto3
    import requests
    
    # Logger Settings
    logger = getLogger(__name__)
    log_level = INFO if is_prod else DEBUG
    basicConfig(level=log_level)
    logger.setLevel(log_level)
    
    
    def get_account_name(account_id):
        """! Get Account Name
        AWSアカウントIDからアカウント名を取得する
        @param account_id(str)
        @return AWS Account Name.(str)
        """
        logger.debug('start func: get_account_name')
        logger.debug('input: {}'.format(account_id))
    
        if not account_id:
            return ''
    
        client = boto3.client('organizations')
        res = client.describe_account(AccountId=account_id)
        account_name = res['Account']['Name']
    
        logger.debug('return {}'.format(account_name))
    
        return account_name
    
    
    def generate_slack_data(data):
        """! Generate Slack Post Data
        SlackのWebhookに投げるPOSTデータを生成する。
        @param data     input event data.
        @return Slack Post Data.(dict)
        """
        logger.debug('start func: generate_slack_data')
        logger.debug(data['detail'])
    
        stacksets_url = 'https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacksets?permissions=service'  # noqa: E501
    
        # Stack Instance account, region
        stack_instance_stack_arn = data['detail'].get('stack-id', '')
        arn_pattern = r'^arn:aws:.*?:(.+?):(.+?):(stackset|stack)/?(.*)$'
        stack_instance_region = re.sub(
            arn_pattern,
            '\\1',
            stack_instance_stack_arn
        )
        stack_instance_account_id = re.sub(
            arn_pattern,
            '\\2',
            stack_instance_stack_arn
        )
        stack_instance_account_name = get_account_name(
            stack_instance_account_id
        )
    
        # Stack Instance status
        stack_instance_status = data['detail']['status-details']['detailed-status']
    
        # StackSet
        stack_set_name = re.sub(
            arn_pattern,
            '\\4',
            data['detail']['stack-set-arn']
        ).split(':')[0]
    
        # emoji
        if stack_instance_status == 'SUCCEEDED':
            color = '#2EB886'
            emoji = ':white_check_mark:'
        else:
            color = '#A30100'
            emoji = ':rotating_light:'
    
        slack_data = {
            'attachments': [
                {
                    'color': color,
                    'blocks': [
                        {
                            'type': 'section',
                            'text': {
                                'type': 'mrkdwn',
                                'text': '{} *<{}|StackSets StackInstance Status>*'.format(  # noqa:E501
                                    emoji, stacksets_url
                                )
                            }
                        },
                        {
                            'type': 'section',
                            'fields': [
                                {
                                    'type': 'mrkdwn',
                                    'text': '*Account*\n{}\n`{}`'.format(
                                        stack_instance_account_id,
                                        stack_instance_account_name
                                    )
                                },
                                {
                                    'type': 'mrkdwn',
                                    'text': '*Region*\n{}'.format(
                                        stack_instance_region
                                    )
                                },
                                {
                                    'type': 'mrkdwn',
                                    'text': '*StackSet*\n{}'.format(
                                        stack_set_name
                                    )
                                },
                                {
                                    'type': 'mrkdwn',
                                    'text': '*Status*\n{}'.format(
                                        stack_instance_status
                                    )
                                },
                            ]
                        }
                    ]
                }
            ]
        }
    
        logger.debug('return {}'.format(slack_data))
        return slack_data
    
    
    def post_to_slack(slack_webhook_url, slack_data):
        """! exec HTTP POST request to Slack Incoming Webhook.
        Slack Incoming WebhookへデータをPOSTする。
        @param slack_webhook_url
        @param slack_data
        @return Slack Post Result.(bool)
        """
        logger.debug('start func: post_to_slack')
        headers = {
            'Content-Type': 'application/json'
        }
        try:
            res = requests.post(
                slack_webhook_url,
                headers=headers,
                data=json.dumps(slack_data)
            )
        except requests.exceptions.RequestException as e:
            err_str = str(e).replace(slack_webhook_url, '****')
            logger.error('[Error posting to slack] {0}'.format(err_str), stack_info=True)  # noqa:E501
            return False
        else:
            logger.info('Slack Post OK.')
            return True
    
    
    def lambda_handler(event, context):
        """! lambda handler
        Lambdaイベントハンドラ。
        @param event    input Event by EventBridge.
        """
        logger.info('input event: {}'.format(event))
    
        slack_webhook_url = os.environ.get('SLACK_WEBHOOK_URL')
    
        if slack_webhook_url:
            slack_data = generate_slack_data(event)
            slack_result = post_to_slack(slack_webhook_url, slack_data)
            if slack_result:
                logger.info('Function Normal End')
        else:
            logger.warn('Slack Webhook URL is Null.')
        return

    ログとかdocstringのお作法はこの時点では割と適当です。ごめんなさい。

    動かしてみる

    StackSetsを管理しているus-east-1リージョンにてデプロイ後、適当なCFnテンプレートをSandbox用OUに展開して、テスト用アカウントをOU間で動かしてみたところ、以下のように無事リアルタイムでSlackに通知が来ました。
    (念のため、ぼかさなくて良いようなところまでぼかしています)

    良い感じですね。
    ap-northeast-1リージョンのスタックインスタンスの結果までちゃんと拾えています。
    通知のフォーマットは改良の余地がありそうですが、一先ず運用上は充分です。

    ポイント

    • 先にも触れましたが、サービスマネージド型StackSetsのイベントはOrganizationsの管理アカウントで発生します。StackSets管理用に委任したアカウントではありません。
      • Organizationsの管理アカウントで余計なリソースを管理したくない場合は、イベントバス転送で他のアカウントのEventBridgeに転送してやれば良さそう。
    • 「StackSetsを管理しているリージョン」のEventBridgeであれば拾えます。スタックインスタンスがデプロイされるリージョン毎にEventBridgeルールを仕込む必要はありません。

    あとがき

    今回のAWSの対応で、StackSetsのデプロイ周りが簡単にリアルタイムイベントとして検知できるようになりました。
    スタックインスタンス単位で通知が来るので、全スタックインスタンスのデプロイが終わるまで待つ必要もありません。グリーンカレーを作る時間もなくなりました。

    …昨年記事書いてた時は「仮にAWSが対応してこの記事が用済みになっても、レシピとして残そう」とか適当こいてたんですが、本当に対応していただけるとは!!!AWSさん神対応ありがとうございました!!

    ※本記事は2022年12月時点の情報です。

    著者:マイナビエンジニアブログ編集部