SAM+TerraformでLambdaの管理を楽にする

2021.06.24

今回は基本はTerraformでインフラを構築しつつも、部分的にはSAMを使用してLambda+API Gatewayをデプロイしたいと思います。
Lambdaのアーカイブ化やS3へのアップロードをSAMにやってもらうことで、Terraform側でのタスクを軽減することができます。

今回の記事の元ネタは以下のスライドです。 IaCについていろいろな知見が得られると思うのでおすすめです。

SAMとは

SAMはLambdaなどのサーバーレスアプリケーションの開発・デプロイを補助するツールでCloudFormationのような形式のファイルを用いてこれらを定義することができます。
Lambdaを開発・デプロイする場合について考えると、必要となる工程は煩雑です。開発ではローカルでの実行やランタイムの管理などをしたくなりますし、デプロイでは依存するパッケージの設置、Zipファイルへのアーカイブ化、アップロードなども自動化できるとうれしいです。
SAMではこれらの煩雑なタスクをある程度自動化してくれるので便利です。 詳しくは以下のリンクを読んでみてください。

TerraformでLambdaを管理したくない理由

TerraformのAWSプロバイダーではLambdaを定義する際にアプリケーションがまとめられたファイルが要求されます。 以下はTerraformのAWS Providerから引用した例です。

Lambdaの定義

resource "aws_lambda_function" "test_lambda" {
  filename      = "lambda_function_payload.zip"
  function_name = "lambda_function_name"
  role          = aws_iam_role.iam_for_lambda.arn
  handler       = "exports.test"

  # The filebase64sha256() function is available in Terraform 0.11.12 and later
  # For Terraform 0.11.11 and earlier, use the base64sha256() function and the file() function:
  # source_code_hash = "${base64sha256(file("lambda_function_payload.zip"))}"
  source_code_hash = filebase64sha256("lambda_function_payload.zip")

  runtime = "nodejs12.x"

  environment {
    variables = {
      foo = "bar"
    }
  }
}

このlambda_function_payload.zipを用意するためには先述のようなパッケージのインストール、アーカイブ化などの作業が伴います。変更の度に毎回こういった作業をやるのはコストが高いです。
タスクランナーなどを用いてある程度は自動化できますが、どうせならSAMをタスクランナーの一部として活用することで導入コストを減らしたいというのが今回の記事のモチベーションです。

やってみる

今回はSAMの機能でCloudFormationのテンプレート作成とS3へのファイルのアップロードまでをやってもらいます。 生成されたテンプレートをTerraformを用いてデプロイすることでTerraform⇄CloudFormation間の変数のやりとりもスムーズに行えます。

今回のプロジェクトの最終的なディレクトリ構造は次のような感じです(一部省略してます)。 基本的にはSAMで生成したプロジェクトがTerraformのプロジェクトに含まれている感じです。

ディレクトリ構造

├── Makefile
├── cloudformation.tf
├── cloudfront.tf
├── main.md
├── main.tf
└── sam-app
    ├── Pipfile
    ├── __init__.py
    ├── hello_world
    │   ├── __init__.py
    │   ├── app.py
    │   └── requirements.txt
    ├── template.yaml
    └── tests

手順

今回は例としてCloudFront+API Gateway+Lambdaのシステムを構築してみましょう。

1. Terraformのセットアップ

以下のようなTerraformを使うためのプロバイダーなどの情報が入ったファイルを用意しましょう。

main.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

provider "aws" {
  region  = "ap-northeast-1"
  profile = "XXXXX"
}

以下のコマンドでTerraformのプロジェクトを始めましょう。

Terraformの準備

$ terraform init

2. SAMのセットアップ

SAMはプロジェクトを開始するにあたってインタラクティブに情報を入力することができます。 今回は以下のように設定しました。 成功するとプロジェクト名(sam-app)と同じディレクトリが作られます。

SAMの準備

$ sam init
Which template source would you like to use?
	1 - AWS Quick Start Templates
	2 - Custom Template Location
Choice: 1
What package type would you like to use?
	1 - Zip (artifact is a zip uploaded to S3)
	2 - Image (artifact is an image uploaded to an ECR image repository)
Package type: 1

Which runtime would you like to use?
	1 - nodejs14.x
	2 - python3.8
	3 - ruby2.7
	4 - go1.x
	5 - java11
	6 - dotnetcore3.1
	7 - nodejs12.x
	8 - nodejs10.x
	9 - python3.7
	10 - python3.6
	11 - python2.7
	12 - ruby2.5
	13 - java8.al2
	14 - java8
	15 - dotnetcore2.1
Runtime: 2

Project name [sam-app]: sam-app

Cloning from https://github.com/aws/aws-sam-cli-app-templates

AWS quick start application templates:
	1 - Hello World Example
	2 - EventBridge Hello World
	3 - EventBridge App from scratch (100+ Event Schemas)
	4 - Step Functions Sample App (Stock Trader)
	5 - Elastic File System Sample App
Template selection: 1

    -----------------------
    Generating application:
    -----------------------
    Name: sam-app
    Runtime: python3.8
    Dependency Manager: pip
    Application Template: hello-world
    Output Directory: .

    Next steps can be found in the README file at ./sam-app/README.md

今回は自分のPythonのバージョンとSAMの方で指定したバージョンが一致しなかったため、pipenvを用いてそれを解決しています。

pipenvの準備

$ cd sam-app
$ pipenv --python 3.8

3. S3バケットの用意

SAMではS3に保存したファイルからLambda関数を作成するので、バケットが必要になります。

S3バケットの作成

$ aws s3api --profile XXXXX \ 
        create-bucket \
            --acl private \
            --bucket XXXXX \
            --create-bucket-configuration LocationConstraint=ap-northeast-1

4. Lambda関数の用意

今回はSAMで作られたテンプレートをほぼそのまま流用しましょう。 以下のファイルを次のように書き換えましょう。 環境変数からNAMEを読み込んでレスポンスとして返しています。

sam-app/hello_world/app.py

import json
from os import environ

def lambda_handler(event, context):
    return {
        "statusCode": 200,
        "body": json.dumps({
            "message": "hello world",
            "name": environ.get("NAME")
        }),
    }

一緒にSAMの方のテンプレートも書き換えましょう。 このテンプレートを元にCloudFormationのテンプレートが生成されます。 今回は環境変数としてNAMEをパラメータから参照しています。 また、アウトプットとしてAPIのドメインを出力しています。

sam-app/template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sam-app

  Sample SAM Template for sam-app

Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.8
      Environment:
        Variables:
          NAME: !Ref Name
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get

Parameters:
  Name:
    Type: String

Outputs:
  HelloWorldApiDomain:
    Value: !Sub "${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com"

これでSAMの準備は完了です。

5. CloudFrontの用意

Terraformを利用してCloudfFrontをセットアップしましょう。 まず初めにSAMによって生成されるCloudFormationのスタックをTerraformから利用できるようにしましょう。
ここでのtemplate.yamlsam-app/template.yamlではなく、後に生成されるCloudFormationのテンプレートである点に注意が必要です。 ここでparametersとして設定したNameがCloudformationの方で利用可能となります。

cloudformation.tf

resource "aws_cloudformation_stack" "sam" {
  name = "sam"
  template_body = file("template.yaml")
  capabilities = ["CAPABILITY_IAM", "CAPABILITY_AUTO_EXPAND"]
  parameters = {
    "Name" = "Terraform"
  }
}

data "aws_cloudformation_stack" "sam" {
  name = "sam"
  depends_on = [
    aws_cloudformation_stack.sam
  ]
}

CloudFrontの設定をしましょう。 今回はカスタムオリジンとしてAPI Gatewayを指定しています。 この際、domain_nameとしてCloudFormationのoutputsが利用できます。

cloudfront.tf

resource "aws_cloudfront_distribution" "api_dist" {
  origin {
    domain_name = data.aws_cloudformation_stack.sam.outputs["HelloWorldApiDomain"]
    origin_id = "sam-api-gateway"

    custom_origin_config {
      https_port = 443
      http_port = 80
      origin_protocol_policy = "https-only"
      origin_ssl_protocols = ["TLSv1.2"]
    }
  }

  enabled = true
  default_cache_behavior {
    allowed_methods = [ "GET", "HEAD" ]
    cached_methods = ["GET", "HEAD"]
    target_origin_id = "sam-api-gateway"

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "https-only"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
  }

  restrictions {
      geo_restriction {
          restriction_type = "whitelist"
          locations = [ "JP" ]
      }
  }
  viewer_certificate {
      cloudfront_default_certificate = true
  }
}

6. タスクランナーを準備する

今回はタスクランナーとしてGNU Makeを利用します。

Makefile

PROFILE=XXXXX
SAM_APP_DIR=sam-app
SAM_BUCKET=XXXXX
TEMPLATE_FILE=template.yaml

deploy:
	cd $(SAM_APP_DIR) && pipenv run sam build
	cd $(SAM_APP_DIR) && sam package --profile $(PROFILE) --s3-bucket $(SAM_BUCKET) --output-template-file ../$(TEMPLATE_FILE)
	terraform apply

デプロイまでの流れは次のような感じです。

  • sam buildで必要なファイルをまとめる
  • sam packageでZipアーカイブ化、S3にアップロード、CloudFormationのテンプレートを生成
  • Terraformでデプロイ

CI/CDだったらSAM_BUCKETを環境変数から取得したり、Terraformのデプロイ前の確認をスキップしたりする必要があるでしょう。

以下のコマンドでデプロイできます。

デプロイ

$ make deploy

実行するとプロジェクトのルートにtemplate.yamlが生成されていると思います。

7. 確認する

実際に動いているか確認してみましょう。 CloudFrontに対してcurlでリクエストを送ります。

リクエスト

$ curl https://XXXXX.cloudfront.net/Prod/hello
{"message": "hello world", "name": "Terraform"}

Terraformから渡した値が返ってきましたね。

8. 全てのリソースを削除する

デプロイしたリソースのほとんどは以下のコマンドで削除できます。

Terraformでの削除

terraform destroy

最後にSAM用のS3バケットを削除すれば全て完了です。

感想

今回はSAM+Terraformという組み合わせでやりましたが、SAMはCloudFormationの拡張なのでそちら側でも必要なリソースの定義をすることができます。その場合Terraformを使わず、makeなどのタスクランナーは不要なのでもう少しシンプルになるでしょう。 また、TerraformだけでLambdaやAPI Gatewayの管理を行うという選択肢もあります。

今回のような組み合わせが必要になるのは以下のような局面だと思います。

  • Terraformをどうしても使いたい
    • すでに多くのリソースがTerraformで管理されている
    • Terraformに習熟している
    • CloudFormationだけで作るには複雑すぎる
    • AWS以外のプロバイダーもつかう
  • Lambda関数のコードが頻繁に変更される
  • API Gatewayの設定項目が多い

SAMを用いてCloudFormationのテンプレートを作成するというのはいい手法だと思いました。 Terraform側との変数のやりとりも簡単なのも良い点です。 1からタスクランナーを設定するよりは楽に開発ができると思います。