
はじめに
こんにちは。フォースタートアップス株式会社エンジニアの田畑です。
最近の業務で、サーバーレス構成を AWS SAM(以下、SAM)で実装する機会がありました。
サーバーレス構成を実装するならSAMが良い、という話は以前から耳にしており、実際の開発でも深く考えずにSAMを選択していました。しかし、なぜSAMが良いのか、他のIaCツールと比べてどのようなメリットがあるのかについては、正直あまり理解できていませんでした。
そこで今回は、サーバーレス構成をSAMを含めた3つのIaCツールで実装し、それぞれを比較しながらSAMの特徴やメリットを探っていきます。
目次
IaCツール
比較に入る前に、今回検証対象とするIaCツールの特徴を簡単に整理しておきます。
AWS CloudFormation
AWS CloudFormation(以下、CloudFormation)は、AWSのインフラリソースをYAMLまたはJSON形式で定義するIaCサービスです。定義したファイルはテンプレートと呼ばれ、インフラ構成の設計図となります。
AWSネイティブのため、複数アカウント・リージョンへ一括デプロイができたり、新機能やサービスに対応するまでの期間がTerraformなどのIaCと比較して短い傾向にあります。
AWS SAM
AWS Serverless Application Model(以下、SAM)は、オープンソースのフレームワークで、CloudFormationを拡張しサーバーレスアプリケーションのリソース定義を簡潔に記述できます。
AWS::Serverless::Api(API Gateway)、AWS::Serverless::Function(Lambda)などの独自リソースタイプを用いることで、CloudFormationと比較してリソース定義を大幅に短縮できます。
参考:AWS Serverless Application Model (AWS SAM) とは
Terraform
Terraformは、HashiCorp社によって開発されたIaCツールです。HCL(HashiCorp Configuration Language)という独自の宣言的言語を用いてインフラ構成を定義します。
Providerと呼ばれるプラグインによってAWS、Microsoft Azure、Google Cloud Platformのほか、数千を超える各種サービスと連携できる点が特徴です。
参考:
S3 + Lambda + CloudWatch構成におけるIaCツールの比較
構成概要
今回はサンプルとして、S3にファイルをアップロードすると、Lambdaが自動で起動しCloudWatch Logsのロググループにファイル名を出力するという要件で実装します。

作成するリソース
- S3バケット
- Lambda関数
- CloudWatch LogGroup
- IAMロール
SAM
template.yaml
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: > S3 trigger -> Lambda -> CloudWatch Logs Resources: # S3バケット(入力用) SampleInputBucket: Type: AWS::S3::Bucket Properties: BucketName: sample-sam-input-bucket-demo # Lambda関数 SampleFunction: Type: AWS::Serverless::Function Metadata: BuildMethod: esbuild BuildProperties: EntryPoints: - handlers/sampleHandler.ts Minify: true Sourcemap: true Target: es2022 Format: cjs Properties: FunctionName: sample-sam-function-demo CodeUri: src Timeout: 60 MemorySize: 256 Architectures: - arm64 PackageType: Zip Runtime: nodejs24.x Handler: handlers/sampleHandler.handler Environment: Variables: SAMPLE_KEY: 'sample_value' Events: S3Event: Type: S3 Properties: Bucket: !Ref SampleInputBucket Events: s3:ObjectCreated:* Policies: - Version: '2012-10-17' Statement: - Effect: Allow Action: - s3:GetObject Resource: arn:aws:s3:::sample-sam-input-bucket-demo/* # CloudWatch LogGroup SampleFunctionLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: /aws/lambda/sample-sam-function-demo RetentionInDays: 7
samconfig.toml
version = 0.1 [default.build.parameters] cached = true parallel = true [default.deploy.parameters] stack_name = "sample-sam-stack-demo" resolve_s3 = true confirm_changeset = false capabilities = "CAPABILITY_IAM"
デプロイまで
1. ビルド
$ sam build
2. デプロイ
$ sam deploy
所感
コード量の少なさや、ビルドからデプロイまでのステップが非常にシンプルである点が特に印象的で、sam build と sam deploy だけでデプロイまでを一貫して実行できる点に、運用のしやすさを感じました。
また、SAM特有のリソース連携に関する記述量の少なさも大きなメリットだと感じました。通常、CloudFormationで同様の構成を定義する場合、Lambdaの実行ロールやS3からLambdaを呼び出すための権限設定、Lambdaコードの配置先となるS3バケットなどを明示的に定義する必要があります。
しかし、SAMではこれらを省略し簡潔に記述できるため、意識する必要のない設定に煩わされることがなく、主要な構成に集中できると感じました。
CloudFormation
template.yaml
AWSTemplateFormatVersion: '2010-09-09' Description: > S3 trigger -> Lambda -> CloudWatch Logs Resources: # IAM Role (Lambda実行ロール) SampleFunctionRole: Type: AWS::IAM::Role Properties: RoleName: sample-cfn-function-role-demo ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Policies: - PolicyName: sample-cfn-function-policy-demo PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - s3:GetObject Resource: - !Sub "${SampleInputBucket.Arn}/*" # S3 Bucket SampleInputBucket: Type: AWS::S3::Bucket Properties: BucketName: sample-cfn-input-bucket-demo NotificationConfiguration: LambdaConfigurations: - Event: s3:ObjectCreated:* Function: !GetAtt SampleFunction.Arn DependsOn: - SampleFunctionPermission # Lambda Function SampleFunction: Type: AWS::Lambda::Function Properties: FunctionName: sample-cfn-function-demo Code: S3Bucket: Fn::ImportValue: sample-cfn-deploy-bucket-name S3Key: 'sample-handler-function.zip' Timeout: 60 MemorySize: 256 Architectures: - arm64 PackageType: Zip Runtime: nodejs24.x Handler: handlers/sampleHandler.handler Role: !GetAtt SampleFunctionRole.Arn Environment: Variables: SAMPLE_KEY: 'sample_value' Tags: - Key: STAGE Value: sample # Lambda Permission (S3からのInvoke許可) SampleFunctionPermission: Type: AWS::Lambda::Permission Properties: Action: lambda:InvokeFunction FunctionName: !Ref SampleFunction Principal: s3.amazonaws.com SourceArn: !Sub "arn:aws:s3:::${SampleInputBucket}" # CloudWatch LogGroup SampleFunctionLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub "/aws/lambda/${SampleFunction}" RetentionInDays: 7
deploy-bucket.yaml
AWSTemplateFormatVersion: '2010-09-09' Description: > Lambdaのコードを配置するS3バケット Resources: # S3バケット(デプロイ用) SampleDeployBucket: Type: AWS::S3::Bucket Properties: BucketName: sample-cfn-deploy-bucket-demo Outputs: DeployBucketName: Value: !Ref SampleDeployBucket Export: Name: sample-cfn-deploy-bucket-name
デプロイまで
1. デプロイ用S3バケットの作成
$ aws cloudformation deploy \ --template-file deploy-bucket.yaml \ --stack-name sample-cfn-deploy-bucket-stack \ --capabilities CAPABILITY_IAM \
2. Lambda関数のビルド
$ npx esbuild handlers/sampleHandler.ts \ --bundle \ --platform=node \ --target=es2022 \ --format=cjs \ --outfile=dist/handlers/sampleHandler.js \ --minify \ --sourcemap
3. zipファイルを作成
# distディレクトリ内のビルド成果物だけをzip化 $ cd dist && zip -r ../sample-handler-function.zip . && cd ..
4. zipファイルをS3にアップロード
$ aws s3 cp sample-handler-function.zip s3://sample-cfn-deploy-bucket-demo/
5. CloudFormationスタックをデプロイ
$ aws cloudformation deploy \ --template-file template.yaml \ --stack-name sample-cfn-stack-demo \ --capabilities CAPABILITY_NAMED_IAM
所感
特にビルドプロセスには煩雑さがありました。Lambdaのコードを配置するためのS3バケットの定義が必要であることに加え、事前にそのS3バケットを作成しておく必要があるなど手順が多く、デプロイまでの負担は比較的大きいと感じました。
また、今回はLambdaのコードをビルドする際にメタデータをコマンドオプションとして指定しましたが、この方法では設定をコードベースで管理できません。そのため、将来的に設定変更が発生した際に、どのように管理していくかも考慮する必要があると感じました。
さらに、SAMの書き方に慣れた後だと、Lambda実行ロールをはじめとするリソース間の依存関係を明示的に管理する点も、運用上のハードルになりそうです。
Terraform
shared/main.tf
provider "aws" { region = "ap-northeast-1" } terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 6.3" } } } resource "aws_s3_bucket" "deploy_bucket" { bucket = "sample-tf-deploy-bucket-demo" }
main.tf
provider "aws" { region = "ap-northeast-1" } terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 6.3" } } } resource "aws_s3_bucket" "input" { bucket = var.input_bucket_name } resource "aws_iam_role" "lambda_role" { name = "sample-tf-function-role-${var.name_suffix}" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } } ] }) } resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { role = aws_iam_role.lambda_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } resource "aws_iam_role_policy" "lambda_policy" { name = "sample-tf-function-policy-${var.name_suffix}" role = aws_iam_role.lambda_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = ["s3:GetObject"] Resource = [ "${aws_s3_bucket.input.arn}/*" ] } ] }) } resource "aws_lambda_function" "this" { function_name = "sample-tf-function-${var.name_suffix}" role = aws_iam_role.lambda_role.arn handler = "handlers/sampleHandler.handler" runtime = "nodejs24.x" timeout = 60 memory_size = 256 architectures = ["arm64"] package_type = "Zip" s3_bucket = var.deploy_bucket_name s3_key = var.deploy_bucket_key environment { variables = var.lambda_environment } depends_on = [ aws_iam_role_policy.lambda_policy, aws_iam_role_policy_attachment.lambda_basic_execution ] } resource "aws_cloudwatch_log_group" "lambda" { name = "/aws/lambda/${aws_lambda_function.this.function_name}" retention_in_days = 7 } resource "aws_lambda_permission" "allow_input_bucket" { statement_id = "AllowExecutionFromS3" action = "lambda:InvokeFunction" function_name = aws_lambda_function.this.arn principal = "s3.amazonaws.com" source_arn = aws_s3_bucket.input.arn } resource "aws_s3_bucket_notification" "input" { bucket = aws_s3_bucket.input.id lambda_function { lambda_function_arn = aws_lambda_function.this.arn events = ["s3:ObjectCreated:*"] filter_suffix = ".txt" } depends_on = [aws_lambda_permission.allow_input_bucket] }
variables.tf
variable "name_suffix" { type = string default = "demo" } variable "input_bucket_name" { type = string default = "sample-tf-input-bucket-demo" } variable "deploy_bucket_name" { type = string default = "sample-tf-deploy-bucket-demo" } variable "deploy_bucket_key" { type = string default = "sample-handler-function.zip" } variable "lambda_environment" { type = map(string) default = { SAMPLE_KEY = "sample_value" } }
デプロイまで
1. デプロイ用S3バケットの作成
$ cd shared $ terraform init $ terraform apply $ cd ..
2. Lambda関数のビルド
$ npx esbuild handlers/sampleHandler.ts \ --bundle \ --platform=node \ --target=es2022 \ --format=cjs \ --outfile=dist/handlers/sampleHandler.js \ --minify \ --sourcemap
3. zipファイルを作成
$ cd dist && zip -r ../sample-handler-function.zip . && cd ..
4. zipファイルをS3にアップロード
$ aws s3 cp sample-handler-function.zip s3://sample-tf-deploy-bucket-demo/
5. 各リソースをデプロイ
# sharedとは別ディレクトリで管理しているため、ここでもterraform initを実行します。 $ terraform init $ terraform apply
所感
Terraformはリソース単位でブロックが分かれているため、コードの見通しの良さは感じました。
ただし、あくまでインフラの構成管理に特化したツールであるため、Lambdaコードのビルドやzip化といったプロセスを別途行う必要があり、その点は手間に感じました。
また、prodやstgなどで環境を分ける場合、ディレクトリやファイルを環境単位で分けて管理することになると思います。そうすると管理対象のファイルやディレクトリが増え、構成全体の把握や変更影響の確認には、一定の運用コストがかかると感じました。
結論
サーバーレス構成を3つのIaCで実装し比較してみましたが、少なくとも今回検証した範囲では、SAMが最も適していると感じました。
今回はシンプルな構成での検証でしたが、ある程度規模が大きくなった場合でも、記述量の少なさや開発からビルド、デプロイまでのフローがシンプルにまとまっている点は、TerraformやCloudFormationと比較して優位になりやすいのではないかと感じました。
終わりに
サーバーレス構成をそれぞれのIaCで実装し比較してみたことで、SAMがなぜサーバーレス構成と相性が良いのかについて、理解が深まりました。
普段使っているツールにおいても、別の選択肢と比較してみることでその良さが見えてくると思います。今回の検証も、SAMの使いやすさを改めて実感する良い機会になりました。