新しいことにはウェルカム

技術 | 電子工作 | ガジェット | ゲーム のメモ書き

AWS CloudFormation を使ってみたが、人が扱える限界を超えていた話

タイトルそのままですが、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が作成されるので、それを削除すると、テンプレートに記載されていたリソース全てが削除されます。

  • 設定変更が変更レベルを超えている場合は、リソースの置換、つまり、リソースを削除して、新たに一からリソースを作成し直します。例えばリソースとしてデータベースを作成していた場合、変更のつもりが、データもろとも削除されてしまうことがあるので注意が必要です。

感想

AWSのリソースを生成するためのローレベルAPIといった印象です。

CloudFormationを使えば全てのリソースを生成できますが、CloudFormationを直接操作することはあまりせず、何かしらジェネレーターを介して間接的に使うたぐいのものかなぁと感じました。

例えばきっかけとなった「Lambda+API Gateway」に関しては、結局Serverless Frameworkを使うことにしたのですが、Serverless Frameworkも、CloudFormationのテンプレートを生成して、CloudFormationを使ってLambda+API Gatewayを作成しています。

一旦CloudFormationを使うと決めたなら、変更も必ずCloudFormationを介して行うようにするのがベターかなと感じました。また、リソースが作り直されても問題ないような設計・運用が必要だなと感じました。

DockerやLambdaの様に、更新で置き換えるスタイルをよく見かけるので、今風と言えば今風なのかも知れません。

CloudFormationには「CloudFormation デザイナー」というGUIでテンプレートが作成できるツールがあります。パット見かっこいいので、これを使えばドラッグアンドドロップで簡単にテンプレートが作れるのではと期待してしまうのですが、できることは限られていて、テンプレートの手編集は必須です。

AWS CloudFormation デザイナー

更に、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}"
            }
        }
    }
}