CloudFormation Fn::GetAZs を使ったテンプレートが人によって失敗したりしなかったりする件

2020/10/18に公開

概要

CloudFormation において、 Fn::GetAZsFn::Select の組み合わせを使っていると予期せぬエラーに遭遇することがある。

ハンズオンの運営をやっていて、参加者全員が同一のテンプレートを使ったはずなのに、一部の参加者でエラーが発生する事象に見舞われた。

遭遇したテンプレートを一部を抜粋する。マルチ AZ な VPC を構成したい場合にこのようなサブネットの定義の仕方がよく出現する。

  # 該当する記述箇所の例
  PublicSub2:
    Type: 'AWS::EC2::Subnet'
    Properties:
      CidrBlock: 10.0.0.0/24
      AvailabilityZone: !Select
        - '1'
        - !GetAZs 
          Ref: 'AWS::Region'
      VpcId: !Ref VPC

期待としては、「デプロイ先リージョンのすべての AZ をリストして、 1 番目の AZ にサブネットを作成」となってほしい。が、これは期待通りの動作をしない。この謎について調査した。

原因

まずは、 Fn::GetAZs が「デプロイ先リージョンのすべての AZ をリスト」する機能ではないことを把握する必要がある。これについては以下の公式ドキュメントや、同じ罠を踏んだ先人の記事が参考になる。

公式ドキュメントの、この部分の記述が罠。

EC2-VPC プラットフォームでは、Fn::GetAZs 関数はデフォルトのサブネットがあるアベイラビリティーゾーンのみを返します。デフォルトのサブネットがあるアベイラビリティーゾーンがない場合は、すべてのアベイラビリティーゾーンを返します。

つまり、デフォルト VPC が存在するリージョンにおいては、 Fn::GetAZs は最短で長さ1の配列を返す可能性がある。必ずしもすべての AZ を返すとは限らない。このことに留意しておかないと、 Fn::Select で参照したインデックスが配列サイズを超えてしまい、

Template error: Fn::Select cannot select nonexistent value at index 1

のようなエラーを吐き出すことになってしまう。

対応方針

まず、デフォルト VPC が存在しないリージョンでは何もする必要がない(GetAZs がすべての AZ を返すため)。よって、支障がないのであればデフォルト VPC を消してしまうのが一番早い(ただし、自己責任で)。

あるいは、「デフォルトサブネットが存在しない AZ」をなくしてしまう、という方針もある。この対応方針について、コマンドラインを使った諸々の確認手順、そして対応手順を以下で記述する。

対応手順 (デフォルトサブネットの作成)

※ここに記述したコマンドは、 zsh で動作済み。

デフォルトVPCの有無を確認。

aws ec2 describe-vpcs \
  --filters "Name=isDefault,Values=true" \
  --query 'Vpcs[0].VpcId' \
  --output text

上記の結果が null ならば、 GetAZs はすべての AZ を返すため期待通りの動作をする。

VPC-ID が戻った場合はデフォルト VPC が存在するので、ハマる可能性がある。

次に、デフォルトサブネットを確認。

DEFAULT_VPC_ID=$(aws ec2 describe-vpcs --filters "Name=isDefault,Values=true" --query 'Vpcs[0].VpcId' --output text);

aws ec2 describe-subnets \
  --filters "Name=vpc-id,Values=${DEFAULT_VPC_ID}" \
            "Name=default-for-az,Values=true" \
  --query "Subnets[*].{AvailabilityZone: AvailabilityZone,AvailabilityZoneId: AvailabilityZoneId, DefaultForAz: DefaultForAz}"

上記のコマンドを叩いた実行例は以下↓

[
    {
        "AvailabilityZone": "ap-northeast-1a",
        "AvailabilityZoneId": "apne1-az4",
        "DefaultForAz": true
    },
    {
        "AvailabilityZone": "ap-northeast-1c",
        "AvailabilityZoneId": "apne1-az1",
        "DefaultForAz": true
    }
]

ここで出てきた AZ のリストが Fn::GetAZs で取得できるサブネットのリストとなる。しかし、ここから漏れた AZ は Fn::GetAZs では取得できない。

該当する VPC に存在するすべての AZ を列挙し、デフォルトサブネットが存在する AZ のリストと突合する。

# VPC に存在するすべての AZ を列挙
aws ec2 describe-availability-zones --query "AvailabilityZones[*].ZoneName" --output text
# unique count を利用して、デフォルトサブネットが存在しない AZ を調べる
echo \
  $(aws ec2 describe-subnets --filters "Name=vpc-id,Values=${DEFAULT_VPC_ID}" "Name=default-for-az,Values=true" --query "Subnets[*].AvailabilityZone" --output text) \
  $(aws ec2 describe-availability-zones --query "AvailabilityZones[*].ZoneName" --output text) \
  | tr " " "\n" | sort | uniq -c

上記コマンドの実行結果例は以下

   2 ap-northeast-1a
   2 ap-northeast-1c
   1 ap-northeast-1d

カウントが 1 になっている AZ (ここでは ap-northeast-1d) には、デフォルトサブネットが存在していない。

aws ec2 create-default-subnet --availability-zone ap-northeast-1d

以上の作業を実施することで、 Fn::GetAZs ですべての AZ を取得できるようになる。

まとめ

今回の原因である「 GetAZs が少々直感に反した挙動をすること」自体は、すでに多くの先人が踏み抜いたトラブルのようで記事も出回っている。この記事では aws cli を使って調査方法を含めた対処手順をカバーしたつもりなので、私と同じようにハンズオン(等の多数向けのコンテンツ作成)を実施する立場の人は参考にしてもらえると嬉しい。一部のコマンドが zsh の文法を使っているが、それ以外は特にプラットフォームに依存せず実行できるはずだ。

(余談)こういう話があるなら、デフォルト VPC を残しておくことの方が有害にすら見える。以前は「消してもいいけど自己責任ですよ」くらいの温度感だったはず...。

Discussion