AWS LambdaにGo言語のスクリプトをzipファイル形式でデプロイする [Terraformを使用]

## 要約
最終的なソースコード全体は以下のリンクからご確認いただけます。

https://github.com/mochi-yu/qiita-material/tree/main/golang-lambda-by-zip-with-terraform

## 関連記事
Go言語で記述したスクリプトを、**AWSのLambdaにTerraformを用いてデプロイする**機会があり、その過程の試行錯誤を整理して一連の記事にまとめました。

この記事はそのうちの1本目として、zipファイルを用いてLambdaにスクリプトをデプロイしてみたいと思います。

- **zipファイルを用いてGo言語の処理をAWS Lambdaにデプロイする**（この記事）
- [Dockerイメージを用いてGo言語の処理をAWS Lambdaにデプロイする](https://qiita.com/mochi_2225/items/000ee604b3a274b6b62d)
- [EventBridgeからAWS Lambdaを定期実行する処理をデプロイする](https://qiita.com/mochi_2225/items/e41d1f2b87e2bd886790)

## 背景
最近、個人開発でもAWSのようなクラウド環境を使う時には、**リソース管理のために`terraform`を使う**ようにしています。

手動のポチポチ操作を減らして自動化できたり、どのような設定だったのかを忘れることがなくなったり、再利用して別のプロジェクトに応用できたりととても便利です。

加えて最近は、Webのバックエンドやスクリプトの実装には**Go言語**を用い、ローカル環境以外で実行したい場合には **`AWS Lambda`** というサービスへデプロイして利用することが多いです。

ということで、この記事ではその**AWS Lambdaへのデプロイをterraformを用いて行う方法**をまとめたいと思います。

なお、lambdaへのデプロイ方法は実行バイナリを**zip形式でまとめてアップロードする方法**と、**Dockerのコンテナイメージをアップロードする方法**が主にあります。この記事は前者の**zip形式でのアップロード**についてです。

## 実装
ファイル構成は次のようにしています。

```
.
├── .DS_Store
├── archive
│   ├── .gitignore
│   ├── archive.zip
│   └── archive_hash.txt
├── bin
│   ├── .gitignore
│   └── bootstrap
├── src
│   ├── go.mod
│   ├── go.sum
│   └── main.go
└── terraform
    ├── .gitignore
    ├── .terraform.lock.hcl
    ├── lambda-assume-role.json
    ├── lambda.tf
    ├── main.tf
    └── terraform.tfstate
```

### Lambdaで実行するプログラムを用意する
今回は簡単に以下のようなプログラムを用意しました。ここが本題ではないので、Lambdaの処理をGo言語で書くことについては、別の記事や[公式のドキュメント](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-golang.html)を参照してください。

処理は簡単に、イベントとして名前を受け取って、挨拶と現在時刻を返すというものです。

<details>
<summary>今回実行するプログラム</summary>

```go:src/main.go
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/aws/aws-lambda-go/lambda"
)

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

type MyResponse struct {
	Message string `json:"msg"`
	Time    string `json:"time"`
}

func HandleRequest(ctx context.Context, event *MyEvent) (*MyResponse, error) {
	if event == nil {
		return nil, fmt.Errorf("received nil event")
	}

	msg := "Hello, " + event.Name + "!"
	res := MyResponse{
		Message: msg,
		Time:    time.Now().String(),
	}
	log.Print(res)

	return &res, nil
}

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

</details>

### TerraformのLambda以外の部分を用意する
ここはシンプルに、awsのプロバイダーを使用することなどを設定しておきます。

```terraform:terraform/main.tf
terraform {
  required_version = ">=1.7"

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

provider "aws" {
  region = "ap-northeast-1"
}
```

### Lambda関数の設定に関する部分
長くなりますが、lambda関係の設定の全文は以下のとおりです。それぞれ部分ごとに区切って説明していきます。

<details>
<summary>全文を表示</summary>

```terraform:terraform/lambda.tf
locals {
  s3_bucket = "hoge"  # FIXME
  s3_key_prefix = "test-lambda"
  s3_base_path = "${local.s3_bucket}/${local.s3_key_prefix}"

  golang_codedir = "${path.module}/../src"
  binary_file_name = "bootstrap"
  zip_file_name  = "archive.zip"
  hash_file_name = "archive_hash.txt"

  binary_file_path = "${local.golang_codedir}/../bin/${local.binary_file_name}"
  zip_file_path = "${local.golang_codedir}/../archive/${local.zip_file_name}"
  hash_file_path = "${local.golang_codedir}/../archive/${local.hash_file_name}"
}

resource "aws_lambda_function" "test_lambda" {
  function_name    = "test-lambda"
  s3_bucket        = local.s3_bucket
  s3_key           = data.aws_s3_object.zip.key
  role             = aws_iam_role.lambda_role.arn
  handler          = "main"
  source_code_hash = data.aws_s3_object.zip_hash.body
  runtime          = "provided.al2"
}

resource "aws_iam_role" "lambda_role" {
  name = "role-for-test_lambda"
  assume_role_policy = file("lambda-assume-role.json")
}

resource "null_resource" "lambda_build" {
  triggers = {
    code_diff = join("", [
      for file in fileset(local.golang_codedir, "**/{*.go,go.mod,go.sum}")
      : filesha256("${local.golang_codedir}/${file}")
    ])
  }

  # コードのビルド
  provisioner "local-exec" {
    command = "cd ${local.golang_codedir} && CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build -tags lambda.norpc -o ../bin/${local.binary_file_name} ./*.go"
  }

  # バイナリのzip化
  provisioner "local-exec" {
    command = "zip -j ${local.zip_file_path} ${local.binary_file_path}"
  }

  # バイナリのs3へのアップロード
  provisioner "local-exec" {
    command = "aws s3 cp ${local.zip_file_path} s3://${local.s3_base_path}/${local.zip_file_name}"
  }

  # ハッシュの生成
  provisioner "local-exec" {
    command = "openssl dgst -sha256 -binary ${local.zip_file_path} | openssl enc -base64 | tr -d \"\n\" > ${local.hash_file_path}"
  }

  # ハッシュのs3へのアップロード
  provisioner "local-exec" {
    command = "aws s3 cp ${local.hash_file_path} s3://${local.s3_base_path}/${local.hash_file_name} --content-type \"text/plain\""
  }
}

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

  bucket = local.s3_bucket
  key    = "${local.s3_key_prefix}/${local.zip_file_name}"
}

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

  bucket = local.s3_bucket
  key    = "${local.s3_key_prefix}/${local.hash_file_name}"
}
```

```json:terraform/lambda-assume-role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Sid": "",
      "Effect": "Allow"
    }
  ]
}
```
</details>

#### 各種変数の定義
はじめのブロックでは実行に関係する変数を定義しています。

`s3_bucket = "hoge"`の箇所でzipファイルなどをアップロードするS3バケットの指定をしていますが、こちらは各自でご用意ください。この記事のTerraformでは**S3のバケット作成は行なっていません**。すでにあるものと仮定しています。

S3のファイルパスは、私の環境の都合上`s3_key_prefix = "test-lambda"`というprefixを指定しています。これも各自の環境に合わせてご設定ください。

これらの変数のパスやファイル名は基本的に変更できますが、**実行ファイルのファイル名`binary_file_name = "bootstrap"`だけはLambdaの仕様上変更できません**のでご注意ください。

```terraform:terraform/lambda.tf（一部）
locals {
  s3_bucket = "hoge"  # FIXME
  s3_key_prefix = "test-lambda"
  s3_base_path = "${local.s3_bucket}/${local.s3_key_prefix}"

  golang_codedir = "${path.module}/../src"
  binary_file_name = "bootstrap"
  zip_file_name  = "archive.zip"
  hash_file_name = "archive_hash.txt"

  binary_file_path = "${local.golang_codedir}/../bin/${local.binary_file_name}"
  zip_file_path = "${local.golang_codedir}/../archive/${local.zip_file_name}"
  hash_file_path = "${local.golang_codedir}/../archive/${local.hash_file_name}"
}
```

#### lambdaのロールの設定
ここで**lambda用のロールを作成**しています。今回の記事の中ではCloud Watchへのログ出力やDBアクセスなどを行なっていませんが、これらを実施する場合には、**このroleに適切な許可ポリシーをアタッチしてください**。

```terraform:terraform/lambda.tf（一部）
resource "aws_iam_role" "lambda_role" {
  name = "role-for-test_lambda"
  assume_role_policy = file("lambda-assume-role.json")
}
```

```json:terraform/lambda-assume-role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Sid": "",
      "Effect": "Allow"
    }
  ]
}
```

#### ソースコードのビルド、zip圧縮、S3へのアップロード
ここが一番重要な箇所かと思います。**Go言語のソースのビルド、zip圧縮、S3へのアップロード**といった処理を順番に行なっています。

[`null_resource`](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource)というリソースは、それによって生成されるAWSのリソースの様なものはありませんが、代わりに**内部の`local-exec`によりローカルでコマンドを実行する**のに利用しています。何らかの**トリガーを指定**することができ、また、これを**他のリソースの`depends_on`にも指定**し処理を待つこともできます。

```terraform:terraform/lambda.tf（一部）
resource "null_resource" "lambda_build" {
  triggers = {
    code_diff = join("", [
      for file in fileset(local.golang_codedir, "**/{*.go,go.mod,go.sum}")
      : filesha256("${local.golang_codedir}/${file}")
    ])
  }

  # コードのビルド
  provisioner "local-exec" {
    command = "cd ${local.golang_codedir} && CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build -tags lambda.norpc -o ../bin/${local.binary_file_name} ./*.go"
  }

  # バイナリのzip化
  provisioner "local-exec" {
    command = "zip -j ${local.zip_file_path} ${local.binary_file_path}"
  }

  # バイナリのs3へのアップロード
  provisioner "local-exec" {
    command = "aws s3 cp ${local.zip_file_path} s3://${local.s3_base_path}/${local.zip_file_name}"
  }

  # ハッシュの生成
  provisioner "local-exec" {
    command = "openssl dgst -sha256 -binary ${local.zip_file_path} | openssl enc -base64 | tr -d \"\n\" > ${local.hash_file_path}"
  }

  # ハッシュのs3へのアップロード
  provisioner "local-exec" {
    command = "aws s3 cp ${local.hash_file_path} s3://${local.s3_base_path}/${local.hash_file_name} --content-type \"text/plain\""
  }
}
```

`triggers`のブロックでは、**Go言語のソースファイルからハッシュ値を計算**し、その値が変わった場合（すなわちソースコードに変更があった場合）に内部の処理が実行されるようになっています。

[`fileset`関数](https://developer.hashicorp.com/terraform/language/functions/fileset)でパターンにマッチするファイルを全て取得し、for文で[`filesha256`関数](https://developer.hashicorp.com/terraform/language/functions/filesha256)に渡すことでハッシュ値を求め、それを[`join`関数](https://developer.hashicorp.com/terraform/language/functions/join)でくっつけています。ファイル数が多くなるとこの部分が長くなることが考えられるので、修正が必要かもしれません…。

そのあとは[`provisioner "local-exec"`](https://developer.hashicorp.com/terraform/language/resources/provisioners/local-exec)でビルド・zip化・S3へのアップロード、といった処理を行なっています。

一連の処理の中では**バイナリをzip圧縮した後に、そのzipファイルのハッシュ値を求める**という処理をしています。後述しますがlambdaの仕様で **`source_code_hash`というパラメータでソースの変更をLambda側に通知**し、Lambdaにおける実行ファイルの置き換えをトリガーする仕組みがあります。そのためのハッシュ値がここで用意しているハッシュになります。

#### S3のオブジェクトを参照する
ここでは[`aws_s3_object`](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/s3_object)でS3のオブジェクトを参照しています。具体的には**実行バイナリのzipファイル**と、**zipのハッシュ値を記録したテキストファイル**です。

今回の方針ではLambdaの実行ファイルは**S3にアップロードされているzipファイルを使用**するようにしています。その「S3にアップロードされているファイル」の参照を、このデータソースから行なっている形です。

データソースは`depends_on = [null_resource.lambda_build]`となっており、**先ほどのビルドなどの処理の後に実行**されます。すなわち、ソースコードに変更があった場合には、必ずビルドおよびS3へのアップロードを待ってから、新たにアップロードされたファイルが参照されるという形になります。

```terraform:terraform/lambda.tf（一部）
data "aws_s3_object" "zip" {
  depends_on = [null_resource.lambda_build]

  bucket = local.s3_bucket
  key    = "${local.s3_key_prefix}/${local.zip_file_name}"
}

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

  bucket = local.s3_bucket
  key    = "${local.s3_key_prefix}/${local.hash_file_name}"
}
```

#### Lambda本体を設定する
最後に、Lambda本体を設定します。関数の名前、ソースのS3バケットとオブジェクトのキー、Lambda用のロール、ソースのハッシュ値などを指定します。[runtimeは`"provided.al2"`の他に`provided.al2023`も使用できます](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-golang.html)。

```terraform:terraform/lambda.tf（一部）
resource "aws_lambda_function" "test_lambda" {
  function_name    = "test-lambda"
  s3_bucket        = local.s3_bucket
  s3_key           = data.aws_s3_object.zip.key
  role             = aws_iam_role.lambda_role.arn
  handler          = "bootstrap"
  source_code_hash = data.aws_s3_object.zip_hash.body
  runtime          = "provided.al2"
}
```

### Lambdaのデプロイ
ここまできたら`terraform apply`で構成を適用しましょう。

AWSのコンソールに行ってみて、Lambda関数がこの様にデプロイされており、テストが成功すればOKです。

![go_terraform_zip-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351104/66695418-80e5-ff5c-9343-4175da98f3f6.png)

![go_terraform_zip-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351104/bf62d119-5112-6a70-3960-120e8065ebbd.png)

確認用のコンテンツであれば、**`terraform destroy`の実行**を忘れない様にしましょう。

## 参考

https://dev.classmethod.jp/articles/deploy-golang-lambda-function-with-terraform/

https://engineering.nifty.co.jp/blog/20337

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-golang.html