リージョン間のCloudFormationクロススタック参照を実現する

f:id:fullstuck_sato:20201012142231p:plain:w150

こんにちは。マネックス・ラボでferciを担当している佐藤です。今回は、AWSのCloudFormationでクロススタック参照をする時に使うImportValueの制約の一つ、リージョンを跨いでの参照ができない問題を解決する方法について書きたいと思います。

要約

 スタックからエクスポートした値はリージョン固有のリソースですので、別リージョンからはImportValueを使ってのエクスポート値の参照はできません。そこで、LambdaとSSMパラメータストアを使って参照できるようにしてみます。

特定のリージョンにしか作れないリソースの存在

 これをやってみようと思ったきっかけは、一部のAWSリソースはバージニア北部リージョンに作成する必要があり、そのリソースを東京リージョンのCloudFormationスタックからImportValueで参照できないことが不便に感じていたことにあります。

 特に、下記の記事のように、WAFv2とCloudFrontの繋ぎこみをする時に、バージニア北部リージョンで作ったWAFv2のスタックからARNをコピーして、東京リージョン側でCloudFrontを作成する際に、手動でパラメータを設定する必要があったりします。

【小ネタ】AWS WAF v2 の WebACL (CloudFront用)を東京リージョンから CloudFormation で作成しようとしたら怒られた | DevelopersIO

AWS WAF v1 と v2 それぞれで WebACL を CloudFormation で作成したときにハマった話 - michimani.net

 これらのブログ記事で困っていることを整理すると、下図のようになります。CloudFormationで自動化をしようとしているものの、部分的に手作業が必要になっています。

f:id:fullstuck_sato:20201012113206p:plain:w800

 他にもCloudFrontにアタッチするACMやLambda@Edgeといったリソースはバージニア北部で作成しなければならず、CloudFrontを東京リージョンのCloudFormationスタックで作成したい場合には、パラメータの受け渡しで悩むことがあります。そこで、AWSの機能を組み合わせて、これを自動化できないか検証してみました。

やることを整理する

 まず、問題をシンプルにするために、リージョンを2つに限定してしまいます。バージニア北部リージョンで作成したCloudFormationスタックのエクスポート値を、東京リージョンでスタックを作成する時の引数に渡すことを考えます。また、WAFとCloudFrontはデプロイに時間がかかり、検証しづらいので、バージニア北部リージョンではパラメータのエクスポートのみを行い、東京リージョンではS3バケットを作成し、バケットのタグにバージニア北部のエクスポート値をセットすることを考えます。下図の2の部分を自動化することを考えます。

f:id:fullstuck_sato:20201012124046p:plain:w800

検証用スタックのテンプレート

 これら2リージョンの検証用リソースを作成するため、下記のCloudFormationテンプレートを用意しました。

バージニア北部リージョンのテンプレート(virginia_region.yaml)
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  # エクスポートするパラメータ。
  pOutputParameter:
    Type: String
    Default: "virginia-region"

Resources:
  # ダミーのリソース。何かしらリソースを作らないとスタックを作成できないため。
  rDummyIamGroup:
    Type: AWS::IAM::Group
    Properties:
      Path: /
      GroupName: rDummyIamGroup

Outputs:
  pOutputParameter:
    Value: !Ref pOutputParameter  # 値をそのままエクスポートする。
    Export:
      Name: pXROutputParameter  # pXRで始まるところがポイント。後述します。

リソースを何も作らないスタックは作成できないので、ダミーでIAMグループを作成しています。短時間で作成できるリソースであれば何でも良いです。

東京リージョンのテンプレート(tokyo_region.yaml)
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  pXROutputParameter:
    # TypeがStringでない理由は後述します。
    Type: AWS::SSM::Parameter::Value<String>
    Default: 'pXROutputParameter'

Resources:
  rS3Bucket:
    Type: 'AWS::S3::Bucket'
    DeletionPolicy: Retain
    Properties:
      AccessControl: 'Private'
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
      BucketName: "cross-region-parameter-test-bucket"
      Tags:
        # スタック作成時に入力されたパラメータをタグにセットする
        - Key: 'CrossRegionValue'
          Value: !Ref pXROutputParameter

pXROutputParameterを入力とし、バケットのタグに設定しています。ここに、VirginiaRegionStackでエクスポートしたpXROutputParameterの値を自動で設定することが目標です。

実装方法を考える

バージニア北部リージョンのエクスポート値を集める手段

 CloudFormationのスタックが変更された時点でLambdaを呼び出し、スタックからエクスポートされた値をそのLambdaで収集して、東京リージョンから参照できる場所に出力することを考えます。Lambdaでは、SDK(今回はPythonでboto3を使います)を使って全スタックがエクスポートした値を取得することができます。また、CloudFormationのスタック変更をトリガーにしたLambdaの起動は、SNSトピックを作成し、スタックのNotification optionsにトピックを設定することで実現できます。

 エクスポートする値には、命名規則を決めて、一部の値だけを東京リージョンに転送できるようにします。今回は、先頭に「pXR」がついたパラメータを対象にしてみます。私のチームでは、CloudFormationテンプレートをハンガリアン記法で書くルールにしているためpをつけつつ、クロスリージョンを略して、「pXR」としました。

東京リージョンのSSMパラメータストアへの追加

 上記のLambdaから、東京リージョンのssmを指定して上記のpXRで始まるパラメータを追加してあげれば実現できます。

東京リージョンのスタックでのパラメータの参照

 CloudFormationでSSMパラメータを参照するには、パラメータタイプでAWS::SSM::Parameter::Valueを指定する方法と、DynamicReferences(’{{resolve:ssm:parameter-name:version}}'のような記述でSSMパラメータを参照する)のいずれかを使用すると実現できます。比較すると、DynamicReferencesの方はパラメータのバージョンを指定しなければならないあたりが、自動化の妨げになりそうです。

 仮にパラメータが更新されるたびにバージョンが上がるとなると、東京リージョン側のCloudFormationテンプレートに書かれているバージョン番号を毎回書き換えなければなりません。これを回避するには、毎回SSMパラメータを削除して追加することによりバージョンを1に固定することができますが、パラメータが存在しない瞬間があるというのはあまり好ましいことではありません。そこで、DynamicReferencesは諦めて、パラメータタイプにAWS::SSM::Parameter::Valueを指定してSSMの値を参照することにします。これであれば、パラメータをどんどん更新してバージョンが上がっても、CloudFormationテンプレートを修正する必要がありません。

実装方針の整理

 概ね方針が決まったので、実装していきます。今回作るのは、下記のようなアーキテクチャです。

f:id:fullstuck_sato:20201012130500p:plain:w800

 これらを実装し、バージニア北部の入力パラメータpOutputParameterを変更した上でスタックを更新し、手動でのコピペなしに、東京リージョンのスタックに反映できれば成功です。では実装してみましょう。

実装

バージニア北部リージョンのスタック(VirginiaRegionStack)デプロイ

 上記のvirginia_region.yamlを使ってCloudFormationでVirginiaRegionStackを作成します。

VirginiaRegionStackがエクスポートした値を収集するLambda(CFnExportedValueCollector)を作成する

 今回は検証なので、SAMもServerlessも、CloudFormationも使わずにGUIから作ってしまいます。下記のようなPythonコードを書きました。Pythonのバージョンに依存するような内容ではありませんが、Python3.8を使っています。

import boto3

cloudformation = boto3.client('cloudformation')
s3 = boto3.client('s3')

# パラメータを転送する対象のリージョンリストと、そのSSMクライアント。
target_region = ['ap-northeast-1']
ssm_client_list = [boto3.client('ssm', region_name=region) for region in target_region]

def put_parameter_to_ssm(key:str, value:str):
    # パラメータの追加。
    for ssm_client in ssm_client_list:
        ssm_client.put_parameter(
            Name=key,
            Value=value,
            Type='String',
            Overwrite=True
        )

def lambda_handler(event, context):
    # CloudFormationスタックによってエクスポートされた値を全て取得する。
    result = cloudformation.list_exports()
    export_list = result['Exports']
    
    # pXRで始まるパラメータだけをフィルタする。
    export_list = [export for export in export_list if export['Name'].find('pXR') == 0]
    
    # pXRで始まるパラメータだけをSSMに追加する。
    for export in export_list:
        put_parameter_to_ssm(key=export['Name'], value=export['Value'])

なお、Lambdaの実行ロールに対して、下記の権限を追加する必要があります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "cloudformation:ListExports"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "ssm:PutParameter",
                "ssm:GetParameter",
                "ssm:DeleteParameter",
                "ssm:DescribeParameters"
            ],
            "Resource": [
                "arn:aws:ssm:ap-northeast-1:<自分のAWSアカウント番号>:parameter/pXR*"
            ]
        }
    ]
}

VirginiaRegionStackがデプロイされたことを知るためのSNSトピック(CFnNotificationTopic)を作成する

 エクスポート値を収集するLambdaを起動するためのトリガーが必要なので、VirginiaRegionStackが変更されたことを知るためのSNSトピックとサブスクリプションを作成します。今回は検証なので、GUIからポチポチと作成してしまいます。

f:id:fullstuck_sato:20201012131533p:plain:w800

 

VirginiaRegionStackに、CFnNotificationTopicを設定する

 VirginiaRegionStackを選んでUpdateをします。Notification optionsで、上記で作成したCFnNotificationTopicを指定します。これで、スタックを変更するたびにLambdaが実行されるようになりました。

f:id:fullstuck_sato:20201012131656p:plain:w800

VirginiaRegionStackの値を変更してみる

 スタックのパラメータpOutputParameterをvirginia-regionから、virginia-region-updatedという文字列に変更して、更新をかけてみます。

f:id:fullstuck_sato:20201012131801p:plain:w800

東京リージョンのSSMパラメータストアを確認する

 東京リージョンに戻り、SSMパラメータストアを見てみます。pXROutputParameterという名前のパラメータがあり、値がvirginia-region-updatedに変わっていれば成功です。

f:id:fullstuck_sato:20201012131923p:plain:w800

東京リージョンに検証用スタック(TokyoRegionStack)をデプロイする

 東京リージョンのCloudFormationを開いて、tokyo_region.yamlをアップロードしてTokyoRegionStackを作成します。パラメータタイプがAWS::SSM::Parameter::Valueの場合には、SSMパラメータの名前を設定しておけば、その値を拾ってきてくれます。tokyo_region.yamlテンプレートにデフォルト値として記述してあるので、入力する必要はありません。

f:id:fullstuck_sato:20201012133134p:plain:w800

S3バケットが作成され、タグにCrossRegionValue: virginia-region-updatedが追加されていれば成功です。

f:id:fullstuck_sato:20201012132101p:plain:w800

以上で、実装は完了です。

自動化のテスト

 バージニア北部でのエクスポート値の変更を、コピペなしに東京リージョンに反映できることを確認してみます。

VirginiaRegionStackのパラメータの変更

 VirginiaRegionStackの入力パラメータpOutputParameterをpropagation-testに変更して、更新をかけます。

f:id:fullstuck_sato:20201012132318p:plain:w800:

東京リージョンへの伝搬の確認

 VirginiaRegionStackの更新が終わったら、東京リージョンのSSMパラメータが反映されているかどうか確認してみます。

f:id:fullstuck_sato:20201012132440p:plain:w800

無事に更新されています。なお、スタックの更新が13:23:49に完了しており、SSMパラメータの更新時刻も13:23:49ですので、一瞬で反映されているのがわかります。  

TokyoRegionStackの更新

 東京リージョンで、TokyoRegionStackを更新します。スタックを選んでUpdateを押したあとは、何も気にせずNextボタンをポチポチ押すだけです。パラメータのコピペは不要です。

S3バケットのタグの確認

 TokyoRegionStackの更新が終わったら、検証用に作ったS3バケットのタグに、CrossRegionValue: propagation-testが反映されているか確認します。

f:id:fullstuck_sato:20201012133034p:plain:w800

きちんと反映されていますね。

実装してみて

 思いついた時は、黒魔術的な感じで運用には耐えないような気がしていたのですが、実際に作ってみると、バージニア北部のスタックを更新するとすぐに東京リージョンのSSMパラメータに反映されますし、何より手でコピペしなくて済むというメリットが生まれました。手作業は事故の元ですので、これは大きな成果です。また、エクスポート値を収集するLambdaはリージョンに一つあればよく、CloudFormationスタックが増えた場合でも、そのスタックのNotificationOptionsにSNSトピックを設定するだけで良いので、かなり楽に運用できます。収集した値を東京以外のリージョンのSSMパラメータに追加したい場合も、Pythonスクリプトのリージョン一覧と、Lambdaの実行ロールにアクセス許可しているResourceを追加するだけで済みます。

 問題があるとすれば、エクスポート値が無くなった時にSSMにゴミが残っていくことと、エクスポートされたわけではない、他の目的で作成されたpXRで始まるパラメータを上書きしてしまう可能性が残っています。ゴミが残る問題は、一日に一回程度、エクスポート値とSSMを比較して、余計なパラメータを削除するような実装をすれば改善できるかと思います。pXRで始まるエクスポート値ではないパラメータを上書きする問題は、SSMパラメータにタグをつけることができますので、そのSSMパラメータが今回実装したLambdaによって作られたものなのかを検証することで、ある程度は防げるのではと思います。

最後に

 今回の実装は最終的にはかなりシンプルなものになりましたが、実際には途中でハマったりもしました。例えばCloudFormationのDynamicReferencesを使おうとして、実際にCloudFormationのテンプレートを書こうとしたところでバージョン指定が必要であることを知ったり、一方でパラメータのタイプにAWS::SSM::Parameter::Valueがあり、それが最新版のパラメータを参照してくれることだったり。数時間使って、ちょっとした実装をやってみるだけでも自分にとっての新しい発見があったりします。

 インフラに限らずコーディングもそうですが、思いついたらとりあえずエイっと作ってみることがとても大事なのではないかと思います。最近はOSSのライブラリやツールが充実してしまって、何か作ろうと思ったらもうあった、なんていうことはしょっちゅうです。しかし、それでは何もコーディングできず、いざ本当に新しいアイディアを実現しようと思ったら、しばらくコードを書いていなくて何も作れない、ということになってしまいます。

 多少の時間を割いて、車輪の再発明と言われようが、それGitHubにあるよ、と言われようが、気にせずゴリゴリとコードを書き続けることが、一番大事なのではないかと思います。

最後に話がそれましたが、今回は以上です。少しでも実装の参考になれば幸いです。

佐藤 俊介マネックス・ラボ