タイトルそのままですが、AWS CloudFormation を使ってみた感想です。
きっかけ
AWS LambdaをURLから呼び出す時は、AWS API GatewayのGUIを使って、「このURLのこのメソッドの時はこのLambdaを呼び出す」などのマッピングをポチポチやっていくのですが、これが地味に面倒です。
最初のうちは物珍しくて面白いのですが、同じ様な作業の繰り返しになり、そのうち飽きて面倒になってきます。
GUIでの操作は手作業のため、何かと抜け・漏れ・ミスが発生しやすく、また、作業手順を記録して後で再現するのも注意を要します。
そんな時こそ AWS CloudFormation を使えばいいのでは!?と思い試してみました。
AWS CloudFormationとは?
AWSの各種サービスは、GUIやコマンドラインツールで作成・設定するのですが、サービスによってやり方はマチマチです。
CloudFormationはそれらの設定を、統一されたフォーマットのJSON・YAMLのファイル(=テンプレート)にして、そのテンプレートを元にサービスを一気に作成・更新することができます。
サービスをコマンドラインツールで直接作成するのに比べ、CloudFormationでは抽象度が上がっており、複数の異なる種類のサービスを関連付けてまとめて作成したり、テンプレートの一部をパラメータ化して、CloudFormation実行時にパラメータ値を解決するようにできたりします。
手順と言いたいところですが…
LambdaとAPI Gatewayを作成するサンプルテンプレート(概要)を記載しました。(かなり長いので一番最後に載せました)
記事用に色々省略して数百行…。実際は千行超えているので、本格的にやるとなると数千行レベルになるかと思います……。
人が編集できる量の限界を超えています……。 というお話でした……。
テンプレートを簡単に説明すると
CloudFormationでは、Lambda・API Gateway・EC2等、作成する個々のサービスを「リソース」と呼び、「Resources」で定義します。
どの種類のリソースなのかは「Type」で指定し、そのリソースの設定は「Properties」で定義します。
どいうリソースがあって、どういうパラメータを持つかはマニュアルに記載されているので、テンプレートの編集の際は、マニュアルと各リソースのGUIを参照しながら設定値を入れていくことになるかと思います。
そして、CloudFormationでテンプレートを指定して実行すると、定義されたリソースが作成されます。また、テンプレートを変更して再度CloudFormationを実行すると、作成済みのリソースに変更が適用されるという仕組みです。
注意点
CloudFormationを使用するにあたり、結構重要な注意点があります。
リソース作成後、CloudFormation以外でそのリソースの設定を変更すると整合性が取れなくなり、その後CloudFormationによる変更ができなくなることがあります。
CloudFormationは作成・更新だけでなく、削除も行えます。テンプレート単位でCloudFormationが作成されるので、それを削除すると、テンプレートに記載されていたリソース全てが削除されます。
設定変更が変更レベルを超えている場合は、リソースの置換、つまり、リソースを削除して、新たに一からリソースを作成し直します。例えばリソースとしてデータベースを作成していた場合、変更のつもりが、データもろとも削除されてしまうことがあるので注意が必要です。
最初、このあたりの仕様を聞いた時は戸惑ったのですが、docker-composeやKubernetes、Lambdaと同様、初期アップ後に修正を追加していくのではなく、常に最新のバージョンに交換していくやつだと考えればしっくりきました。
感想
AWSのリソースを生成するためのローレベルAPIといった印象です。
CloudFormationを使えば全てのリソースを生成できますが、CloudFormationを直接操作することはあまりせず、何かしらジェネレーターを介して間接的に使うたぐいのものかなぁと感じました。
例えばきっかけとなった「Lambda+API Gateway」に関しては、結局Serverless Frameworkを使うことにしたのですが、Serverless Frameworkも、CloudFormationのテンプレートを生成して、CloudFormationを使ってLambda+API Gatewayを作成しています。
特にCloudFormationのテンプレートを書いていて、しんどいなぁと感じるのは、AWSのコンソールで作成した時にはデフォルトの値としてセットされているものが、テンプレートを書く時には明示的に値をセットしないといけないことがあることです。
コンソールで作成したものと同じものをCloudFormationで作ろうとして、コンソールで見えているパラメータだけをCloudFormationにセットしてもパラメータが足りなくて作成できない…といったことが発生します。そして、コンソールで作成したものからパラメータを調べて都度設定していくことになるのですが、これが結構大変です。
CloudFormerという、既存のリソースからCloudFormationテンプレートを作ってくれるツールはあるのですが、使い勝手が悪くて挫折しました…。
使う場合の方針
一旦CloudFormationを使うと決めたなら、変更も必ずCloudFormationを介して行うようにするのがベターかなと感じました。また、リソースが作り直されても問題ないような設計・運用が必要だなと感じました。
DockerやLambdaの様に、更新で置き換えるスタイルをよく見かけるので、今風と言えば今風なのかも知れません。
CloudFormation デザイナー
CloudFormationには「CloudFormation デザイナー」というGUIでテンプレートが作成できるツールがあります。パット見かっこいいので、これを使えばドラッグアンドドロップで簡単にテンプレートが作れるのではと期待してしまうのですが、できることは限られていて、テンプレートの手編集は必須です。
更に、CloudFormation デザイナーが出力するテンプレートは自動整形され、かつ、GUI情報が付加されていて読みにくくなってしまうので、使用はオススメしません。
ジェネレーターを介して使用する場合でも、そのジェネレーターがサポートしていない設定を行うために、テンプレートをいじる必要があったりするので、TypeとPropertiesのマニュアルの場所は知っておいて損はないかと思います。
サンプルテンプレート
長いのでかなり省略しています。
{ "AWSTemplateFormatVersion": "2010-09-09", "Resources": { "ldLogin": { "Type": "AWS::Lambda::Function", "Properties": { "FunctionName": { "Fn::Sub": "cf-test-lambda-login-${paramStage}" }, "Handler": "login.handler", "Runtime": "nodejs8.10", "Code": { "S3Bucket": "cf-test", "S3Key": { "Fn::Sub": "lambda/${paramLambdaZipFileName}" } }, "Role": "arn:aws:iam::xxxx:role/cf-test-role-lambda-${paramStage}" } }, "api": { "Type": "AWS::ApiGateway::RestApi", "Properties": { "Name": { "Fn::Sub": "cf-test-api-${paramStage}" }, "EndpointConfiguration": { "Types": [ "REGIONAL" ] } } }, "ptLogin": { "Type": "AWS::ApiGateway::Resource", "Properties": { "ParentId": { "Fn::GetAtt": [ "api", "RootResourceId" ] }, "PathPart": "login", "RestApiId": { "Ref": "api" } } }, "mtLoginGET": { "Type": "AWS::ApiGateway::Method", "Properties": { "HttpMethod": "GET", "RestApiId": { "Ref": "api" }, "Integration": { "Type": "AWS", "Uri": { "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ldLogin.Arn}/invocations" }, "PassthroughBehavior": "NEVER", "IntegrationHttpMethod": "GET", "RequestTemplates": { "application/json": { "Fn::Sub": "${paramMappingTemplate}" } }, "IntegrationResponses": [ { "ResponseTemplates": { "application/json": "" }, "StatusCode": "200", "ResponseParameters": { "method.response.header.Access-Control-Allow-Origin": "'*'" } } ] }, "ResourceId": { "Ref": "ptLogin" }, "MethodResponses": [ { "ResponseModels": { "application/json": "Empty" }, "ResponseParameters": { "method.response.header.Access-Control-Allow-Origin": true }, "StatusCode": "200" } ] }, "DependsOn": [ "ldLogin" ] }, "mtLoginOPTIONS": { "Type": "AWS::ApiGateway::Method", "Properties": { "ResourceId": { "Ref": "ptLogin" }, "RestApiId": { "Ref": "api" }, "AuthorizationType": "NONE", "HttpMethod": "OPTIONS", "MethodResponses": [ { "StatusCode": "200", "ResponseModels": { "application/json": "Empty" }, "ResponseParameters": { "method.response.header.Access-Control-Allow-Headers": true, "method.response.header.Access-Control-Allow-Methods": true, "method.response.header.Access-Control-Allow-Origin": true } } ], "Integration": { "Type": "MOCK", "RequestTemplates": { "application/json": "{\"statusCode\": 200}" }, "IntegrationResponses": [ { "ResponseTemplates": { "application/json": "" }, "StatusCode": "200", "ResponseParameters": { "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", "method.response.header.Access-Control-Allow-Origin": "'*'" } } ] } } }, "stage": { "Type": "AWS::ApiGateway::Stage", "Properties": { "StageName": { "Ref": "paramStage" }, "DeploymentId": { "Ref": "deployment" }, "RestApiId": { "Ref": "api" } }, "DependsOn": [ "mtLoginOPTIONS", "mtLoginGET" ] }, "deployment": { "Type": "AWS::ApiGateway::Deployment", "Properties": { "RestApiId": { "Ref": "api" } }, "DependsOn": [ "mtLoginOPTIONS", "mtLoginGET" ] } }, "Parameters": { "paramStage": { "Type": "String", "Default": "cloudformation" }, "paramLambdaZipFileName": { "Type": "String" }, "paramMappingTemplate": { "Type": "String", "Default": "{}" } }, "Outputs": { "url": { "Value": { "Fn::Sub": "https://${api}.execute-api.${AWS::Region}.amazonaws.com/${paramStage}" } } } }