この記事はマイナビ Advent Calendar 2021 17日目の記事です。
本記事の対象者
- グリーンカレーを作ってみたいけど、ナンプラーを買うほど本格的でなくても良い方
- AWS Organizations + CloudFormation StackSetsで複数AWSアカウントへのリソース展開を管理している方
2022/12/22追記
2022年11月、AWSがスタックインスタンスのイベント検知について対応しました。
Amazon EventBridge で AWS CloudFormation StackSets のイベント通知を使ってイベント駆動型アプリケーションを構築
なので1年越しにそっちの方法でやってみたので置いておきます。
AWS CloudFormation StackSetsの自動デプロイ結果通知を簡単に受け取れるようになったのでやってみた | マイナビエンジニアブログ
まえがき
社内では様々なサービスの基盤としてAWSが利用されており、多数存在するAWSアカウントを統制・管理するためにAWS Organizationsの活用を進めています。
私の最近の業務は、そのOrganizationsや関連機能の設計・実装です。
ということで、アドカレの記事を書くにあたりその辺のネタ+αの候補で周りの社員にSlackでアンケートを取ってみました。
その結果がこちら。
なんということでしょう。
仕方が無いので、民意に則って今回は**「手軽なグリーンカレーの作り方」**について書こうと思います。
手軽に作るグリーンカレー
グリーンカレーはノーマルな日本カレーと比べて「調理時間が短い」「安い鶏肉でも満足感がある」「野菜を取りやすい」といったメリットがあります。
特に調理時間については、ノーマルな日本カレーが煮込み時間を入れて概ね40分くらいなのに対し、グリーンカレーは半分の20分程度で作れてしまいます。時間が限られる社会人にはありがたい話です。
ただ、折角ならその20分も有効に活用したいところ。
最近弊社では、AWSアカウントを作成してからCloudFormation StackSetsの自動デプロイの完了までに大体20分弱かかっているので、以下の流れで進めていきます。
なんて自然でわかりやすい導入でしょう。
- 下準備
- 食材・環境の準備
- StackSetsのデプロイ結果通知基盤の作成
- 調理
- AWSアカウント新規作成(StackSetsのデプロイ開始)
- グリーンカレーの調理開始
- グリーンカレー完成
- StackSetsのデプロイ結果通知を受け取る
- グリーンカレーを食べる
AWS Organizations + StackSetsによる自動デプロイ
OrganizationsにはCloudFormation StackSetsとの連携機能があり、組織内または特定のOU下にアカウントが追加された際に、自動でCloudFormationのスタックをアカウントに展開してくれます。
新機能: AWS CloudFormation StackSets が AWS Organization のマルチアカウントで利用可能に | Amazon Web Services ブログ
自動デプロイ(結果を自動で受け取れるとは言ってない)
とても便利な機能で、StackSets様の靴なら喜んで舐めるくらい助かっているのですが。
実はこの自動展開の部分が若干曲者で、StackSetsの概要でさらっとこのように触れられています。
管理者アカウントを使用して、AWS CloudFormation テンプレートを定義および管理し、指定のリージョンの選択されたターゲットアカウントにスタックをプロビジョニングする基盤としてテンプレートを使用します。
つまり、StackSetsはあくまでCFnテンプレートを管理して対象のアカウントに配布しているだけに過ぎず、実際にテンプレートからスタックをデプロイするのは対象アカウント内でのイベントとなります(※本記事執筆時点の仕様)。
当たり前といえば当たり前なんですが、これは言い換えればStackSetsを管理するアカウント上では【スタックのデプロイ】がイベントとして発生しないということです。
なので、スタックのデプロイが何らかの理由で失敗したとしても、StackSets管理アカウントではイベントをトリガーとした検知ができません。
もちろんデプロイ対象のアカウント内ではCloudFormationのスタック作成やその失敗がイベントとして記録されていますが、「作成したばかりのアカウントに自動でリソースを仕込むアクション」の失敗を検知したいのに、その検知をするために対象アカウントに検知用のリソースを作るというのは本末転倒です。
一応、受動的なトリガーがなくとも、能動的にStackSetsのデプロイ結果を取得するAPI(DescribeStackSetOperation)はあります。
とはいえ、日次レベルでアカウント追加があるわけでもないので、定期的にLambdaを回すのも無駄が多いです。
自動デプロイが失敗するケース
「そもそも失敗するようなテンプレートが悪いんじゃないの?」という疑問があるかもしれませんが、実際にはテンプレート自体には問題なくてもスタックのデプロイが失敗するケースがあります。
- Organizationsで適用するSCP(Service Control Policy)に変更が入り、既存のテンプレートに記述されたリソースが作成できなくなった
- 新規アカウント作成ではなく既に運用されているアカウントを組織に招待して、既存のリソース名とStackSetsで作るリソース名が重複した
- スタックのデプロイ用で自動作成されるIAMロールが作成完了する前にスタック作成APIが呼ばれ、実行ロールが無いために失敗した
- レアケースのようですが、弊社ではここ4ヶ月の間に2度このバグを踏んでいます(サポートによる調査で判明)。
などなど。
運用でカバーできる部分もありますが、最後のバグに関してはどうしようもないです。
なければ工夫するしかない
で、「何とか検知する方法ない?」とダメ元でリセラーのサポートに確認したところ、ピンズドな回答ではないですが、
デプロイのトリガーとなる【組織 or OUへのアカウント追加】はマネージドアカウントのCloudTrailイベントとして検知できるので、その一定時間後にデプロイ結果を取得するAPIを叩けばなんとかできるかもしれません
という回答をいただきました。
その手があったか!
「一定時間後」という点でややスマートさに欠けますが、そこに目を瞑れば確かにある程度の無駄を省いた形で検知が実装できそうですので、ひとまずAWS SAMを使ってStep Functionsで実装してナンとかしてみました。
(カレーだけに)
本題
前説が長くなりましたが、今回作るものは以下の通りです。
- グリーンカレー
- (心が清らかな人には美味しいグリーンカレーの画像が見えます)
- CloudFormation StackSetsの自動デプロイ結果通知基盤
- なお、StackSetsの管理をマネージドアカウントから別のアカウントに委任している前提です。
- また、StackSetsのデプロイ先は組織全体ではなくOUを想定しています。組織全体とする場合はEventBridgeで拾うイベントを修正してください。
下準備
食材・環境の準備
以下を用意してください。
- 鶏肉
- もも肉の方が美味しくできると思いますが、私はいつもむね肉です。贅沢は敵だ。
- タケノコの水煮
- 自分で切っても良し、スライス・細切り済みの物でも良し、お好みで。
- お好みの野菜
- 私はナスとオクラを入れることが多いですが、他にもほうれん草、ピーマン、ズッキーニ、カボチャなど。
- メープロイ グリーンカレーペースト
- 大きめのスーパーなら売ってると思いますが、無かったら通販か、KALDIのような輸入食品店などあたってみてください。
- ココナッツミルク
- ユウキ食品の400gが入手しやすく、使いやすいと思います。
- 醤油
- 本格的に作るならナンプラーですが、サクッと作るなら醤油で充分です。
- コンソメ
- 固形より粉末タイプがおすすめです。
- 鶏がらスープの素
- 粉末こぶ茶でも可。
- ごはん or パン
- お好みで。
- サラダ油
- 水
- 調理器具
- 包丁、菜箸、大きめの鍋があればOK。
- AWSアカウント3つ
- AWS Organizations Managementアカウント
- AWS CloudFormation StackSets管理用アカウント
- テスト用AWSアカウント
- AWS SAM開発環境
- AWS CLI
- AWS SAM CLI
StackSetsのデプロイ結果通知基盤の作成
AWS SAMで作ったものを、StackSets管理用アカウントにデプロイします。
SAMテンプレート
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
sam-app
#-----------------------------------------------------------------------------------#
Parameters:
#-----------------------------------------------------------------------------------#
SystemName:
Type: String
Description: input System Name.
Env:
Type: String
Description: input Env Name.
AllowedValues:
- dev
- stage
- prod
FirstWaitSeconds:
Type: Number
Description: input wait time(Sec) for first check from StackSets Deployment start.
RetryWaitSeconds:
Type: Number
Description: input wait time(Sec) for retry check (because of Deployment not completed).
TargetOuId:
Type: String
Description: "input Deployment Target OU ID"
OrganizationManagedAccountId:
Type: String
Description: "input Organization Managed Account ID"
SlackWebhookUrl:
Type: String
Description: "input Slack Incoming Webhook URL"
#-----------------------------------------------------------------------------------#
Resources:
#-----------------------------------------------------------------------------------#
#---------------------------------------------------------------------------------#
# StepFunctions: StateMachine
#---------------------------------------------------------------------------------#
StackSetsDeployCheckStateMachine:
Type: AWS::Serverless::StateMachine
Properties:
Name: !Sub ${SystemName}-${Env}-StateMachine
DefinitionUri: statemachine/stacksets_status_notify.asl.yaml
DefinitionSubstitutions:
DeployCheckerFunctionArn: !GetAtt DeployCheckerFunction.Arn
StatusNotifierFunctionArn: !GetAtt StatusNotifierFunction.Arn
FirstWaitSeconds: !Ref FirstWaitSeconds
RetryWaitSeconds: !Ref RetryWaitSeconds
Events:
DeployTrigger:
Type: EventBridgeRule
Properties:
Pattern:
source:
- "aws.organizations"
detail-type:
- "AWS API Call via CloudTrail"
detail:
eventSource:
- "organizations.amazonaws.com"
eventName:
- "MoveAccount"
requestParameters:
destinationParentId:
- !Ref TargetOuId
Policies:
- LambdaInvokePolicy:
FunctionName: !Ref DeployCheckerFunction
- LambdaInvokePolicy:
FunctionName: !Ref StatusNotifierFunction
# ------------------------------------------------------------#
# EventBridge: Default EventBus Policy
# ------------------------------------------------------------#
DefaultEventBusPolicy:
Type: AWS::Events::EventBusPolicy
Properties:
StatementId: allow_masteraccount_to_put_events
EventBusName: default
Statement:
Effect: Allow
Action: "events:PutEvents"
Principal:
AWS: !Ref OrganizationManagedAccountId
Resource: !Sub "arn:aws:events:${AWS::Region}:${AWS::AccountId}:event-bus/default"
#---------------------------------------------------------------------------------#
# Lambda: Deploy Checker
#---------------------------------------------------------------------------------#
DeployCheckerFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: functions/deploy_checker/
Handler: app.lambda_handler
Runtime: python3.9
FunctionName: !Sub ${SystemName}-${Env}-DeployChecker-Func
Role: !GetAtt DeployCheckerFunctionExecRole.Arn
Timeout: 120
DeployCheckerFunctionExecRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub ${SystemName}-${Env}-DeployChecker-Role
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
- arn:aws:iam::aws:policy/AWSCloudFormationReadOnlyAccess
Policies:
- PolicyName: DeployCheckerFunctionExecRolePolicy
PolicyDocument:
Version: 2012-10-17
Statement:
- Sid: AllowListDelegatedAdministrators
Action:
- organizations:ListDelegatedAdministrators
Resource: '*'
Effect: Allow
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Action:
- sts:AssumeRole
Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
#---------------------------------------------------------------------------------#
# Lambda: Status Notifier
#---------------------------------------------------------------------------------#
StatusNotifierFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: functions/status_notifier/
Handler: app.lambda_handler
Runtime: python3.9
FunctionName: !Sub ${SystemName}-${Env}-StatusNotifier-Func
Role: !GetAtt StatusNotifierFunctionExecRole.Arn
Environment:
Variables:
SLACK_WEBHOOK_URL: !Ref SlackWebhookUrl
Timeout: 30
StatusNotifierFunctionExecRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub ${SystemName}-${Env}-StatusNotifier-Role
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Action:
- sts:AssumeRole
Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
StateMachine定義
いつの間にかyamlで書けるようになっていたのでyamlで。
StartAt: Init
TimeoutSeconds: 1800
States:
Init:
Comment: パラメータセット
Type: Pass
Result:
first-wait: ${FirstWaitSeconds}
reexec-wait: ${RetryWaitSeconds}
ResultPath: $.seconds
Next: FirstWait
FirstWait:
Comment: OUへのアカウント移動からStackSetsデプロイ完了見込みまで待機
Type: Wait
SecondsPath: $.seconds.first-wait
Next: ExecDeployCheck
ExecDeployCheck:
Comment: StackSetsデプロイ状況チェック実施
Type: Task
InputPath: $
Resource: ${DeployCheckerFunctionArn}
ResultPath: $.counter
Next: ChoiceLoopStop
ChoiceLoopStop:
Type: Choice
Choices:
- Comment: 終了していないStackSetsデプロイオペレーションがあれば待機して再チェック
Variable: $.counter.RemainNotSucceededCount
NumericGreaterThan: 0
Next: ReExecWait
Default: ExecStatusNotify
ReExecWait:
Comment: デプロイ状況再チェック待機
Type: Wait
SecondsPath: $.seconds.reexec-wait
Next: ExecDeployCheck
ExecStatusNotify:
Comment: デプロイ結果の通知実行
Type: Task
InputPath: $
Resource: ${StatusNotifierFunctionArn}
ResultPath: $.notify
End: True
Lambda
DeployCheckerFunctionはこんな感じです。
(StatusNotifierFunctionはイベントのデータを加工してSlack通知をするだけなので割愛。)
ざっくり書いただけなので、(エラー処理とかロガーは)ないです。
from datetime import datetime
from dateutil.tz import tzutc
import boto3
def get_stack_set_id_lists(client):
stack_sets_list = []
paginator = client.get_paginator('list_stack_sets')
res = paginator.paginate(
Status='ACTIVE',
CallAs='DELEGATED_ADMIN'
)
for p in res:
stack_sets_list += [x['StackSetId'] for x in p['Summaries']]
return stack_sets_list
def get_stack_set_operations_list(
client, event_timestamp, stack_set_id, account_id
):
paginator = client.get_paginator('list_stack_set_operations')
operations_list = []
res = paginator.paginate(
StackSetName=stack_set_id,
CallAs='DELEGATED_ADMIN'
)
for p in res:
for operation in p['Summaries']:
if operation['CreationTimestamp'] < event_timestamp:
break
operation_results = get_stack_set_operation_results(
client, stack_set_id, operation['OperationId']
)
operation_info = {
'StackSetId': stack_set_id,
'OperationId': operation['OperationId']
}
operations_list += [
{**x, **operation_info}
for x in operation_results
if x['Account'] == account_id
]
else:
continue
return operations_list
def get_stack_set_operation_results(client, stack_set_id, operation_id):
res = client.list_stack_set_operation_results(
StackSetName=stack_set_id,
OperationId=operation_id,
CallAs='DELEGATED_ADMIN'
)
return res['Summaries']
def get_stack_set_results(event_timestamp, account_id):
client = boto3.client('cloudformation', region_name='us-east-1')
results = []
all_stack_set_ids = get_stack_set_id_lists(client)
for stack_set_id in all_stack_set_ids:
results += get_stack_set_operations_list(
client, event_timestamp, stack_set_id, account_id
)
return results
def lambda_handler(event, context):
account_id = event['detail']['requestParameters']['accountId']
event_timestamp = datetime.strptime(
event['time'], '%Y-%m-%dT%H:%M:%S%z'
)
stack_set_results = get_stack_set_results(event_timestamp, account_id)
runnings = [
x
for x in stack_set_results
if x['Status'] in ['RUNNING', 'PENDING']
]
successes = [
x
for x in stack_set_results
if x['Status'] == 'SUCCEEDED'
]
failures = [
x
for x
in stack_set_results
if x['Status'] in ['FAILED', 'CANCELLED']
]
return {
'TotalCount': len(stack_set_results),
'RemainNotSucceededCount': len(runnings),
'SuccessCount': len(successes),
'FailedCount': len(failures),
'FailedJobList': failures
}
マネージドアカウントのEventBridgeルール
イベントパターンは、StackSets管理アカウントのEventBridgeルールと同様。
{
"detail-type": ["AWS API Call via CloudTrail"],
"source": ["aws.organizations"],
"detail": {
"eventSource": ["organizations.amazonaws.com"],
"requestParameters": {
"destinationParentId": ["【デプロイ先OUのID】"],
},
"eventName": ["MoveAccount"]
}
}
ターゲットは、StackSets管理アカウントのデフォルトイベントバスのARNを指定。
実行ロールで同イベントバスへのevents:PutEvents
を許可しておきましょう。
ポイント
- 「StackSetsデプロイのトリガーとなるイベントが起きてから一定時間待機」をStep FunctionsのWaitで行っています。
- 待機の秒数はパラメータ指定としています。
- DeployCheckerFunction実行時、まだデプロイの終わっていないStackSetsデプロイがある場合にはWait→再実行としています。
- 再実行までの秒数もパラメータ指定です。
- 何らかの理由でStep Functionsが延々実行されるということを防ぐために、全体のタイムアウトを設定しています。
- ここはなぜかベタ書きで30分としていますが、パラメータにした方が良いかもしれません。
- デプロイ状況をチェックするLambda関数の実行ロールには、
organizations:ListDelegatedAdministrators
を許可しています。- この権限がないと、自分がStackSetsの管理を委任されたアカウントであることを認識できずエラーになります。
- 委任されたStackSetsの情報を取得するため、APIを叩くときに
CallAs='DELEGATED_ADMIN'
を指定しています。 - 通知結果のノイズを抑えるため、OU移動イベントで拾ったアカウントIDをターゲットとするオペレーションのみ取得しています。
- オペレーションやその結果を取得するAPIはタイムスタンプの新しい順に結果を返してくるようですので、「OU移動イベント時刻より前」になった時点で結果のページングを終了しています。
- 通知はLambdaでSlackのIncoming Webhookを叩いています。
- EventBridgeとChatbotでなんとかなるかもしれないと思ったのですが、Step Functions絡みのイベントはChatbotの通知内容が少なすぎたので今回は不採用です。
調理
さて、やっと下準備が終わりましたので、調理に入っていきましょう。
1. AWSアカウントを新規に作る
Organizationsのマネージドアカウントにログインし、AWSアカウントを作成します。
アカウントを作成したら、StackSetsのデプロイ対象となっているOUに移動させましょう。
今回はテスト用OUとテンプレートを予め用意しておきました。
- 通常利用しているStackSetsテンプレート2件(いずれも単独リージョン)
- 必ず失敗するテンプレート1件(2リージョン)
- 名前を固定したIAMロールを作成するテンプレート
- 2リージョンにデプロイすることで、片方がリソース名重複となり失敗する
StackSetsテンプレート3件・4リージョンでデプロイが実施され、うち1リージョンで必ず失敗することになります。
また、StackSetsデプロイ結果通知基盤について、今回は最初の待機時間が長いものと短いものの2パターンをデプロイします。
最初のチェック実行時にデプロイが終わっている場合とそうでない場合の両方の挙動を見るためです。
2. グリーンカレーの調理開始
今更ですが、このレシピはかつてネット掲示板で連載されていたAA短編のものがベースになっています。
気になる人は「翠星石がやる夫の為に料理を作るようです」でググりましょう。
- 鶏肉・タケノコ・野菜を1口サイズに切る。
- 大きめの鍋にサラダ油を適量垂らし、鶏肉・タケノコ・野菜・粉コンソメを入れて炒める。
- 鶏肉に火が通ってきたら一度火を止め、ココナッツミルクを入れる。
- 再度火を点け(中火)、グリーンカレーペーストを入れる。
- 沸騰する手前くらいで水を入れる。お好みで50~150mlくらい(少ないほど辛口になります)。
- 醤油を大さじ1.5杯程度入れる。あまり多すぎるとグリーンカレー感が薄れるので注意。
- 鶏がらスープの素を入れる。
- あとは中火のまま温める。
3. グリーンカレー完成
再度沸騰する手前くらいで火を止めれば完成です。
前述の通り、大体20分くらいで出来上がると思います。
4. StackSetsのデプロイ結果通知を受け取る
さて、グリーンカレーができたところで、Step Functionsの実行結果とSlack通知を確認してみます。
リトライなしパターン
念の為、アカウント・スタック・オペレーションのID、不真面目なSlackApp名とアイコンは伏せさせていただいておりますが、いい感じです。
リトライありパターン
やったぜ。
あとはログ回りやStep Function自体のアラートについて足せばとりあえず運用は回りそうです。
デプロイ失敗分は1回くらい自動で再実行させるなど、機能追加の余地はまだまだありそうですが。
ただ、あまり凝ったことをする前にAWS公式が何らかの検知方法を実装してくれる気がしないでもないです。
というか実装してくださいお願いします。そしたら今度こそ靴舐めます。
5. グリーンカレーを食べる
無事デプロイ結果を受け取れましたので、美味しくいただきましょう。
あとがき
まえがきの末尾に
ひとまずAWS SAMを使ってStep Functionsで実装してナンとかしてみました。
(カレーだけに)
と書きましたが、ナンはインドカレーにつけるものであり、グリーンカレーを始めとしたタイカレーはごはんで食べるのが基本です。
混同しないように気をつけましょう。