for Startups Tech Blog

フォースタ社員のエンジニアたちが思い思いのことを書き綴ります。

サーバーレス構成のIaCにAWS SAMは本当に適しているのか

テックブログのアイキャッチ画像

はじめに

こんにちは。フォースタートアップス株式会社エンジニアの田畑です。

最近の業務で、サーバーレス構成を 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と比較して短い傾向にあります。

参考:CloudFormationとは?

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 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 buildsam 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の使いやすさを改めて実感する良い機会になりました。