GolangのLambda関数をTerraformだけでデプロイする

2022.04.28

やりたいこと

GolangのLambda関数をTerraformを使ってデプロイしたいです。普段ならcdkとかslsとかを使いますが、今回のプロジェクトはTerraformでガッツリ他リソースを作成しており、今更Lambda関数ひとつをデプロイするためだけに別ツールを使いたくなかったからです。

さて、GolangのLambda関数をデプロイするためには、以下のステップが必要になります。

  1. Golangのコードを書く
  2. ↑をビルドしてバイナリファイルを作成する
  3. ↑のバイナリファイルをzip圧縮してデプロイパッケージを作成する
  4. 関数デプロイ時に↑のzipファイルを指定する

Terraformにはaws_lambda_functionというリソースがありますが、これがやってくれるのは最後の関数のデプロイだけです。コードを書いた後このaws_lambda_functionリソースを使うまでの間にビルドとzipファイル化というステップがまだ残っています。

上記ビルドとzipファイル化、そしてデプロイ(=terraform apply)を一気通貫で実施するために、makefileでそれらの処理をラップするという方法があります。以下弊社新井のブログではその方法を採っています。

しかし今回は前述のとおりTerraformですでにガッツリ他リソースを作成しており、今更makefileでラップするのもできればやりたくないなぁと思いました。そこでTerraform内で、つまりterraform applyコマンドだけでこれらの処理が完結できる構成を模索しました。

全体像

結果、できあがったコードは以下の様になりました。

├── lambdas
│   ├── archive
│   │   └── .gitignore
│   ├── bin
│   │   └── .gitignore
│   └── cmd
│       └── sample
│           ├── go.mod
│           ├── go.sum
│           └── main.go
├── .gitignore
├── .go-version
├── .terraform-version
├── .terraform.lock.hcl
├── go.mod
├── go.sum
├── lambda.tf
├── locals.tf
├── main.tf
└── providers.tf

ざっくりとした処理の流れは以下になります。

  1. ./lambdas/cmd以下にコードを書く
  2. ./lambdas/bin以下にバイナリファイルを作成する
  3. ./lambdas/archive以下にzipファイル(=デプロイパッケージ)を作成する
  4. デプロイパッケージをS3にアップロードする
  5. アップロードしたデプロイパッケージを指定してLambda関数をデプロイ

2〜5までが terraform applyでできます。かつ2回目以降の実行ではコードに変更があったときだけこれら(2〜5)の処理が走ります。

動作環境

  • MacBook Pro (13-inch, M1, 2020)
  • macOs Big Sur v11.6.5
  • Terraform
    • v1.1.9
    • aws provider
      • v4.11.0
    • null provider
      • v3.1.1
  • Golang
    • v1.16.15
  • 以下コマンドが使える
    • aws cli
    • openssl
    • zip

GitHub

以下で中身を解説していきます。

Lambda関数コード

Golangのコードの中身自体は今回は特に重要ではないので、公式Lambda デベロッパーガイドに出てくる以下のコードを使いました。

./lambdas/cmd/sample/main.go

package main

import (
        "fmt"
        "context"
        "github.com/aws/aws-lambda-go/lambda"
)

type MyEvent struct {
        Name string `json:"name"`
}

func HandleRequest(ctx context.Context, name MyEvent) (string, error) {
        return fmt.Sprintf("Hello %s!", name.Name ), nil
}

func main() {
        lambda.Start(HandleRequest)
}

Resource: aws_lambda_function

まずはこの構成のゴールとなるLambda関数のデプロイを担うaws_lambda_functionリソースについて説明します。

lambda.tfの一部

resource "aws_lambda_function" "sample" {
  function_name    = "sample"
  s3_bucket        = aws_s3_bucket.lambda_assets.bucket
  s3_key           = data.aws_s3_object.golang_zip.key
  role             = aws_iam_role.iam_for_lambda.arn
  handler          = "sample"
  source_code_hash = data.aws_s3_object.golang_zip_hash.body
  runtime          = "go1.x"
  timeout          = "10"
}

ここで説明したい点は2点です。

デプロイパッケージの指定

aws_lambda_functionではデプロイパッケージの指定方法が2種類、ローカル上に存在するものを使う方法と、S3にアップされているものを使う方法があります。今回は後者を採用しました。

ローカルの方法の方がS3へのアップロードが不要な分簡単で、ググるとそちらを使っている例の方が多いと思います。ですが今回、

  • ビルドしたバイナリファイルやzipのデプロイパッケージをGit管理外にする

という要件があり、この要件を踏まえると以下いずれかが回避できなさそうだったので、S3を使う方法を採用しました。

  • ローカルにバイナリやzipファイルがある/ない状況どちらもありえて、これをうまくハンドリングできなかった(エラーになるパターンがある)
  • コードに変更がなくても毎度ビルドとzip化が実行される =ムダ

S3を使う方法はs3_bucket,s3_keyattributeの指定が必須で、オプションでs3_object_versionも指定できます。逆にローカルファイルを指定する場合はfilenameを使います。

source_code_hash

このattributeは関数の再デプロイのトリガーに使われます。この値が更新されると再デプロイされます。

Must be set to a base64-encoded SHA256 hash of the package file

という要件があります。

ローカルのデプロイパッケージを使う場合は filebase64sha256("file.zip") とか base64sha256(file("file.zip")) といった方法が使えます。

また、ローカルのデプロイパッケージを使い、さらにzip化までTerraform管理内にする場合は Data Sources: archive_file が使えて、このAttributes Referenceにoutput_base64sha256というのがあるので、 source_code_hash = data.archive_file.deploy_package.output_base64sha256 みたいな書き方が使えます。

しかしながら、もう一方の方法、S3にアップされているファイルを使う方法の場合の指定方法が調べても中々見つからず、苦労しました。。最終的にbase64-encoded SHA256 hashの結果だけを格納したファイルを作成して、それをS3にアップし参照するという方法に落ち着きました。この点は後述します。

デプロイパッケージアップロード用S3バケット

前述したとおりデプロイパッケージをS3にアップロードする必要があるので、それ用のS3バケットをプロビジョニングします。セキュリティ面の最低限の設定を加えています。

lambda.tfの一部

resource "aws_s3_bucket" "lambda_assets" {}
resource "aws_s3_bucket_acl" "lambda_assets" {
  bucket = aws_s3_bucket.lambda_assets.bucket
  acl    = "private"
}
resource "aws_s3_bucket_server_side_encryption_configuration" "lambda_assets" {
  bucket = aws_s3_bucket.lambda_assets.bucket

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}
resource "aws_s3_bucket_public_access_block" "lambda_assets" {
  bucket = aws_s3_bucket.lambda_assets.bucket

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

デプロイパッケージを参照する Datasource: aws_s3_bucket_objects

前述のaws_lambda_function.s3_keyで参照されているdata sourceです。

depends_on = [null_resource.lambda_build] を指定しているのがポイントです。後述しますがこのnull_resource.lambda_build内でこのdata sourceで参照しているS3オブジェクトを作成しています。ですので、null_resource.lambda_buildの実行がこのdata sourceの参照より先に行われることを保証するためにdepends_onが必要なのです。またこのdepends_onのおかげでnull_resource.lambda_buildが再実行される度に参照も再度行われます。

lambda.tfの一部

data "aws_s3_object" "golang_zip" {
  depends_on = [null_resource.lambda_build]

  bucket = aws_s3_bucket.lambda_assets.bucket
  key    = local.golang_zip_s3_key // "archive/sample.zip"
}

ビルド、zip化、S3アップロードをnull_resourceでやる

null_resourceはちょっと特殊なリソースで、簡単にいうと指定したコマンドを実行するリソースです。 provisioner "local-exec"というブロック内で書いたコマンドはローカルで実行され、provisioner "remote-exec"ブロック内に書いたコマンドは外部サーバー上で実行されます。(接続設定が別途必要です)

local-exec

今回以下のように3つprovisioner "local-exec"ブロックを定義していて、それぞれ上からビルド、zip化(デプロイパッケージ作成)、S3へのアップロードを行なっています。(provisioner "local-exec" ブロックは他にもあと2つありますが、こちらは後述します。)

lambda.tfの一部

  provisioner "local-exec" {
    command = "GOARCH=amd64 GOOS=linux go build -o ${local.golang_binary_local_path} ${local.golang_codedir_local_path}/*.go"
  }
  provisioner "local-exec" {
    command = "zip -j ${local.golang_zip_local_path} ${local.golang_binary_local_path}"
  }
  provisioner "local-exec" {
    command = "aws s3 cp ${local.golang_zip_local_path} s3://${aws_s3_bucket.lambda_assets.bucket}/${local.golang_zip_s3_key}"
  }

locals.tf

locals {
  golang_codedir_local_path          = "${path.module}/lambdas/cmd/sample"
  golang_binary_local_path           = "${path.module}/lambdas/bin/sample"
  golang_zip_local_path              = "${path.module}/lambdas/archive/sample.zip"
  golang_zip_s3_key                  = "archive/sample.zip"
}

AWS認証設定についての注意点

最後のS3へのアップロード処理でaws cliを使っています。もしTerraform aws providerの認証設定を例えば以下のようにprovider "aws"の中でやっている場合、

provider "aws" {
  shared_config_files      = ["/Users/tf_user/.aws/conf"]
  shared_credentials_files = ["/Users/tf_user/.aws/creds"]
  profile                  = "customprofile"
}

このクレデンシャル設定は上記 null_resource内でやっているaws cliでは使われませんのでご注意ください。provider "aws"で定義した認証設定はaws providerの各resource / data sourceとやり取りするために使われるものであり、null provider管轄のnull_resourceとは無関係です。

AWS_PROFILEAWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEYといった環境変数でprovider "aws"外で認証設定している場合は、その認証がaws providerでもnull_resource内のaws cliコマンドでも使われるでしょう。

triggers

null_resource内で定義されたコマンドが実行されるタイミングは以下2つです。

  • 初回、リソース作成時
  • triggers attribute以下のいずれかの値が更新された際

今回triggers attributeは以下のように設定しています。

lambda.tfの一部

  triggers = {
    code_diff = join("", [
      for file in fileset(local.golang_codedir_local_path, "*.go")
      : filebase64("${local.golang_codedir_local_path}/${file}")
    ])
  }

./lambdas/cmd以下においたgolangのファイルすべてからハッシュを生成しています。コードに何かしら変更があった場合このハッシュ値が変わるので、コマンドが再実行つまりビルド、zip化、S3アップロードの処理が走ります。

aws_lambda_function.source_code_hash

provisioner "local-exec"のまだ説明していない残りの2つは、aws_lambda_function.source_code_hash値を更新して、Lambda関数再デプロイを走らせるための前処理です。

lambda.tfの一部

  provisioner "local-exec" {
    command = "openssl dgst -sha256 -binary ${local.golang_zip_local_path} | openssl enc -base64 | tr -d \"\n\" > ${local.golang_zip_base64sha256_local_path}"
  }
  provisioner "local-exec" {
    command = "aws s3 cp ${local.golang_zip_base64sha256_local_path} s3://${aws_s3_bucket.lambda_assets.bucket}/${local.golang_zip_base64sha256_s3_key} --content-type \"text/plain\""
  }

source_code_hash値は「base64-encoded SHA256 hash of the package file」である必要があるとResource: aws_lambda_functionの項でご説明しました。1つ目のprovisioner "local-exec"でこのハッシュ値を作成してファイル出力しています。そして2つ目でS3にアップロードしています。

アップロードしたファイルは以下Data Sourceで参照します。これもdepends_on = [null_resource.lambda_build]を入れることで、必ず S3にファイルがアップロードされた後に参照されるようにしています。

lambda.tfの一部

data "aws_s3_object" "golang_zip_hash" {
  depends_on = [null_resource.lambda_build]

  bucket = aws_s3_bucket.lambda_assets.bucket
  key    = local.golang_zip_base64sha256_s3_key
}

そして、aws_lambda_functionにて上記Data Sourceを参照します。aws_s3_object Data Sourceにはbodyというfieldがあるので、オブジェクトの中身を使えるというわけです。

lambda.tfの一部

resource "aws_lambda_function" "sample" {
  function_name    = "sample"
  s3_bucket        = aws_s3_bucket.lambda_assets.bucket
  s3_key           = data.aws_s3_object.golang_zip.key
  role             = aws_iam_role.iam_for_lambda.arn
  handler          = "sample"
  source_code_hash = data.aws_s3_object.golang_zip_hash.body
  runtime          = "go1.x"
  timeout          = "10"
}

ポイントとしては、null_resource内でaws s3 cpコマンドでS3にハッシュ文字列を格納したファイルをアップロードする際に、Content Typeを指定している点です。 aws_s3_object Data Sourceでbody値を参照するには、該当オブジェクトのContent Typeがhuman-readableなもの(text/* もしくは application/json) である必要があるためです。

Note: The content of an object (body field) is available only for objects which have a human-readable Content-Type (text/* and application/json). This is to prevent printing unsafe characters and potentially downloading large amount of data which would be thrown away in favour of metadata.

指定なしでアップロードするとContent Typeがbinary/octet-streamになってしまい、bodyフィールドはnullを返していました。

参考情報