テクノロジー
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からこんなアナウンスが。
本日より、AWS CloudFormation StackSets で、Amazon EventBridge を介したイベント通知の提供が開始されました。イベント駆動型のアクションは、CloudFormation のスタックセットを作成、更新、削除した後に、トリガーすることができます。これを、CloudFormation スタックセットのデプロイで、CloudFormation API を介して定期的に変更をポーリングするカスタムのソリューションを開発または維持しなくても実行できます。
( ゚д゚)
(つд⊂)ゴシゴシ
(;゚д゚)
(つд⊂)ゴシゴシ
_, ._
(;゚ Д゚) …!?
つ、ついに待望の機能が実装されました!!!!!
というわけで、とにかくイベント検知ができるようになった以上それを活かさない手はないだろうと、新しく各スタックインスタンスのデプロイステータスを検知する基盤を作り直しました。
本題
通知したいのは、「各スタックインスタンスのデプロイステータスの変更」です。
構成は別に図にするまでもないんですが、一応こちらの青背景部分になります。
SAM実装部分だけ見るとだいぶスッキリしました。
Slack通知をするだけなら、Chatbotを使ったりEventBridgeのトランスフォーマーでSlackAPIを叩いたりすればLambdaを書かなくても良いんですが、
- Chatbotの通知内容だと情報が少なすぎる
- 条件分岐で通知内容をカスタマイズしたい
といったことから、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月時点の内容です。