こんにちは、プロダクトエンジニアリング部のちょうです。最近ひたすら既存システムの自動化をやっています。その中で、TerraformとCloudFormation両方を使っていました。TerraformあればCloudFormationが必要ないと思う方もいるかもしれませんが、CloudFormationを使ってTerraformができないことを可能にするCloudFormationのCustom Resourceを紹介しようと思います。
まず簡単にTerraformとCloudFormationの特徴を比較します。
Terraform | CloudFormation | |
---|---|---|
プラットフォーム | AWS, GCP etc | AWS |
構文 | HCL リソース定義 |
JSON/YAML リソース定義 |
IDEサポート(JetBrain) | ◯ | △ それ以外Web Designerある |
リソース状態 | tfstateファイルで管理 | AWSが管理 |
オンライン実行 | Terraform Cloud(有料) | 画面で変更および実行できる(無料) |
入力 | 環境変数 tfvarsファイル Terraform Cloud(有料) |
作成・更新時に環境変数、JSONファイル 作成後AWSが管理 |
ロールバック機能 | なし、途中で失敗すると止まる/crash | あり |
AWS API | 最新ではない 例えばECS Serviceの一部設定など |
最新 |
拡張機能 | Golang | Custom Resource (SNS/Lambda) |
全体的に見ると、TerraformはIDEサポートがいいおかげて使いやすい、かつ複数プラットフォームを対応します。比べてCloudFormationはAWSのサービスとして、リソース状態や入力の管理は必要なくなります。そして注目すべきなのは、ロールバック機能と拡張機能です。
ロールバック機能は、実行するとき、一つのリソースの作成・更新がエラーになるとき、変更したほかのリソースは前の状態に戻れるかとういうことです。これは結構重要な機能で、アプリケーションがバージョンアップしてインフラをすこし変更したいときは中途半端な状態はだめです。ゆえ、CodePipelineにECS Scheduled Taskなどを更新(TaskDefinitionとEventBridge Rule Target両方)したいとき、CloudFormationが必要です。
もう一つは拡張機能です。TerraformはGolangで書かれているのでGolangをサポートしています。正直インフラをやる人はPythonをすこし書ける人一番多いかもしれません。比べてCloudFormationはAWSのサービスですのでLambdaを使えます。Lambdaが複数言語を対応するのとLambdaをVPCと接続すればVPC内のリソースを触れます。後者はTerraformが基本できないことです。
例えば、RDSのインスタンスを作って、アプリケーション用のデータベースを作りたいときは、EC2でしたら、何らかのツールを経由してRDSインスタンスに接続してデータベースを作ります。Fargateを使うと、EC2がなく、RDSインスタンスは基本プライベートサブネットにいるので、接続できず作成できません。そして手動に戻り、Cloud9/EC2インスタンスを作って、インスタンスに入って作業します。もしLambdaがあれば、VPCと繋がってRDSインスタンスと通信することができます。
では、実際CloudFormationのCustom Resource(Lambda)を使ってみましょう。いきなりデータベースをいじるより、簡単なCustom Resourceをやります。例えば、S3バケットにあるテキストファイル。
(以下のコードでは利用されるS3バケットは状況に応じて作ってください)
利用側
# s3-text-file-example.yaml AWSTemplateFormatVersion: "2010-09-09" Description: S3 Text File Example Parameters: Prefix: Type: String Resources: EcsEnvFile: Type: "AWS::CloudFormation::CustomResource" Properties: ServiceToken: !ImportValue "CrS3TextFileFunctionArn" Bucket: !Sub "${Prefix}-ecs" ObjectKey: "app.env" Content: !Sub | PREFIX=${Prefix} ACCOUNT_ID=${AWS::AccountId} REGION=${AWS::Region}
CloudFormationを詳しくない方向けに説明します。CloudFormationはStackという単位で管理します。基本1 Stackイコール1テンプレートファイルです。テンプレートファイルに
- AWSTemplateFormatVersion バージョン
- Description 説明
- Parameters 入力
- Resources リソース定義
があります。Parametersにパラメータ名と型の一覧があります。リソースにはリソースタイプと属性が定義されています。Custom Resourceにはリソースタイプは AWS::CloudFormation::CustomResource (ほかにもある)になります。属性にServiceTokenはLambdaのARNになります。残りはすべてLambdaの入力パラメータです。
CloudFormationのテンプレートファイルにいくつの関数があります。!ImportValue はほかのStackがエクスポートした変数を取り入れます。!Sub は ${} で囲まる変数を実際の値に変換します。入力のPrefix以外に、最初からAWS::AccountIdとAWS::Region(値はその名前通り)も使えます。
ではもう一回みると、EcsEnvFileというリソースはLambdaを呼び出して、Bucket, ObjectKeyなどを入力することになります。Lambda側はこのようなコードです。
// index.js exports.handler = async (event, context) => { let resourceProperties = event['ResourceProperties']; let bucket = resourceProperties['Bucket']; let objectKey = resourceProperties['ObjectKey']; let content = resourceProperties['Content']; let requestType = event['RequestType']; let s3Client = new AWS.S3(); if (requestType === 'Create' || requestType === 'Update') { await s3Client.putObject({ Bucket: bucket, Key: objectKey, Body: content }).promise(); } else if (requestType === 'Delete') { await s3Client.deleteObject({ Bucket: bucket, Key: objectKey }).promise(); } // cloudFormationReply(event, context, success, responseData, physicalResourceId, noEcho) await cloudFormationReply(event, context, true, {}, `s3://${bucket}/${objectKey}`, false); }
基本AWSサービスをいじるときNodeJSが書きやすいです。ロジックはそんなに難しくないと思います。RequestType によって、作成・更新と削除が行います。最後にある cloudFormationReply はAWSのライブラリcfn-responseの関数です。こちらにドキュメントがあります。
Lambdaのテンプレートは以下のようになります。
# custom-resource-s3-text-file.yaml AWSTemplateFormatVersion: "2010-09-09" Description: Custom Resources S3 Text File Resources: CrS3TextFileRole: Type: "AWS::IAM::Role" Properties: RoleName: "custom-resource-s3-text-file" AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Principal: Service: - "lambda.amazonaws.com" Action: - "sts:AssumeRole" ManagedPolicyArns: - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" Policies: - PolicyName: s3 PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - "s3:PutObject" - "s3:GetObject" - "s3:DeleteObject" Resource: "arn:aws:s3:::*/*" CrS3TextFileFunction: Type: "AWS::Lambda::Function" Properties: FunctionName: "custom-resource-s3-text-file" Code: ../lambda/custom-resource-s3-text-file Handler: index.handler Role: !GetAtt CrS3TextFileRole.Arn Runtime: nodejs12.x Outputs: CrS3TextFileFunctionArn: Value: !GetAtt CrS3TextFileFunction.Arn Export: Name: "CrS3TextFileFunctionArn"
LambdaをserverlessやAWSのSAMで管理できますが、serverlessが複数プラットフォームを対応するため、Lambdaの設定が複雑になると、結局CloudFormationのリソースを書かないといけないです。AWSのSAMはCloudFormationのベースにしたLambda開発に特化したツールです。ただ特別な要望がないなら、そのままCloudFormationで管理することもできます。
テンプレートファイルに2つのリソースがあります。IAMロールとLambdaです。IAMロールに基本的なAWSLambdaBasicExecutionRoleとS3を操作できるポリシーが入っています。LambdaリソースのCode属性に注意してください。実際のコードは別ファイルになります。
ではLambdaをデプロイします。
$ # aws s3 mb cfn-package-abcd $ aws cloudformation package --template-file=custom-resources-s3-text-file.yaml \ --s3-bucket=cfn-package-abcd --s3-prefix=custom-resource-s3-text-file \ --output-template-file out-custom-resource-s3-text-file.yaml $ aws cloudformation deploy --stack-name=custom-resources-s3-text-file \ --template-file=out-custom-resource-s3-text-file.yaml \ --capabilities CAPABILITY_NAMED_IAM
まずcloudformationのpackageコマンドを利用して、LambdaのコードをS3にアップロードします。packageは変換したテンプレートファイルを出力します。次はcloudformationのdeployコマンドを使って、出力テンプレートファイルを使ってデプロイします。デプロイコマンドはStackないなら作成、あるなら更新するという意味です。最後にIAMロールを作成ならCAPABILITY_NAMED_IAMが必要になります。
Lambdaテンプレートファイルの最後にOutputsがあります。この出力は最初のテンプレートに使われています。cloudformationのテンプレートファイルは出力とエクスポートを指定することで、別のStackに利用されることができます。
LambdaのStackができたことで、利用側のテンプレートをデプロイしましょう。
$ aws cloudformation deploy --stack-name=s3-text-file-example \ --template-file=s3-text-file-example.yaml \ --parameter-overrides Prefix="my"
もしS3バケットにapp.envができたら成功です!
ところで、このファイル実はFargate 1.4.0からサポートされる環境変数ファイルであります。CloudFormationはS3バケットのオブジェクトを作成するリソースがないですが、こうすることで、自分でカバーすることができます。
本題のデータベースに戻ります。大体の流れがわかることで、データベースを作成するLambdaも難しくないでしょう。
# custom-resource-pg-database.yaml AWSTemplateFormatVersion: "2010-09-09" Description: Custom Resources PostgreSQL Database Parameters: VpcId: Type: AWS::EC2::VPC::Id PrivateSubnetIds: Type: List<AWS::EC2::Subnet::Id> Resources: # https://docs.aws.amazon.com/lambda/latest/dg/services-rds-tutorial.html CrPgDatabaseRole: Type: "AWS::IAM::Role" Properties: RoleName: "custom-resource-pg-database" AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: "Allow" Principal: Service: - "lambda.amazonaws.com" Action: - "sts:AssumeRole" ManagedPolicyArns: - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" - "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" CrPgDatabaseSecurityGroup: Type: "AWS::EC2::SecurityGroup" Properties: GroupName: "custom-resources-pg-database" GroupDescription: "custom resource pg database" VpcId: !Ref VpcId SecurityGroupEgress: - IpProtocol: tcp FromPort: 5432 ToPort: 5432 CidrIp: 0.0.0.0/0 Description: "PostgreSQL" - IpProtocol: tcp FromPort: 443 ToPort: 443 CidrIp: 0.0.0.0/0 Description: "CloudFormation Response" CrPgDatabaseFunction: Type: "AWS::Lambda::Function" Properties: FunctionName: "custom-resource-pg-database" Code: ../lambda/custom-resource-pg-database Handler: app.handler Role: !GetAtt CrPgDatabaseRole.Arn Runtime: python3.8 VpcConfig: SecurityGroupIds: - !Ref CrPgDatabaseSecurityGroup SubnetIds: !Ref PrivateSubnetIds Outputs: CrPgDatabaseFunctionArn: Value: !GetAtt CrPgDatabaseFunction.Arn Export: Name: "CrPgDatabaseFunctionArn"
VPCとつながるので、AWSLambdaVPCAccessExecutionRoleが必要です。そして、Security GroupにPostgreSQLの5432以外に443もアクセスようにしてください、そうではないとCustom Resourceの状態は成功にできません。残りは普通のLambdaの設定になります。続いては利用側です。
# database.yml AWSTemplateFormatVersion: "2010-09-09" Description: Database Parameters: DatabaseHost: Type: String DatabaseMasterUsername: Type: String NoEcho: true DatabaseMasterPassword: Type: String NoEcho: true DatabaseName: Type: String MigrationUsername: Type: String NoEcho: true MigrationPassword: Type: String NoEcho: true Resources: PgDatabase: Type: "AWS::CloudFormation::CustomResource" Properties: ServiceToken: !ImportValue "CrPgDatabaseFunctionArn" DatabaseHost: !Ref DatabaseHost DatabaseMasterUsername: !Ref DatabaseMasterUsername DatabaseMasterPassword: !Ref DatabaseMasterPassword DatabaseName: !Ref DatabaseName MigrationUsername: !Ref MigrationUsername MigrationPassword: !Ref MigrationPassword DatabaseHostParameter: Type: "AWS::SSM::Parameter" Properties: Name: /database-host Type: String Value: !Ref DatabaseHost DatabaseNameParameter: Type: "AWS::SSM::Parameter" Properties: Name: /database-name Type: String Value: !Ref DatabaseName DatabaseMigrationSecrets: Type: "AWS::SecretsManager::Secret" Properties: Name: /database-migration-secrets SecretString: !Sub '{"username":"${MigrationUsername}","password":"${MigrationPassword}"}' Outputs: DatabaseHostParameter: Value: !Ref DatabaseHostParameter Export: Name: "DatabaseHostParameter" DatabaseNameParameter: Value: !Ref DatabaseNameParameter Export: Name: "DatabaseNameParameter" DatabaseMigrationUsernameArn: Value: !Sub "${DatabaseMigrationSecrets}:username::" Export: Name: "DatabaseMigrationUsernameArn" DatabaseMigrationPasswordArn: Value: !Sub "${DatabaseMigrationSecrets}:password::" Export: Name: "DatabaseMigrationPasswordArn"
利用側のParametersにNoEchoというパラメータが設定されています。最初の比較テーブルに書いたように、CloudFormationの入力は作成後AWSに管理されます。ゆえ、パスワードなどの情報にNoEchoを設定して表示しないようにします。CustomResource以外にParameter StoreとSecretsManagerにデータベースの情報を保存します。これでデータベースの情報が一元管理します。データベースが作成できなかったらこれらの情報も保存すべきではないです。ちなみに、CloudFormationにParameter StoreのSecureStringタイプのパラメータを作成できません。おそらくパラメータなどの情報はParameter Storeに保存するのおすすめしないという方針で、SecretsManagerに誘導しています。SecretsManagerにアクセス記録、パスワードローテーションやJSONでまとめて保存するいろんな機能があるので、喜んで受け入れましょう。
データベースを作成するLambdaはPythonで書かれています。作成、更新と削除を対応するため、すこし複雑になります。以下の抜粋したコードです。
import postgresql.driver as pg_driver class Request: def __init__(self, event): self.event = event self.kind = event['RequestType'] self.event_properties = event['ResourceProperties'] self.database_host = get_resource_property(self.event_properties, "DatabaseHost") self.database_port = get_resource_property(self.event_properties, "DatabasePort", required=False, default_value=5432) self.database_master_username = get_resource_property(self.event_properties, "DatabaseMasterUsername") self.database_master_password = get_resource_property(self.event_properties, "DatabaseMasterPassword") self.database_name = get_resource_property(self.event_properties, "DatabaseName") self.schema_name = get_resource_property(self.event_properties, "SchemaName", required=False, default_value="app") self.migration_role_name = get_resource_property(self.event_properties, "MigrationRoleName", required=False, default_value=f"{self.database_name}_migration") self.migration_username = get_resource_property(self.event_properties, "MigrationUsername") self.migration_password = get_resource_property(self.event_properties, "MigrationPassword") self.crud_role_name = get_resource_property(self.event_properties, "CrudRoleName", required=False, default_value=f"{self.database_name}_crud") self.crud_username = get_resource_property(self.event_properties, "CrudUsername", required=False) self.crud_password = get_resource_property(self.event_properties, "CrudPassword", required=(self.crud_username != None)) def create_database(request): # conn.execute(f'CREATE DATABASE "{database_name}"') create_database(request.database_name, request.schema_name) # conn.execute(f'CREATE ROLE "{role_name}"') # ... create_migration_role(request.database_name, request.schema_name, request.migration_role_name) # conn.execute(f"CREATE USER \"{username}\" WITH PASSWORD '{password}'") # ... create_user(request.migration_username, request.migration_password, request.database_name, request.schema_name, request.migration_role_name) def handler(event, context): request = Request(event) try: if request.kind == 'Create': create_database(request) cloud_formation_reply(event, context, "SUCCESS", responseData={}, physicalResourceId=request.database_name) elif request.kind == 'Update': print("unsupport operation [update]") cloud_formation_reply(event, context, "FAILED", responseData={}, physicalResourceId=request.database_name) elif request.kind == 'Delete': delete_database(request) cloud_formation_reply(event, context, "SUCCESS", responseData={}, physicalResourceId=request.database_name) except Exception as error: cloud_formation_reply(event, context, "FAILED", responseData={}, reason=str(error), physicalResourceId=request.database_name)
S3テキストファイルと同じ、Lambdaを先にデプロイして、続いてデータベースのテンプレートをデプロイすれば、データベースが自動的に作成されます。Cloud9/EC2を作成する必要がありません。もちろん、テーブルを作成するSQLがあっても、コードをすこしいじって、SQLを実行するのも可能です。そして、Custom Resourceの属性を変えない限り、Custom Resourceに更新リクエストが来ないです。別のリソースをすきに変更しても問題ありません。
ここまで2つの例を見ていかがでしょうか。CloudFormationのCustom ResourceがLambdaを利用できることで、管理できる領域が結構広げましたね。ほかの例というと
- API Gateway (v1) の自動デプロイ
- AES256にランダムなIVとKeyを作成する
- 内部用のOAuthアプリケーションを作成する
- Redisにあるアプリケーションの設定をいじる
最後に、TerraformができないことはCloudFormationができるといっても、既存のTerraformを放棄しすべてをCloudFormationにする意味ではないです。個人的には
- アプリケーションに近いほど環境の設定はTerraformにとって難しいため(インスタンスの設定はできないなど)、Terraform以外のツールに変えるほうがいい
- AWSのCodeシリーズ(CodeBuild/CodePipeline)をTerraformにしない
- 簡単なものがいいですが、複雑なものはエンジニアが一番くわしい(感覚的にエンジニア七割、インフラ三割)
- 変更する可能性が高いものほどtfstate/tfvarsの管理に意味がない、インフラはただボトルネックになる
- ゆえ、CloudFormationはよいかと
- アプリケーションの起動設定(ECS Task Definitionなど)はTerraformから離す
- Terraformの適応はアプリケーションのデプロイになる
- 失敗したら中途半端になりかねない(デプロイに関してはロールバック機能が必要、Blue/Greenデプロイなど)
- CodePipelineに環境変更があったらCloudFormationがいい、例えばECS Scheduled Task、API Gateway
と考えています。要するに、アプリケーションと開発の設定はインフラに依頼しなくても済むことです。このDevOpsの間にあるものはCloudFormationがよいだと思います。そしてCustom Resourceを利用してどんどん自動化できると信じています。