Go言語をつかった Lambda関数の開発をもっと楽にしたい〜Codespacesを添えて〜

メリークリスマス！
この記事は[朝日新聞社 Advent Calendar 2022](https://qiita.com/advent-calendar/2022/asahi)の24日目の記事です。

## TL;DR
Dockerコンテナ上で RIE と air つかってサクサク開発環境を作りましょう
サンプルは https://github.com/duck8823/sample-lambda にあります。
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=581006091)

## 今回の開発スタイル
こんな感じで開発するとしましょう。

- スキーマをOpenAPIで管理したい
- Go でLambda関数を書きたい
- curl で動作確認したい
- ホットリロードほしい
- スキーマから生成されたクライアントを試したい

## Lambda 関数の開発の何がめんどくさいのか
Lambda関数はコードを書くのはとっても簡単です。
公式ドキュメントの通り、ハンドラー関数を記述するだけです。

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


また、コンテナイメージを利用して cURL で動作確認をする方法も公式ドキュメントに記載されています。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/go-image.html#go-image-other

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


RIE を使った場合、関数を実行する際のパスは `/2015-03-31/functions/function/invocations`　になります。


実際には前述の通り、 OpenAPI でスキーマを管理し、生成されたクライアントコードを使って開発します。
その際、スキーマで定義されたパスと RIE で用意されるパスが異なります。

要は API Gateway の役割を果たしてくれる何かがあればグッと楽になりそうでうですね。
上記開発スタイルを一つ一つ見ながら開発環境を整えていきましょう。

## Lambda関数を開発するよ
### スキーマをOpenAPIで管理したい
今回はこんな感じのスキーマを想定します。

```yml:openapi.yml
---
openapi: "3.0.0"

info:
  version: 0.0.1
  title: サンプル
servers:
  - url: http://127.0.0.1
    description: ローカル開発
  - url: http://api-gateway
    description: コンテナ開発
paths:
  /users:
    post:
      operationId: create_user
      summary: 
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/User'
            example:
              name: John
      responses:
        200:
          description: |-
            結果
          content:
            application/text:
              schema:
                $ref: '#/components/schemas/Result'
      tags:
        - user

components:
  schemas:
    User:
      title: User
      type: object
      required:
        - name
      properties:
        name:
          type: string
    Result:
      title: Result
      type: string
   
externalDocs:
  description: 朝日新聞社では、技術職の中途採用を強化しています
  url: https://www.asahishimbun-saiyou.com/information/career

tags:
  - name: user
    description: ユーザー
```

スキーマからコードを自動生成しましょう。
[OpenAPI Generator](https://openapi-generator.tech/)などで作成することができます。

下記の例では `--global-properties`を用いて生成するコードを絞っています。

```shell
openapi-generator generate \
  -g go \
  -i openapi.yml \
  -o generated/go/user \
  --package-name user \
  --global-property=models,apis,supportingFiles=configuration.go:client.go:utils.go,apiTests=false,apiDocs=false,modelTests=false,modelDocs=false
```
https://github.com/OpenAPITools/openapi-generator/blob/master/docs/global-properties.md

以下が生成されるファイル一覧です。
```
generated
└── go
    └── user
        ├── api
        ├── api_user.go
        ├── client.go
        ├── configuration.go
        ├── model_user.go
        └── utils.go
```

`model_user.go` では構造体（DTO）が定義されています。
生成された構造体を用いてJSONのペイロードをデコードすることができます。


### Go でLambda関数を書きたい
上記で生成されたコードを利用して Go を使った Lambda 関数を作ります。

ほぼドキュメント（ https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/golang-handler.html ）のサンプルコードのままですが、ハンドラー関数の引数としてスキーマから生成された構造体を利用しています。

```shell
go mod init github.com/duck8823/sample-lambda
```

```go:main.go
package main

import (
	"context"
	"fmt"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/duck8823/sample-lambda/generated/go/user"
)

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

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

### cURL で動作確認したい

公式ドキュメント（ https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/images-test.html 　）に従って `entry.sh` と `Dockerfile` を用意しましょう。

```shell:entry.sh
#!/bin/sh
if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
  exec /usr/bin/aws-lambda-rie "$@"
else
  exec "$@"
fi        
```

実行可能ファイルにします。
```shell
chmod +x entry.sh
```

ファイルの変更の度にDockerイメージのビルドを行うのは手間暇がかかるので、ボリュームマウント前提で書きます。

```Dockerfile:Dockerfile
FROM golang:1.18

## for lambda development
ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie /usr/bin/aws-lambda-rie
RUN chmod +x /usr/bin/aws-lambda-rie

WORKDIR /workspace

ENTRYPOINT ["./entry.sh"]
```

通常通りイメージをビルドして起動しています。

```shell
docker build -t sample-lambda .
```

```shell
docker run -v ${PWD}:/workspace -p 127.0.0.1:9000:8080 sample-lambda go run main.go
```

これでコンテナが立ち上がったので確認してみましょう。

```shell
curl -XPOST "http://127.0.0.1:9000/2015-03-31/functions/function/invocations" -d '{"name": "John"}'
```
```
"Hello John!"
```

Lambda関数が cURL で試せました。


### ホットリロードほしい

上述の起動方法では、ソースコードを変更しても実行中のプログラムには反映されず、コンテナを起動し直す必要があります。

Go製のプログラムのホットリロードには Air ( https://github.com/cosmtrek/air ) というツールが便利です。

```shell
go install github.com/cosmtrek/air@latest
```

ただし、デフォルトでは RIE を介した実行にならないので、設定ファイルを作成して書き換えましょう。

```shell
air init
```

すると `.air.toml` というファイルが生成されるので、 `bin` 部分を `./entry.sh` 経由に書き換えます。

```toml:.air.toml
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
  args_bin = []
  bin = "./entry.sh ./tmp/main"
  cmd = "go build -o ./tmp/main ."
  delay = 1000
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = false
  follow_symlink = false
  full_bin = ""
  include_dir = []
  include_ext = ["go", "tpl", "tmpl", "html"]
  kill_delay = "0s"
  log = "build-errors.log"
  send_interrupt = false
  stop_on_error = true

[color]
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  time = false

[misc]
  clean_on_exit = false

[screen]
  clear_on_rebuild = false
```

`Dockerfile` を書き換えてコンテナで Air をインストールしましょう

```dockerfile:Dockergile
FROM golang:1.18

## for lambda development
ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie /usr/bin/aws-lambda-rie
RUN chmod +x /usr/bin/aws-lambda-rie

## hot reload
RUN go install github.com/cosmtrek/air@latest

WORKDIR /workspace

CMD ["air", "-c", ".air.toml"]
```

```shell
docker build -t sample-lambda .
```

```shell
docker run -v ${PWD}:/workspace -p 127.0.0.1:9000:8080 sample-lambda
```

この状態でソースコードを書き換えると、以下のようにログが出力され、アプリケーションがリロードされます。
```
main.go has changed
building...
running...
```

これで、
1. コードを変更する
1. cURL で確認する

のイテレーションがとても楽になりました。
既存の Lambda 関数でも Air と RIE を追加するだけでグンと開発生産性があがると思うのでぜひお試しください。

### スキーマから生成されたクライアントを試したい

OpenAPIでスキーマを定義しているので、自動生成されたクライアントを利用して実行したいです。
試しに以下のようなコードを書いてみました。

```go:main_test.go
package main_test

import (
	"context"
	"github.com/duck8823/sample-lambda/generated/go/user"
	"reflect"
	"testing"
)

func Test_APIClient(t *testing.T) {
	// given
	want := "\"Hello John!\""

	// when
	got, _, err := user.NewAPIClient(user.NewConfiguration()).UserApi.
		CreateUser(context.Background()).
		User(user.User{Name: "John"}).
		Execute()

	// then
	if err != nil {
		t.Error(err)
	}

	// and
	if !reflect.DeepEqual(got, want) {
		t.Errorf("want: %#v, but got: %#v", want, got)
	}
}
```

スキーマから生成されたクライアントコードにはパスの情報（ `/users` ）が組み込まれています。
なんらかの形で　`/users` にきたリクエストを Lambda の実行環境の `/2015-03-31/functions/function/invocations` に渡してやれば良さそうですね。

リバースプロキシーサーバーである traefik ( https://github.com/traefik/traefik ) は Docker 環境との相性もよく、ラベルを使って設定を実現できます。
既出の Docker の定義に加え、 AWS API Gateway に見立てたリバプロを立てた `docker-compose.yml` を用意してみました。


```yml:docker-compose.yml
version: "3.3"

services:
  api-gateway:
    image: "traefik:v2.9"
    command:
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
    ports:
      - 127.0.0.1:80:80
      - 127.0.0.1:8080:8080
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"

  sample-lambda:
    build: .
    volumes:
      - ${PWD}:/workspace
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.sample-lambda.rule=Host(`127.0.0.1`, `api-gateway`)"
      - "traefik.http.routers.sample-lambda.entrypoints=web"
      - "traefik.http.services.sample-lambda.loadbalancer.server.port=8080"
      - "traefik.http.middlewares.sample-lambda.replacepathregex.regex=^/users"
      - "traefik.http.middlewares.sample-lambda.replacepathregex.replacement=/2015-03-31/functions/function/invocations"
      - "traefik.http.routers.sample-lambda.middlewares=sample-lambda@docker"
```

これで起動し、

```shell
docker compose up
```

上記の設定で `api-gateway` のコンテナ（ `127.0.0.1` または `api-gateway` をホストとして ）で `/users`　にアクセスがあった場合は `sample-lambda` コンテナの `8080` ポート、 `/2015-03-31/functions/function/invocations` にプロキシしてくれるように記述されています。

なので、以下のコマンドで Lambda 関数が実行されます。

```shell
curl -XPOST "http://127.0.0.1/users" -d '{"name": "John"}'
```
```
"Hello John!"
```

これにより生成されたクライアントを使ったコードも動きます。 クライアントにおけるホストの情報は環境変数などを使って外から変更できるようにしておくと便利です。

```golang
	cli := user.NewAPIClient(user.NewConfiguration())
	if host, exist := os.LookupEnv("SAMPLE_HOST"); exist {
		cli.GetConfig().Host = host
	}
```

今回はプロキシを介してリクエストをそのまま Lambda関数 に渡しましたが、
プロキシ統合を利用する場合は


## まとめ
RIE と Air、 そして traefik を使って Lambda関数 の開発が簡単な環境をつくりました。
リポジトリのクローンから数ステップで開発できるようになっていると嬉しいですよね。

サンプルは https://github.com/duck8823/sample-lambda にあります。ぜひ体験してみてください。
これらは既存のコードにコンフリクトせずに入れられるので、既存の開発リポジトリにもぜひ導入してみてはいかがでしょう。


## おまけ その１（コンテナで開発するのが流行ってるらしい）
DevContainer や Codespaces といった、コンテナ上での開発が流行っているようです。

用意した `docker-compose.yml` 使って最低限実行できる設定を書いてみました。
Dev Container については明日のアドベントカレンダーで紹介する予定です。お楽しみに。

```json:.devcontainer/devcontainer.json
{
  "name": "sample-lambda",
  "dockerComposeFile": [
    "../docker-compose.yml"
  ],
  "service": "sample-lambda",
  "workspaceFolder": "/workspace",
  "remoteUser": "root"
}
```

コンテナ内で開発をするので、必要なツールのインストールを Dockerfile に書いてしまいましょう。
紹介した RIE および Air に加えて Linter や コードジェネレーター をインストールしています。

```dockerfile:Dockerfile
FROM golang:1.18

## node & generator
RUN apt update \
 && apt install -y nodejs npm \
 && npm install -g openapi-generator

## jq
RUN apt install -y jq

## for lambda development
ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie /usr/bin/aws-lambda-rie
RUN chmod +x /usr/bin/aws-lambda-rie

## hot reload
RUN go install github.com/cosmtrek/air@latest

## linter
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.50.1

WORKDIR /workspace

CMD ["air", "-c", ".air.toml"]
```

なお、`.devcontainer/devcontainer.json` があれば、 Codespaces （ https://github.co.jp/features/codespaces ）も動きます。
Codespaces はクラウド上にホスティングされている開発環境です。
ブラウザ上で VSCode のエディターを操作することができます。


サンプルのリポジトリでも Codespaces を試せるので触ってみてください。
リポジトリのトップ、クローンするときによく見るところに Codespaces が生えています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103763/2955c318-34e0-3025-ccc3-6555d182cce9.png)

起動するとまさに VSCode な画面が映し出されます。
![スクリーンショット 2022-12-22 13.35.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103763/8bd86ac6-8208-b9d9-ab66-95ad68833a19.png)
これで年末年始に実家に帰った際にパソコンがなくってもブラウザからコードを書けますね！


ワンステップで開発に必要なツール群が揃うのは便利ですが、
コンテナ内で操作するので `127.0.0.1` では用意したリバースプロキシに到達しません。コンテナ内からはコンテナ名で接続することができます。

```shell
curl -XPOST "http://api-gateway/users" -d '{"name": "John"}'
```
```
"Hello John!"
```


## おまけ その2（ 用意したDockerfileをCIで使ったら楽ちゃうのん ）
開発用に用意した Dockerfile には Linter なども用意されているので、
それ使えばCIの定義も楽になるんじゃ？と考えてみました。

GitHub Actions で Dockerアクション を作るのは簡単です。
Dockerfile と アクションの定義を用意してあげるだけです（ https://docs.github.com/ja/actions/creating-actions/creating-a-docker-container-action ）。
今回は　`Makefile` のタスクを指定して実行できるアクションにしてみました。

```yml:action.yml
name: 'develop'
description: '開発用のアクション'
inputs:
  task:
    description: タスクの設定
    required: true
runs:
  using: 'docker'
  image: 'Dockerfile'
  args:
    - make
    - ${{ inputs.task }}
```

作成したアクションをワークフローで使ってみましょう（ https://docs.github.com/ja/actions/creating-actions/creating-a-docker-container-action#example-using-a-private-action ）。
必要なツールセットはコンテナに含まれているのでチェックアウトだけで簡単にCIができてしまいます。

```yml:.github/workflows/test.yml
name: テスト

on:
  push

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: ./
        with:
          task: test
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: ./
        with:
          task: lint
```

このアクションの場合、ワークフローの実行時にDockerイメージのビルドを実行するので、ツールがもりもりの `Dockerfile` を指定するとCIの実行時間が長くなってしまいます。

そこで、テスト（CI）に必要なツールだけをインストールできるようにマルチステージビルドできる記述にしてみました。

```Dockerfile:Dockerfile
FROM golang:1.18 AS base

## linter
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.50.1

FROM base AS develop

## node & generator
RUN apt update \
 && apt install -y nodejs npm \
 && npm install -g openapi-generator

## jq
RUN apt install -y jq

## for lambda development
ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie /usr/bin/aws-lambda-rie
RUN chmod +x /usr/bin/aws-lambda-rie

## hot reload
RUN go install github.com/cosmtrek/air@latest

WORKDIR /workspace

CMD ["air", "-c", ".air.toml"]

FROM base AS test
```

このように書くことで、BuildKitが有効な場合、ターゲットを指定しなければ develop のステージはスキップしてくれます。
しかし、 実際に試したところ GitHub Actions では全てのステップが実行されてしまいました...BuildKit が有効じゃないみたい。

素直に CI用に Dockerfile を用意してあげるのがCI高速化には有効なようです。


## 今後
今回は非常にシンプルなリポジトリ構成の場合の紹介でした。
モノレポや、API Gateway の統合プロキシを利用する場合にどういう構成にすると開発が楽になりそうかなどは引き続き検討していきたいです。

---

なお、朝日新聞社では、技術職の中途採用を強化しています。
ご興味のある方は下記リンクから希望職種の募集ページに進んでください。
皆様からのご応募、お待ちしております！

https://www.asahishimbun-saiyou.com