Hubs Cloud で学ぶ AWS CloudFormation 実践入門
インフラの駒崎です。
AWS CloudFormation について、コードリーディングならぬテンプレートリーディング的な形で拾い読みしながら理解を深めていきたいと思います。今回題材とするのは、弊社グループ GREE VR Studio Laboratoryでも利用している Mozilla 製品 Hubs Cloud の構築テンプレートです。
AWS CloudFormation とは
AWS CloudFormation は、テンプレートと呼ばれる設定ファイルを使って AWS リソースを作成 & 設定できるサービスです。いわゆる Infrastructure as Code サービスで、どういったものかは簡単な設定ファイルの例を見ていただくのがわかりやすいかと思います。
| 
					 1 2 3 4 5  | 
						Resources:   HelloBucket:     Type: AWS::S3::Bucket     Properties:       AccessControl: PublicRead  | 
					
ここには、「S3 バケットを 1 つ用意し、その設定は公開アクセス可能である」といった内容が書かれています。この設定ファイルを Web コンソールや CLI で CloudFormation サービスに渡すと、実際の S3 バケットを作成、設定してくれます。ここには S3 バケット 1 つだけしか書かれていませんが、EC2、VPC、IAM など複数の AWS リソースを並べて書いて一度に作ることもできます。
テンプレートとスタック
CloudFormation の概念で最低限覚える必要があるのは、「テンプレート」と「スタック」です。テンプレートは前述の例のように「こういう状態にしたい」という宣言を書いたものです。設計図に相当します。スタックはその設計図をもとに AWS リソースを実体化するものです。基本的には、一つのスタックを作る際に、そのスタックが使うテンプレートを一つ指定します。
役立ちどころ
CloudFormation を活用することで、一般的には次のような利点が挙げられます。
- 複数リソースをまとめてコード管理できる
- 本稿の題材のような、環境一式をまとめたテンプレートを作れる
 
 - 宣言的な記述のため構築順序や中間状態を気にしなくてよい
 - 同じテンプレートから複数のアカウントやリージョンに容易に環境を複製できる
 
他には、CloudFormation は AWS CDK や AWS Copilot、 AWS SAM といった上位ツールのエンジンとして使われるケースもあります。直接 CloudFormation を書かない場合でも、動作を知っておくことでそういったツールの挙動を追いかける際にも役に立つかと思います。
近い位置づけのツールとして Terraform by HashiCorp が挙げられると思いますが、本稿ではその差異については扱いません。
Hubs Cloud AWS と CloudFormation
本稿で取り上げるのは Hubs Cloud AWS という Mozilla のプロダクトです。Hubs は VR コラボレーションプラットフォームとされており、その Hubs をユーザの AWS 環境上に簡単に構築できるようパッケージ化されたものが Hubs Cloud AWS です。グリーグループでは、GREE VR Studio Laboratory を中心に Hubs の PoC 開発や公開実験、ドキュメント発信を行っています。 CEDEC2022 でも Hubs 関連で パネルディスカッション、チュートリアル の 2 講演が予定されておりますのでこちらもよろしくお願いします。

このプロダクトは AWS マーケットプレイスで CloudFormation テンプレートの配布という形で展開されています。このテンプレートは CloudFormation の機能をふんだんに活用したものになっており、実例として参考になるものだと思いましたので、今回このテンプレートから設定をピックアップして紹介させていただきます。
なお、Hubs Cloud AWS を動かすには有料のサブスクリプション登録が必要ですが、テンプレート自体はマーケットプレイスページのリンクから自由に参照可能です。またマーケットプレイス配布物のベースになっているテンプレートが GitHub mozilla/hubs-ops に公開されています。マーケットプレイスのものと GitHub 上のソースには細部の違いはありますが、今回リーディングに取り上げる範囲ではどちらを参照していただいても問題ありません。本稿時点で参照しているテンプレートは以下のバージョンです。
- マーケットプレイス : Hubs Cloud Enterprise v1.1.4 Multi-Server (link)
 - GitHub : github.com/mozilla/hubs-ops/cloudformation/stack.yaml (1c3e15e)
 
基本編
では、ここからは実際のテンプレートの一部を見ながら使い方を確認していきます。シンプルな例から順番に見ていこうと思います。
リソース宣言
まずは作りたいリソースを宣言するところからです。 以下の例は VPC を作成するための宣言です。トップに Resources: というセクション指定があり、その下の VPC 以下に様々な記述が並んでいます。テンプレートのトップレベルには、いくつかの決められたセクションを記述する必要がありますが、中心となるのがこの Resources セクションです。構築、設定したい AWS リソースを列挙するセクションです。
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13  | 
						Resources:   VPC:                     # "VPC" がこのリソースの論理ID     Type: AWS::EC2::VPC    # AWS リソースタイプ VPC を指定     Properties:       CidrBlock:         Fn::Sub: 10.${ClassB}.0.0/16    # Fn::Sub は組み込み関数       EnableDnsSupport: true       EnableDnsHostnames: true       InstanceTenancy: default       Tags:         - Key: Name           Value:             Fn::Sub: ${AWS::StackName} 10.${ClassB}.0.0/16    # ${AWS::*} や ${ClassB} は変数  | 
					
ここで VPC の部分が論理IDとよばれ、リソースごとに付ける名前を表します。これはテンプレート内で一意な値にする必要があります。次行の Type: AWS::EC2::VPC の指定により、このリソースは VPC を作成するものであることを示しています。その次の Properties: 以下でこの VPC の細かな設定値を指定しています。
Resources セクション以下はこのように、 論理ID: { Type: 作成するリソースタイプ, Properties: 設定値 } を並べていくのが基本になります。
またこのブロックには Fn::Sub や ${ClassB} のような特殊文字が含まれており、スタック作成時まで確定しない情報をテンプレート内で柔軟に扱えるようになっています。こういった仕組みについても次節以降で見ていきます。
組み込み関数とパラメータ
Fn:: で始まるのは CloudFormation の組み込み関数です。 Fn::Sub 関数は、文字列中の ${MyVar} のような部分を変数とみなして実行時に実際の値に置き換えます。先程の例では ${AWS::StackName} と ${ClassB} の 2 つの変数が使われていました。
${AWS::*} は CloudFormation で事前定義されたパラメータです。疑似パラメータと呼ばれます。${AWS::StackName} は作成されたスタックの名前が入ります。他にも ${AWS::AccountId} や ${AWS::Region} などがあります。
もう一方の ${ClassB} は、ここではテンプレート中の Parameters セクションにてユーザ定義された変数です。以下のように定義されています。
| 
					 1 2 3 4 5 6  | 
						Parameters:   ...   ClassB:     Type: String     Description: VPC ClassB (10.XX.0.0/16) block to use for internal server IPs.     Default: 0  | 
					
Parameters セクションは実行時にテンプレートに渡すパラメータを定義する場所です。各パラメータのデフォルト値を指定したり、値をパターンや数値範囲で制限したりできます。この例では Type: は String ですが、他にも Number やカンマ区切りリスト、AWS の特定のリソースタイプなどを指定できます。
リソース間の参照
もし、あるリソースのプロパティが、同時に作成される別のリソースの ID に依存している場合はどうすればよいでしょうか。以下の例では、Subnet を作る際に VPC ID を指定する必要がありますが、VPC ID は実際に AWS 上に VPC が作成されるまで決定しません。こういったケースでは組み込み関数 Ref や Fn::GetAtt を使用して、別のリソースの値を取得することができます。
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34  | 
						Resources:   ...   SubnetAPrivate:     # このリソースの論理IDは "SubnetAPrivate"     Type: AWS::EC2::Subnet     Properties:       ...       CidrBlock:         Fn::Sub: 10.${ClassB}.16.0/20       VpcId:         Ref: VPC    # 別途宣言してある 論理ID "VPC" を組み込み関数 Ref で参照   ...   AppDbSubnet:     Type: AWS::RDS::DBSubnetGroup     Properties:       ...       SubnetIds:         - Ref: SubnetAPrivate     # 論理ID "SubnetAPrivate" を組み込み関数 Ref で参照         - Ref: SubnetBPrivate     ...   AppDb:     Type: AWS::RDS::DBCluster     Properties:       AvailabilityZones:         - Fn::GetAtt: SubnetAPrivate.AvailabilityZone     # 論理ID SubnetAPrivate を組み込み関数 Fn::GetAtt で参照         - Fn::GetAtt: SubnetBPrivate.AvailabilityZone       BackupRetentionPeriod:         Ref: DbBackupRetentionPeriod     # パラメータ "DbBackupRetentionPeriod" を Ref で参照       DatabaseName: polycosm_production       ...       DBSubnetGroupName:         Ref: AppDbSubnet       Engine: aurora-postgresql       ...  | 
					
Ref や Fn::GetAtt が返す値は対象とするリソースによって様々ですが、ほとんどの場合 Ref では ID や名前といった一意の文字列、Fn::GetAtt ではリソースに付随した属性を取得します。
SubnetAPrivate リソースは、同じテンプレート内に定義された VPC リソースを Ref で参照し VPC ID を取得しています。AppDbSubnet リソースは、同様に Ref: SubnetAPrivate とし、Subnet ID を取得しています。AppDb リソース中では、Fn::GetAtt により subnet の AZ を取得しています。
取得できる属性は追加されることもあるので、最新の仕様は公式のテンプレートリファレンスを都度ご参照ください。
参照と依存関係
Ref や Fn::GetAtt で参照されたリソース間には、暗黙的な依存関係が構築されます。多くの場合リソースの作成順序は CloudFormation が自動的に管理してくれます。ただし、間接的な依存が隠れていたりユーザが順序をコントロールしたいケースでは DependsOn を使って依存を明示することもできます。Hubs Cloud テンプレートでは、VPC に internet gateway がアタッチされるのを他のリソースで待ったり、後述するカスタムリソース間の依存を明示する場合などに使われています。
応用編
続いて、パッケージプロダクトらしい (?) ちょっと複雑な部分を紹介していきます。
条件によるリソース構成の切り替え
Hubs Cloud AWS は、新規構築時はもちろん CloudFormation を用いるわけですが、簡単な運用もスタックのパラメータ変更操作から行えるように作られています。例えば、EC2 やデータベースのキャパシティを変更したり、予算アラートを設定したりするのもスタック更新から行います。
運用オペレーションの一つにオフラインモードというものがあります。オフライン切り替え用のパラメータが用意されており、スタック更新でこのパラメータを有効 (オフライン) 化すると LB と EC2 が停止された構成に切り替わったうえで DNS や CDN が返すエラーページが切り替わります。(アプリが使用されていないときのコスト削減などの目的で使用されます。)
パラメータによって構成を切り替えたい場合には Conditions セクションとリソースの Condition 属性などを利用できます。
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34  | 
						Conditions:      # 各種条件を定義するセクション   ...   IsOnline:      # 条件: ${StackOffline} == "Online"     Fn::Equals:       - Ref: StackOffline       - Online   IsOffline:          Fn::Not:       - Condition: IsOnline   ...   HasALB:        # 条件: ((${IsOnline} OR ${Perform...}) AND ${RequestedALB}     Fn::And:       - Fn::Or:           - Condition: IsOnline           - Condition: PerformOfflineRedirectWithUnmanagedDomain       - Condition: RequestedALB ... Resources:   ...   AppALB:     Type: AWS::ElasticLoadBalancingV2::LoadBalancer     Condition: HasALB       # ${HasALB} が真のときのみ有効なリソース   ...   AppASG:     Type: AWS::AutoScaling::AutoScalingGroup     ...     Properties:       ...       DesiredCapacity:         Fn::If:             # if (${IsOffline}); then 0; else ${AppInstanceCount}           - IsOffline                 - 0           - Ref: AppInstanceCount  | 
					
こちらは Load balancer と EC2 を構成する Auto scaling group リソースの定義の抜粋です。まず Conditions セクションにはいくつかの条件が定義されており、ユーザが与えるパラメータに応じて真偽値を返すようになっています。Resources セクションでは、これらの条件を参照してリソースの存在やプロパティの値をコントロールしています。
AppALB リソース中では、条件 HasALB の値によってリソースそのものの有無を切り替えています。リソースの Condition 属性が偽の場合、そのリソースは無いものとして扱われます。
もう一つの AppASG リソース中では、リソースそのものを切り替えるのではなく、プロパティの一つである DesiredCapacity (起動する EC2 インスタンス数に相当するもの) の値を IsOffline 条件によって変更しています。IsOffline が真の場合はグループの容量をゼロにして EC2 インスタンスが起動しないようにしています。
CloudFormation は、スタックによって作成された実リソースをタグによって管理しています。先の AppALB リソースの場合では、スタック内で過去に作成されていたが更新によってテンプレートから消える、ということが起こるわけですが、そのような場合は実リソースがテンプレートに合わせて削除されることになります。
削除時のデータ保持
スタックを削除したり、テンプレートからリソースの宣言を消してスタックを更新すると、デフォルトでは対応する実リソースが CloudFormation によって削除されます。残しておきたい場合には DeletionPolicy 属性を指定しておく必要があります。Hubs Cloud AWS ではアプリケーションデータが保持されるリソースなどに、スタック削除時もデータを残すような指定がされています。
スタック削除後も実リソースを残すには DeletionPolicy: Retain を指定します。以下の例では、Elastic File System (EFS) とバックアップボールトにこの指定がされています。
| 
					 1 2 3 4 5 6 7 8 9 10 11 12  | 
						Resources:   ...   StorageEFS:     Type: Custom::EFS     DeletionPolicy: Retain     Properties:   ...   DailyBackupVault:     Type: AWS::Backup::BackupVault     DeletionPolicy: Retain     Properties:  | 
					
通常、EFS を作成する場合は Type: AWS::EFS::FileSystem が使えますが、ここでは後述するカスタムリソースを使って Custom::EFS と独自のリソース定義をしています。これは、新規で Hubs を立ち上げる場合も、過去の Hubs のバックアップから EFS をリストアする場合も両方同じ StorageEFS リソースで対応できるようにしているためです。
本題とは外れますが、リソースに DeletionPolicy: Retain をつけた上で元のスタックのテンプレートから削除し、別のスタックからインポートすることで (全てではないですが) リソースを別のスタック管理下に移動することができます。手間はかかりますが、リファクタリングに利用可能な機能です。
カスタムリソースで任意の処理を行う
既存のリソースでサポートされない処理を行いたい場合は、任意のロジックを実行するカスタムリソースを定義する事ができます。ざっくりと言えば、カスタムリソースは次のように動作します。
- テンプレートにカスタムリソースが定義される
- カスタムリソースの ServiceToken に通知先の SNS または Lambda の ARN を指定する
 
 - CloudFormation はカスタムリソース実行時に、通知先にリクエストオブジェクトを送信する
- リクエスト中には待ち受け URL が含まれる
 - CloudFormation はその待ち受け URL への書き込みを待つ
 
 - 通知を受けた側は何らかの処理を行い、待ち受け URL へ結果を書き込む
 - CloudFormation が書き込まれた結果を読み取る
 
Hubs Cloud AWS のテンプレート中でも、これでもかと言わんばかりにカスタムリソースが使われています。以下は、スタック名を小文字にして返すカスタムリソースの例です。Lambda によって処理されています。
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28  | 
						Resources:   ...   ToLower:     Type: AWS::Lambda::Function     Properties:       ...         Description: Convert a string to lower       Runtime: nodejs14.x       Code:         ZipFile: |           // 引数から "String" の値を受け取って           // { "Value": "小文字化された文字列" } な JSON を含むデータを S3 に PUT するコード           ...   ...   LowerStackName:     Type: Custom::ToLower     Properties:       String:               # Lambda 関数にわたす引数         Fn::Sub: ${AWS::StackName}       ServiceToken:         # Lambda 関数を ARN で指定する         Fn::GetAtt: ToLower.Arn   ...   AppAssetsDNS:     Type: AWS::Route53::RecordSet     Properties:       Name:      # ↓ 別のリソースからカスタムリソースの値を参照する                Fn::Sub: ${LowerStackName.Value}-assets.${InternalZoneInfo.Name}       ...     | 
					
ToLower.Properties.Code 部分がロジックの本体になります。nodejs で文字列を加工する処理が書かれています。出力は event 引数で指定された S3 URL に特定のフォーマットで書き出します。今回は、{ "Value": "文字列" } という形を出力しています。
Lambda 関数を呼び出すのがカスタムリソース LowerStackName です。プロパティ中で、String の値を Fn::Sub を使って与えており、この値が Lambda 関数に event 引数の一部として渡されます。
最終的にカスタムリソース LowerStackName は他のリソースから Fn::Sub: ${LowerStackName.Value}... のように参照され、小文字化されたスタック名文字列として使われています。ちなみに本稿では初出の記法ですが、この例のような Fn::Sub 中の ${リソース名.属性名} 記法は Fn::GetAtt で取得できるものと同じ値を取得します。
Hubs Cloud AWS でのカスタムリソース使用例
上記の例の他にも、Hubs Cloud では以下のような用途でカスタムリソースを使っています。
- URL 文字列のパース
 - EFS を新規またはバックアップからのリストアで作成
 - SES を SMTP で使う際の認証パスワードを IAM ユーザの鍵から生成
 - 入力されたドメイン文字列から Route53 ゾーン ID を検索
 
※ Hubs Cloud AWS はマーケットプレイス製品でテンプレート中に全部盛りの構成を取っている都合上、カスタムリソースを駆使して CloudFormation に機能を詰め込んでいるのかと思います。自組織内で使うだけであれば、あまり機能を詰め込むよりも別途適切なソリューションを組み合わせるほうが運用には向いているかと思います。
おわりに
Hubs Cloud AWS というプロダクトで実際に使われているテンプレートを引用しながら、CloudFormation のノウハウの一部を紹介させていただきました。紹介できなかった内容もまだまだたくさんあるのですが、テンプレートを読む手がかりとなれば幸いです。
CloudFormation は便利ですが、スタック外での変更にあまり強くない、ドリフト検知の範囲が限られているなど弱点もあります。日常的な CloudFormation の使用においては、冒頭にもあげた上位ツールの活用であったり、例えば構築フェーズに限定するなど、ケースバイケースで無理のない範囲で活用できるとよいかと思います。
関連資料
- AWS Marketplace Hubs Cloud Enterprise
 - AWS CloudFormation ユーザーガイド
 - GitHub mozilla/hubs-ops
 - Mozilla Hubs
 - Hubs | GREE VR Studio Laboratory
 
