俺流AWS Lambdaデプロイ+IaCのベストプラクティスを考える

# 概要

Lambdaをコード管理したいのですが、Lambdaのベストプラクティス で良い感じのものがないと思い困り調べてみました。

以下のデプロイ方法がありますが、どれも個人的にはいまいちなため悩んでいました。

- AWS CLIでのデプロイする
- 独自のデプロイツール(Lambdaroll)を使う
    - https://tech.toreta.in/entry/2020/12/05/000000
- ServerlessFrameworkを使う
- AWS CDKでデプロイする
    - https://zenn.dev/faycute/articles/be5599fc093511

## 課題感

サーバレス関連のツールはどうしても構成管理ツールと紐づいてしまっているのでどうしたものかなぁと思っていました。

例えば、AWS CDKでもServerlessFrameworkでもバックエンドではCloudFormationで動いてしまっています。このようなツールのメリットは１リポジトリでインフラの構成管理とコード管理が完結することがメリットだと思っています。ただ、既存インフラ環境のIaCにTerraformを使っている場合、TerraformとCloudFormationのダブルスタンダードになってしまい個人的には気持ち悪いなと思っています。

それを解消する方法をこの記事では考えていきたいです。

# Lambdaリソースの基本設計

## 設計方針

Lambdaのデプロイ方法はソースコードをzipで固めてS3に置くか、ECRにDocker Imageをプッシュするかを現在は選ぶことができます。

保守性が高く、どの環境でもちゃんとアプリケーションのデバッグができるという点ではDocker Imageの方がいいので、LambdaコードはECRにプッシュすることを選びます。

ちなみにサーバレスアプリケーションを作るときは、アプリケーションごとにリポジトリを作る方が良いとAWSの中の人に聞いたことがありますが、今回の用途としてはヘッダー書替え用やIP制限等のLambdaを扱いたいため１リポジトリでディレクトリを分けてデプロイします。

## Lambdaリソースの管理方針

- インフラリソースはTerraformで管理
- Lambdaへのデプロイはソースコード側で行う
- CI/CDはGitHub Actionsで行う

### インフラリソースをTerraform管理にする

ここではLambda Functionの管理とIAM、ECRの管理をします。

```HCL
# IAM
resource "aws_iam_role" "lambda_basic" {
  name = "lambda-basic-role"
  path = "/service-role/"

  assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json
}

data "aws_iam_policy_document" "lambda_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

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

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_basic.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# ECR
resource "aws_ecr_repository" "lambda1" {
  name                 = "lambda-function1"
  image_tag_mutability = "IMMUTABLE"
  image_scanning_configuration {
    scan_on_push = true
  }
}

# Lambda Function
resource "aws_lambda_function" "lambda1" {
  function_name = "lambda-function1"
  role          = aws_iam_role.lambda_basic.arn
  package_type  = "Image"
  image_uri     = "${aws_ecr_repository.lambda1.repository_url}:latest"
  timeout       = 60

  lifecycle {
    ignore_changes = [image_uri]
  }
}

# CloudWatch Logs
## ref https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/monitoring-cloudwatchlogs.html
resource "aws_cloudwatch_log_group" "lambda1" {
  name              = "/aws/lambda/${aws_lambda_function.lambda1.function_name}"
  retention_in_days = 30
}
```

### Lambdaコードを作成する

以下のようなディレクトリにコードを配置します。

言語はnode.jsにします。

```bash
$ tree
.
└── lambda-function1
    ├── Dockerfile
    ├── index.js
    ├── package-lock.json
    └── package.json
```

各ファイルの中身は以下の通りです。

```jsx
{
  "name": "lambda-container-image-example",
  "license": "MIT"
}
```

`npm install` して `package-lock.json` は作っておいてください。

`index.js` のコード内容は以下の通り。

```jsx
exports.handler = (event, context, callback) => {
   const response = event.Records[0].cf.response;
   const headers = response.headers;

   const headerNameSrc = 'X-Amz-Meta-Last-Modified';
   const headerNameDst = 'Last-Modified';

   if (headers[headerNameSrc.toLowerCase()]) {
      headers[headerNameDst.toLowerCase()] = [
         headers[headerNameSrc.toLowerCase()][0],
      ];
      console.log(`Response header "${headerNameDst}" was set to ` +
               `"${headers[headerNameDst.toLowerCase()][0].value}"`);
   }

   callback(null, response);
};
```

Dockerfileはこの通り

```docker
# AWS ベースイメージを使用
FROM public.ecr.aws/lambda/nodejs:14

# ソースコードを関数のルートディレクトリにコピーします。
# 関数のルートディレクトリは `LAMBDA_TASK_ROOT` 環境変数を上書きすることで変更することができます ( デフォルトは `/var/task` ) 。
COPY index.js package.json package-lock.json /var/task/
RUN npm install

# CMD にハンドラを設定します。
# Node.js の場合は `{ファイル名(拡張子なし)}.{関数名}` のように指定します。
# 今回は `index.js` の `handler` 関数をハンドラとして用意しているので以下のようになります。
CMD ["index.handler"]
```

### GitHub Actionsのworkflowを作成する

Lambdaのソースコードおいてあるリポジトリでこちらのコードを設定してください。

GitHub Actionsで特定のディレクトリで操作された時にデプロイされるように設定してます。

```yaml
name: "Deploy"

on:
  push:
    branches:
      - main
    paths:
      - "lambda-function1/**"

jobs:
  lambda:
    name: "Deploy Lambda"
    runs-on: ubuntu-latest

    defaults:
      run:
        shell: bash
        working-directory: ./lambda-function1/

    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          fetch-depth: 1
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: docker build & docker push & lambda update
        run: |
          docker build -t XXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/lambda-function1:${{ github.sha }} .
          docker push XXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/lambda-function1:${{ github.sha }}
          aws lambda update-function-code --function-name lambda-function1 --image-uri XXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/lambda-function1:${{ github.sha }}
```

# Tips

## GitHub Actionsのマーケットプレイスにあるものは使えない

GitHubActionsの公式にLambdaデプロイ用のコードがありますが、これはimage対応していないのでエラーになります。
参考資料: [lambda-action](https://github.com/appleboy/lambda-action)

大元はこちらのコードですが、ファイルのみの対応になってしまっています。 `image_uri` だけ指定してデプロイを試みても400エラーになります。
参考資料: [drone-lambda](https://github.com/appleboy/drone-lambda)

# 結論

ServerlessFrameworkのようにそのフレームワークだけ使えばインフラのことを管理しなくても良いのですが、元々のインフラ設計がサーバーレスを中心とした設計でない場合、余計なインフラリソースが勝手に作られて運用が辛い経験がありました。

この方法を使えば、TerraformとLambdaのデプロイを分離できますし、ServerlessFrameworkでできるような余計なインフラリソースが増えてしまう心配がありませんし、不要になれば、Terraformから削除すればAWS環境も綺麗な状態を保てるのでお勧めです。