CloudFormationスタックセットをいい感じにデプロイする

CloudFormationスタックセットをデプロイするのに一手間かかったので、色々なスタックセットに使いまわせる汎用的なスクリプトを作ってみました。


やりたいこと

  • CloudFormationスタックセットを作るときに、「いい感じ」にデプロイする


CloudFormation StackSetsとは

ざっくり言うと、「1つのCloudFormationスタックを複数アカウント・複数リージョンにまとめてデプロイするもの」です。詳細は以下の公式ドキュメントを参照してください。

AWS CloudFormation StackSets は、複数のアカウントおよびリージョンのスタックを 1 度のオペレーションで、作成、更新、削除できるようにすることで、スタックの機能を拡張します。

docs.aws.amazon.com


「いい感じ」にデプロイ?

スタックセットでかかる手間

①リージョン選択

スタックセットを利用するユースケースの一つにセキュリティ系のサービスがあるかと思いますが、このようなサービスは全リージョンに設定することが推奨されています。

それをいちいちリージョンを選択してデプロイするのも大変で、また大阪リージョンの様にリージョン自体が後から追加されるケースもあるので、そのようなときは再デプロイすれば勝手に反映してくれるというのが理想。

②作成・更新ごとのコマンドの使い分け

さらにCLIでスタックセットを展開するとき、すでにスタックセットがある場合とない場合ではコマンドが変わる(create-stack-setupdate-stack-set)ため、スタックセット作成のときと更新のときでコマンドを変える必要があります。

③手順の数

上記を対処できる仕組みがあったとしても、それを実現する手順の数が多いと結局大変になってしまうので、簡単・汎用的に行えるのが理想です。

「いい感じ」に対応すること

つまり以下の様なことを盛り込んだ実装にします。

  • リージョンは自動で「全リージョン」に展開される
  • 作成・更新ごとのコマンドの使い分けをスクリプト内で隠蔽する
  • 他のスタックでも簡単に使いまわせるような汎用的なものにする
    • そのため、関数にしておきます


前提

  • 1アカウント内の複数リージョンへの展開としています
  • 対応リージョン全てに展開するようにしてあります
    • 個別にリージョンを選択して展開する処理は本記事では対応しません


実装

コードはGitHubにもあります。

github.com


スタックセットデプロイ用関数

  • 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とあるように、スタックセットの各オペレーションに対するステータスになります。

ここではjqStackSetOperation.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


スタックセットの作成・更新が終わったら、次はスタックインスタンスの作成に移ります。

タックインスタンスとは各リージョンごとに存在するスタックへのリファレンスです。スタックセットから生成されたスタックの実体が各リージョンにあるというようなイメージです。

ここで大事なのは「リージョンごと」ということです。

docs.aws.amazon.com


スタックセットのスタックインスタンスの一覧を取得します。--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-preferencesMaxConcurrentPercentageは「リージョンごとに同時に展開するアカウントの数の割合」、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に渡すパラメータ部分やスタック名を呼び出し元で変えるだけでどのスタックでもスタックセットによって全リージョンへ展開できるようになるため、改善点はあれどもなかなか便利になりました。