テクノロジー

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

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

はじめに

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

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

image.png

要約すると、

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

という内容でした。

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

image.png

が、

それから約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実装部分だけ見るとだいぶスッキリしました。

image.png

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に通知が来ました。
(念のため、ぼかさなくて良いようなところまでぼかしています)

image.png

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

ポイント

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

あとがき

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

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

※本記事は2022年12月時点の内容です。

テクノロジーの記事一覧
タグ一覧
TOPへ戻る