Lambda function URLsをTerraformでお手軽構築する

# はじめに
4/7に突如登場したLambda function URLs。
ついにAPI GatewayやALBを介さずにLambdaが直接HTTPサーバの機能を持ったので、お手軽に利用する幅が拡がることだろう。
今回は、実際どれだけお手軽になったか、どんなことが制約になり得るかを実際に構築しながら考察をしてみたい。

なお、事前知識としては以下を想定している。

- TerraformでLambda Functionを作成した経験がある
- Lambda Permissionsに関する知識が多少ある

Lambda関数の実装にはGolangを使っているが、基本的なことしかやっていないためそれほど重要ではない。

# 構成図
今回は以下の構成で作成をする。

![構成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/0d20ee3b-7998-c96d-05cf-a48660a19370.png)

- ServerとProxyのLambdaそれぞれにfunction URLsを設定する
- ServerはHTTPレスポンスのBodyで "Hello Lambda Function URLs!!" を応答する
- Proxyは、その名の通り、Serverへのリクエストとレスポンスのプロキシを行う
- Serverは、Proxyに設定したIAMのみ許容するfunction URLsを設定して、パブリックアクセスを行わせない
- Proxyは、パブリックアクセス可能なfunction URLsを設定する

# Lambda関数
## Server
Serverは超シンプルに以下の構成とする。

```golang:main.go
package main

import (
	"context"
	"encoding/json"
	"net/http"

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

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

func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	jsonBytes, _ := json.Marshal(struct {
		Detail string `json:"detail"`
	}{
		Detail: "Hello Lambda Function URLs!!",
	})

	return events.APIGatewayProxyResponse{
		StatusCode:      http.StatusOK,
		IsBase64Encoded: false,
		Body:            string(jsonBytes),
	}, nil
}
```

ポイントになるのは、ハンドラに渡されてくるイベントの型だ。
[公式のデベロッパーガイド](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/urls-invocation.html)に

> The request and response event formats follow the same schema as the Amazon API Gateway payload format version 2.0.

と記載されている通り、API Gatewayのイベントをそのまま流用できるようになっている。
既存のAPI Gatewayのバックエンドに配置された関数を用意に移植出来て良い感じだ。

## Proxy
Proxyは以下の通り実装する。

```golang:main.go
package main

import (
	"context"
	"encoding/json"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/aws/aws-sdk-go/aws/session"
	v4 "github.com/aws/aws-sdk-go/aws/signer/v4"

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

type responseBody struct {
	Detail string `json:"detail"`
}

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

func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	client := &http.Client{
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
	req, err := http.NewRequest("GET", os.Getenv("LAMBDA_SERVER_URL"), nil)
	if err != nil {
		jsonBytes, _ := json.Marshal(responseBody{
			Detail: "http.Get() Failed",
		})

		return events.APIGatewayProxyResponse{
			StatusCode:      http.StatusInternalServerError,
			IsBase64Encoded: false,
			Body:            string(jsonBytes),
		}, nil
	}

	// リクエストに署名を付加
	sess := session.Must(session.NewSession())
	credential := sess.Config.Credentials
	signer := v4.NewSigner(credential)
	signer.Sign(req, nil, "lambda", "ap-northeast-1", time.Now())

	resp, err := client.Do(req)
	if err != nil {
		jsonBytes, _ := json.Marshal(responseBody{
			Detail: "client.Do() Failed",
		})

		return events.APIGatewayProxyResponse{
			StatusCode:      http.StatusInternalServerError,
			IsBase64Encoded: false,
			Body:            string(jsonBytes),
		}, nil
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		jsonBytes, _ := json.Marshal(responseBody{
			Detail: "ioutil.ReadAll() Failed",
		})

		return events.APIGatewayProxyResponse{
			StatusCode:      http.StatusInternalServerError,
			IsBase64Encoded: false,
			Body:            string(jsonBytes),
		}, nil
	}

	return events.APIGatewayProxyResponse{
		StatusCode:      http.StatusOK,
		IsBase64Encoded: false,
		Body:            string(body),
	}, nil
}
```

Lambda function URLsは動的に生成されるため、冪等にするためには、環境変数で渡してあげて、```http.NewRequest()```の引数で```os.Getenv()```で取得するようにしよう。(試していないが、Route53のエイリアスレコードを作っても良いかもしれない)

:::note warn
特筆すべきは、

```golang
import (
    // (中略)
	"github.com/aws/aws-sdk-go/aws/session"
	v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
    // (中略)
)
    // (中略)
	// リクエストに署名を付加
	sess := session.Must(session.NewSession())
	credential := sess.Config.Credentials
	signer := v4.NewSigner(credential)
	signer.Sign(req, nil, "lambda", "ap-northeast-1", time.Now())
```

の部分だ。
ProxyからServerへの通信の際はIAM認証を行う。通常のAWSサービス間の通信であれば、この手の認証情報はSDKが良い感じにラップしてくれていて利用者が意識するのはセッションを作るタイミングのみであるが、あくまでもLambda function URLsへの通信は通常のHTTPS通信の世界であるため、自分でSigV4の署名を設定する必要がある。

つまり、現在の構成によっては(例えばnginxプロキシの後段にAPI Gateway＋Lambdaで実装している構成を置換するケースでは、nginxがSigV4の署名を付ける必要があり)、構成見直しのハードルが上がるので注意が必要だ。
:::

# IaC
あとは、粛々とIaCを書いていけば良い。
API Gatewayを作らなくて良いので非常に気楽だ。

```HCL
################################################################################
# Lambda(Server)                                                               #
################################################################################
data "archive_file" "server" {
  type        = "zip"
  source_dir  = "../artifact/server"
  output_path = "../outputs/lambda_server_function.zip"
}

resource "aws_lambda_function" "server" {
  depends_on = [
    aws_cloudwatch_log_group.lambda_server,
  ]

  function_name    = local.lambda_server_function_name
  filename         = data.archive_file.server.output_path
  role             = aws_iam_role.lambda.arn
  handler          = "server"
  source_code_hash = data.archive_file.server.output_base64sha256
  runtime          = "go1.x"

  memory_size = 128
  timeout     = 30
}

resource "aws_lambda_function_url" "server" {
  function_name      = aws_lambda_function.server.function_name
  authorization_type = "AWS_IAM"
}

resource "aws_lambda_permission" "allow_function_url_auth_type_iam" {
  statement_id  = "FunctionURLAllowLambdaIAMAccess"
  action        = "lambda:InvokeFunctionUrl"
  function_name = aws_lambda_function.server.function_name
  principal     = aws_iam_role.lambda.arn
  function_url_auth_type = "AWS_IAM"
}

resource "aws_cloudwatch_log_group" "lambda_server" {
  name              = "/aws/lambda/${local.lambda_server_function_name}"
  retention_in_days = 3
}

################################################################################
# Lambda(Proxy)                                                                #
################################################################################
data "archive_file" "proxy" {
  type        = "zip"
  source_dir  = "../artifact/proxy"
  output_path = "../outputs/lambda_proxy_function.zip"
}

resource "aws_lambda_function" "proxy" {
  depends_on = [
    aws_cloudwatch_log_group.lambda_proxy,
  ]

  function_name    = local.lambda_proxy_function_name
  filename         = data.archive_file.proxy.output_path
  role             = aws_iam_role.lambda.arn
  handler          = "proxy"
  source_code_hash = data.archive_file.proxy.output_base64sha256
  runtime          = "go1.x"

  memory_size = 128
  timeout     = 30

  environment {
    variables = {
      LAMBDA_SERVER_URL = aws_lambda_function_url.server.function_url
    }
  }
}

resource "aws_lambda_function_url" "proxy" {
  function_name      = aws_lambda_function.proxy.function_name
  authorization_type = "NONE"
}

resource "aws_cloudwatch_log_group" "lambda_proxy" {
  name              = "/aws/lambda/${local.lambda_proxy_function_name}"
  retention_in_days = 3
}

################################################################################
# IAM                                                                          #
################################################################################
resource "aws_iam_role" "lambda" {
  name               = local.iam_role_name_lambda
  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}

data "aws_iam_policy_document" "lambda_assume" {
  statement {
    effect = "Allow"

    actions = [
      "sts:AssumeRole",
    ]

    principals {
      type = "Service"
      identifiers = [
        "lambda.amazonaws.com",
      ]
    }
  }
}

resource "aws_iam_role_policy" "lambda_custom" {
  name   = local.iam_policy_name_lambda
  role   = aws_iam_role.lambda.id
  policy = data.aws_iam_policy_document.lambda_service_account_custom.json
}

data "aws_iam_policy_document" "lambda_service_account_custom" {
  statement {
    effect = "Allow"

    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]

    resources = [
      "*",
    ]
  }
}

################################################################################
# Outputs                                                                      #
################################################################################
output "lambda_server_function_url" {
  value = aws_lambda_function_url.server.function_url
}

output "lambda_proxy_function_url" {
  value = aws_lambda_function_url.proxy.function_url
}
```

なお、Lambda function URLsの記述は以下だけで良い。あとは自動でリソースポリシーとURLが設定される。

```HCL
resource "aws_lambda_function_url" "server" {
  function_name      = aws_lambda_function.server.function_name
  authorization_type = "AWS_IAM"
}
```

```HCL
resource "aws_lambda_function_url" "proxy" {
  function_name      = aws_lambda_function.proxy.function_name
  authorization_type = "NONE"
}
```

# 実際に動かしてみる
さて、ここまでで作ったGolangをビルドし、```terraform apply```すると、

```
Outputs:

lambda_proxy_function_url = "https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.lambda-url.ap-northeast-1.on.aws/"
lambda_server_function_url = "https://yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy.lambda-url.ap-northeast-1.on.aws/"
```

といった感じで出力される。
これを、curlで直接実行した場合、lambda_server_function_urlは403応答をして、lambda_proxy_function_urlの場合は

```JSON
{"detail":"Hello Lambda Function URLs!!"}
```

という応答が帰ってくるはずだ。

リソースポリシーの部分が多少詰まったが、それ以外は1時間もかからず構築できたので、Lambda利用の幅がまた広がったかと思う。