ローカルのAWS Lambda開発環境を整える(hot reload)

ローカルでLambdaの開発をやるために環境を整備したのでやったことを残しておきます。

zenn にも全く同じ内容を書いています。

https://zenn.dev/imishinist/articles/8ff24429f0eb41 

今回作成したサンプルの全体コードはこちらのリポジトリです。

https://github.com/imishinist/lambda-sample

## できること
1. ローカルに立ち上げたDockerでAWS Lambdaを動かす
2. 手元からcurlでLambdaを呼び出す
3. ビルド時に自動でLambdaを更新する(hot-reload)

### 動かし方

Goのコード(Lambdaのハンドラ)をビルドしたいときは以下の `make` を実行する。

```bash
make build-lambda
```

Lambdaを立ち上げたいときは `docker-compose` で起動する。

```bash
docker compose up -d --build
```

## 仕組み

### AWS Lambda を Docker 上で動かす

Docker上で ハンドラを動かすためにコードをビルドする。

```Makefile:Makefile
.PHONY: build-lambda
build-lambda:
        cd src/lambda && \
                GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o ../../build/main
```

```bash:ビルド
make build-lambda
```

> Docker上で動かすため、 Linux 向けに**クロスコンパイル**するのを忘れないように注意

---

実行環境としては、AWSが公式で提供している、Dockerイメージを使用する
Dockerイメージ: `public.ecr.aws/lambda/provided:al2`

```Dockerfile:Dockerfile
FROM public.ecr.aws/lambda/provided:al2

COPY --chmod=755 ./docker/lambda/entrypoint.sh /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
CMD ["/app/main"]
```

`/usr/local/bin/aws-lambda-rie` を使用して `entrypoint.sh` に対して、ビルドしたコードを渡すことでリクエストを処理してもらう。

```bash:/entrypoint.sh
#!/bin/bash

if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
  exec /usr/local/bin/aws-lambda-rie "$@"
else
  exec "$@"
fi
```

```yaml:docker-compose.yaml
services:
  lambda-docker:
    build:
      context: .
      dockerfile: ./Dockerfile
    ports:
      - 8080:8080
    volumes:
      - ./build:/app
    environment:
      - PORT=8080
```


```bash:起動
docker compose up -d --build
```

```bash:Lambda実行
$ curl -i "http://localhost:8080/2015-03-31/functions/function/invocations" -d '{}'
HTTP/1.1 200 OK
Date: Fri, 17 May 2024 10:25:47 GMT
Content-Length: 4
Content-Type: text/plain; charset=utf-8

null
```

ここまでやれば、 「1. ローカルに立ち上げたDockerでAWS Lambdaを動かす」ことと「2. 手元からcurlでLambdaを呼び出す」 ことが実現できる。

### hot reload を実現

このままだと、コードをビルドしても、Docker上のLambdaには反映されない。
ビルドしたら自動でLambdaに反映させるために、 [watchexec](https://github.com/watchexec/watchexec) を使用する。

`./build/main` ファイルを `/app/main` にマウントしているため、Docker内で `watchexec` を適切に設定して起動すれば、ビルド時に自動でリロードしてくれるようになる。

Multi Stage Build を使って、watchexec のみを Lambdaのイメージに含めるようにしておく。

```Dockerfile:Dockerfile
# syntax=docker/dockerfile:1
FROM ubuntu:latest AS builder

RUN apt update && apt install -y curl xz-utils

ENV WATCHEXEC_VERSION=2.1.1
RUN mkdir -p /tmp/dist && \
  curl -sSL https://github.com/watchexec/watchexec/releases/download/v${WATCHEXEC_VERSION}/watchexec-${WATCHEXEC_VERSION}-$(uname -i)-unknown-linux-musl.tar.xz | tar -xJ -C /tmp/dist --strip-components 1

FROM public.ecr.aws/lambda/provided:al2

COPY --from=builder /tmp/dist/watchexec /usr/bin/watchexec

COPY --chmod=755 ./docker/lambda/entrypoint.sh /entrypoint.sh
COPY --chmod=755 ./docker/lambda/watch.sh /watch.sh

ENTRYPOINT ["/watch.sh"]

CMD ["/app/main"]
```

ここで、 `--force-poll` オプションを有効にしているのは、 Mac での開発時にうまく検知してくれないことがあったため。不要な場合は削除しても良い。

あと `watchexec` の仕様として、ウォッチする対象をファイルで指定していると、 ビルドした後に変更を検知してくれなくなるので、 `/app/` ディレクトリを指定している。（多分検知対象のファイルをオープンしたときの ファイルディスクリプターを元に変更を検知していて、`go build` を実行するとファイルが置き換わるためではないかと思う）

```bash:/watch.sh
#!/bin/bash

watchexec -w /app/ --force-poll 100ms -r /entrypoint.sh "$@"
```

これで無事にホットリロードが実現できた。

![](https://storage.googleapis.com/zenn-user-upload/04388485e3f8-20240517.gif)
（途中 null が返っちゃってるのは、ファイルが置き換わったときの遅延？よくわからない）

## まとめ

- Goのコードをビルドするときは、Linux向けにクロスコンパイルする
- `public.ecr.aws/lambda/provided:al2` イメージを使う
- `watchexec` でhot-reloadを実現するときは、 `--force-poll` オプションと、対象をディレクトリにする