CloudFormationスタックセットをデプロイするのに一手間かかったので、色々なスタックセットに使いまわせる汎用的なスクリプトを作ってみました。
やりたいこと
- CloudFormationスタックセットを作るときに、「いい感じ」にデプロイする
CloudFormation StackSetsとは
ざっくり言うと、「1つのCloudFormationスタックを複数アカウント・複数リージョンにまとめてデプロイするもの」です。詳細は以下の公式ドキュメントを参照してください。
AWS CloudFormation StackSets は、複数のアカウントおよびリージョンのスタックを 1 度のオペレーションで、作成、更新、削除できるようにすることで、スタックの機能を拡張します。
「いい感じ」にデプロイ?
スタックセットでかかる手間
①リージョン選択
スタックセットを利用するユースケースの一つにセキュリティ系のサービスがあるかと思いますが、このようなサービスは全リージョンに設定することが推奨されています。
それをいちいちリージョンを選択してデプロイするのも大変で、また大阪リージョンの様にリージョン自体が後から追加されるケースもあるので、そのようなときは再デプロイすれば勝手に反映してくれるというのが理想。
②作成・更新ごとのコマンドの使い分け
さらにCLIでスタックセットを展開するとき、すでにスタックセットがある場合とない場合ではコマンドが変わる(create-stack-set
、update-stack-set
)ため、スタックセット作成のときと更新のときでコマンドを変える必要があります。
③手順の数
上記を対処できる仕組みがあったとしても、それを実現する手順の数が多いと結局大変になってしまうので、簡単・汎用的に行えるのが理想です。
「いい感じ」に対応すること
つまり以下の様なことを盛り込んだ実装にします。
- リージョンは自動で「全リージョン」に展開される
- 作成・更新ごとのコマンドの使い分けをスクリプト内で隠蔽する
- 他のスタックでも簡単に使いまわせるような汎用的なものにする
- そのため、関数にしておきます
前提
- 1アカウント内の複数リージョンへの展開としています
- 複数アカウントへの展開は本記事では対応しません
- 対応リージョン全てに展開するようにしてあります
- 個別にリージョンを選択して展開する処理は本記事では対応しません
実装
コードはGitHubにもあります。
スタックセットデプロイ用関数
- stack_sets_functions.sh
deploy_stack_sets
関数を呼び出せばスタックセットをデプロイできる
#!/bin/bash set -eu function check_stack_set_operation { local stack_set_name="$1" local operation_id="$2" local operation_region="$3" local profile="$4" if [ -z ${stack_set_name} ] \ || [ -z ${operation_id} ] \ || [ -z ${operation_region} ] \ || [ -z ${profile} ]; then echo "Invalid options for check_stack_set_operation function" return 1 fi while true; do local operation_status=$(aws cloudformation describe-stack-set-operation \ --stack-set-name ${stack_set_name} \ --operation-id ${operation_id} \ --region ${operation_region} \ --profile ${profile} \ | jq -r .StackSetOperation.Status) echo "=== STATUS: ${operation_status} ===" if [ "${operation_status}" == "RUNNING" ]; then echo "Waiting for SUCCEEDED..." echo sleep 10 elif [ "${operation_status}" == "SUCCEEDED" ]; then echo "SUCCESS." break else echo "!!!!!!!!!!!!!!!!!!!!!!!" echo "!!! Error Occurred. !!!" echo "!!!!!!!!!!!!!!!!!!!!!!!" return 1 fi done } function deploy_stack_sets { local template_path="$1" local stack_set_name="$2" local operation_region="$3" local parameters="$4" local profile="$5" if [ -z "${template_path}" ] \ || [ -z "${stack_set_name}" ] \ || [ -z "${operation_region}" ] \ || [ -z "${parameters}" ] \ || [ -z "${profile}" ]; then echo "Invalid options for deploy_stack_sets function" return 1 fi check_stack_exists=$(aws cloudformation describe-stack-set \ --stack-set-name ${stack_set_name} \ --region ${operation_region} \ --profile ${profile} 2>&1 >/dev/null || true) if [ -n "${check_stack_exists}" ]; then echo "create stack set..." echo aws cloudformation create-stack-set \ --stack-set-name ${stack_set_name} \ --template-body file://${template_path} \ --parameters $(echo "${parameters}") \ --region ${operation_region} \ --profile ${profile} else echo "update stack set..." echo operation_id=$(aws cloudformation update-stack-set \ --stack-set-name ${stack_set_name} \ --template-body file://${template_path} \ --parameters $(echo "${parameters}") \ --region ${operation_region} \ --query "OperationId" \ --output text \ --profile ${profile}) check_stack_set_operation "${stack_set_name}" "${operation_id}" "${operation_region}" "${profile}" fi stack_instances_regions=$(aws cloudformation list-stack-instances \ --stack-set-name ${stack_set_name} \ --region ${operation_region} \ --query "Summaries[].Region" \ --output text \ --profile ${profile} \ 2>/dev/null || true \ ) regions=$(aws ec2 describe-regions --query "Regions[].RegionName" --region ${operation_region} --output text --profile ${profile}) add_instances_region=() for region in ${regions}; do if [ -z "$(echo ${stack_instances_regions} | grep ${region})" ];then add_instances_region+=( ${region} ) fi done if [ ${#add_instances_region[@]} -ne 0 ];then account_id=$(aws sts get-caller-identity --query "Account" --output text --profile ${profile}) echo "create stack instances..." echo operation_id=$(aws cloudformation create-stack-instances \ --stack-set-name ${stack_set_name} \ --accounts ${account_id} \ --regions ${add_instances_region[@]} \ --operation-preferences MaxConcurrentPercentage=100,FailureTolerancePercentage=100 \ --region ${operation_region} \ --query "OperationId" \ --output text \ --profile ${profile}) check_stack_set_operation "${stack_set_name}" "${operation_id}" "${operation_region}" "${profile}" fi echo "Finished." }
呼び出し元
- サンプルシェル(stack_sets.sh)
- SNSトピックを全リージョンに展開するためのスタックセット
deploy_stack_sets
関数を呼び出す
#!/bin/bash set -eu cd $(dirname $0) . ./stack_sets_functions.sh CFN_TEMPLATE="./sns_for_notification.yaml" PROFILE="" while getopts p: OPT; do case $OPT in p) PROFILE="$OPTARG" ;; esac done if [ -z ${PROFILE} ]; then echo "required PROFILE" exit 1 fi CFN_STACK_SET_NAME="SNSForStacksets" OPERATION_REGION="us-east-1" CFN_NotificationEmailAddress1="test1@testsampleaddress.com" CFN_NotificationEmailAddress2="test2@testsampleaddress.com" CFN_PARAMETERS="\ ParameterKey=NotificationEmailAddress1,ParameterValue=${CFN_NotificationEmailAddress1} \ ParameterKey=NotificationEmailAddress2,ParameterValue=${CFN_NotificationEmailAddress2} \ " deploy_stack_sets "${CFN_TEMPLATE}" "${CFN_STACK_SET_NAME}" "${OPERATION_REGION}" "${CFN_PARAMETERS}" "${PROFILE}"
- サンプルシェル実行
sh stack_sets.sh -p my_profile
解説
stack_sets_functions.sh
function check_stack_set_operation
cloudformation describe-stack-set-operation
で該当スタックセットの情報を見ることができます。--operation-id
とあるように、スタックセットの各オペレーションに対するステータスになります。
ここではjq
でStackSetOperation.Status
を取得している様に、スタックセットのオペレーションステータスを取得します。
スタックセットデプロイ後はしばらくの間RUNNING
状態になるので、成功(SUCCEEDED
)するか失敗するまで10秒ごとにループさせて待機します。
while true; do local operation_status=$(aws cloudformation describe-stack-set-operation \ --stack-set-name ${stack_set_name} \ --operation-id ${operation_id} \ --region ${operation_region} \ --profile ${profile} \ | jq -r .StackSetOperation.Status) echo "=== STATUS: ${operation_status} ===" if [ "${operation_status}" == "RUNNING" ]; then echo "Waiting for SUCCEEDED..." echo sleep 10 elif [ "${operation_status}" == "SUCCEEDED" ]; then echo "SUCCESS." break else echo "!!!!!!!!!!!!!!!!!!!!!!!" echo "!!! Error Occurred. !!!" echo "!!!!!!!!!!!!!!!!!!!!!!!" return 1 fi done
stack_sets_functions.sh
function deploy_stack_sets
まずcloudformation describe-stack-set
でスタック情報を取得し、スタックがあるかどうかを確認します。
スタックがあるかどうかの確認がしたいため、レスポンスの標準エラー出力のみを返し、標準出力は捨ててしまいます(2>&1 >/dev/null
)。
また、存在しないときにexitコードが1で返ってきてset -eu
によって終了するのを防ぐため、|| true
を挟んでいます。
check_stack_exists=$(aws cloudformation describe-stack-set \ --stack-set-name ${stack_set_name} \ --region ${operation_region} \ --profile ${profile} 2>&1 >/dev/null || true)
つまり、 check_stack_exists
に文字列が入った場合、describe-stack-set
によってエラーが出た=スタックセットが存在しないことになります。(profileの権限エラーなど、その他正常でない場合のエラーは起きない前提で書いています。もっと厳格にやるなら、標準出力を取得してレスポンスのデータを見て判断する方が良いですが、今回は簡潔な方法を選んでいます。)
逆に check_stack_exists
に文字列が入っていない場合、何もエラーが起きなかった=スタックセットが存在することになります。
これによって、作成と更新ごとに処理を分岐させます。
ここで、「スタックがある時は更新、ないときは作成」を実現します。この様にして作成(create-stack-set
)と更新(update-stack-set
)でのコマンドを使い分けます。
上記で説明した様に、check_stack_exists
に文字列が入った場合がスタックセットが存在しないことになるので、作成コマンドでスタックセットを作成します。
if [ -n "${check_stack_exists}" ]; then echo "create stack set..." echo aws cloudformation create-stack-set \ --stack-set-name ${stack_set_name} \ --template-body file://${template_path} \ --parameters $(echo "${parameters}") \ --region ${operation_region} \ --profile ${profile}
elseで、つまりcheck_stack_exists
に文字列が入っていない場合はスタックセットが存在することになるので、更新コマンドでスタックセットを更新します。
更新コマンドで返るOperationId
はスタックセットのステータスを確認するために必要なので、operation_id
変数に格納しておきます。
そして先程定義したcheck_stack_set_operation
を呼んで、スタックセットの作成が成功するまで待ちます。
else echo "update stack set..." echo operation_id=$(aws cloudformation update-stack-set \ --stack-set-name ${stack_set_name} \ --template-body file://${template_path} \ --parameters $(echo "${parameters}") \ --region ${operation_region} \ --query "OperationId" \ --output text \ --profile ${profile}) check_stack_set_operation "${stack_set_name}" "${operation_id}" "${operation_region}" "${profile}" fi
スタックセットの作成・更新が終わったら、次はスタックインスタンスの作成に移ります。
スタックインスタンスとは各リージョンごとに存在するスタックへのリファレンスです。スタックセットから生成されたスタックの実体が各リージョンにあるというようなイメージです。
ここで大事なのは「リージョンごと」ということです。
スタックセットのスタックインスタンスの一覧を取得します。--query "Summaries[].Region"
で絞るのでつまり、すでに展開してあるリージョンの一覧が取得できます。
stack_instances_regions=$(aws cloudformation list-stack-instances \ --stack-set-name ${stack_set_name} \ --region ${operation_region} \ --query "Summaries[].Region" \ --output text \ --profile ${profile} \ 2>/dev/null || true \ )
次は、AWSの全リージョンの一覧を取得します。
regions=$(aws ec2 describe-regions --query "Regions[].RegionName" --region ${operation_region} --output text --profile ${profile})
そして、すでに展開してあるリージョンの一覧とAWSの全リージョン一覧とで比較をし、まだ展開していないリージョンを洗い出します。
if [ -z "$(echo ${stack_instances_regions} | grep ${region})" ]
で、展開済みリージョン一覧に対してAWS各リージョンごとにgrepをして、結果がなければ未展開リージョンなのでadd_instances_region
配列に詰める、という処理をします。
add_instances_region=() for region in ${regions}; do if [ -z "$(echo ${stack_instances_regions} | grep ${region})" ];then add_instances_region+=( ${region} ) fi done
まだ展開していないリージョンの一覧が取得できたら、これらのリージョンに対してスタックインスタンスをcreate-stack-instances
で作成します。
update-stack-set
と同じ様にOperationId
が取得できるので、check_stack_set_operation
を呼んで成功するのを待ちます。
if [ ${#add_instances_region[@]} -ne 0 ];then account_id=$(aws sts get-caller-identity --query "Account" --output text --profile ${profile}) echo "create stack instances..." echo operation_id=$(aws cloudformation create-stack-instances \ --stack-set-name ${stack_set_name} \ --accounts ${account_id} \ --regions ${add_instances_region[@]} \ --operation-preferences MaxConcurrentPercentage=100,FailureTolerancePercentage=100 \ --region ${operation_region} \ --query "OperationId" \ --output text \ --profile ${profile}) check_stack_set_operation "${stack_set_name}" "${operation_id}" "${operation_region}" "${profile}" fi echo "Finished."
ちなみに--operation-preferences
のMaxConcurrentPercentage
は「リージョンごとに同時に展開するアカウントの数の割合」、FailureTolerancePercentage
は「リージョンごとに何%のアカウントで失敗したらオペレーションを停止するかの割合」になります。
100
(%)と指定していますが今回は1アカウントにしか展開しないのであまり関係ありません。
また失敗アカウント数がFailureTolerancePercentage
を超えた場合はそのリージョンへの展開が停止され、さらに処理中の他のリージョン展開までも停止してしまいます。
これで関数は完成しました。
上記で挙げたサンプルシェル(stack_sets.sh)のように、引数を用意してdeploy_stack_sets関数を呼ぶとスタックセットによる展開が可能になります。
deploy_stack_sets "${CFN_TEMPLATE}" "${CFN_STACK_SET_NAME}" "${OPERATION_REGION}" "${CFN_PARAMETERS}" "${PROFILE}"
最後に
セキュリティサービス系をスタックセットで全リージョンに展開する際にかなり重宝する、汎用スクリプトを作りました。
基本的にyamlのパスとCloudFormationに渡すパラメータ部分やスタック名を呼び出し元で変えるだけでどのスタックでもスタックセットによって全リージョンへ展開できるようになるため、改善点はあれどもなかなか便利になりました。