GolangのAPIサーバをAWS Lambdaへ移植してみた

# 背景
現在Golang + Nginxで動いているAPIをLambda関数へ移行したい。


# API仕様
- おみくじAPI
- `/fortune` へのアクセスで大吉・中吉・小吉のどれかをjsonとして返してくれる
- `/list` でおみくじで得ることが出来る結果一覧をJSONで取得できる
- `/version` でプレーンテキストとしてAPIのバージョンを取得できる
- 無効なパスへのアクセスは404を返す

# 環境
OS: Ubuntu 18.04

バージョン

```sh
$ go version
go version go1.10.3 gccgo (Ubuntu 8.3.0-6ubuntu1~18.04.1) 8.3.0 linux/amd64
```

# 実際に移植してみる！


## ざっくりやること
* Lambda関数作成
* ALBとターゲットグループを作成
* ビルドしてLambdaへデプロイ

## 移植前のソース

下記のコードをLambdaで動くように修正します。

<details><summary>移植前ソース</summary><div>

```go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "math/rand"
    "net/http"
)

var fortune = []string{"大吉", "中吉", "小吉"}

func fortuneHandler(w http.ResponseWriter, r *http.Request) {
    res, err := json.Marshal(fortune[rand.Intn(3)])

    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    fmt.Fprint(w, string(res))
}

func versionHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plane; charset=utf-8")
    fmt.Fprint(w, string("version 1.0.0"))
}

func listHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    fmt.Fprint(w, fortune)
}

func notFoundHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    w.WriteHeader(404)
    fmt.Fprint(w, "404 not found")
}

func main() {
    http.HandleFunc("/fortune", fortuneHandler)
    http.HandleFunc("/version", versionHandler)
    http.HandleFunc("/list", listHandler)
    http.HandleFunc("/", notFoundHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}
```
</div></details>

### Lambda環境作成
#### Lambda関数の作成
ランタイムが `Go 1.x` になっていることを確認し、関数を作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142518/e0e5f31c-d72a-eb60-74ab-f05148f54d46.png)

#### ALBとターゲットグループを作成
EC2の画面を開き、左のメニューからロードバランサーを選択
![スクリーンショット 2020-02-24 21.35.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142518/c18ba1c1-88eb-9362-1a74-b2e53c91853d.png)

画面上位の `ロードバランサーの作成` を押下
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142518/736f4879-f329-ce9b-408c-f62353fa0181.png)

`Application Load Balancer` の `作成` を押下
![スクリーンショット 2020-02-24 21.39.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142518/533086e0-048f-450c-3fd7-96ab0ef4324d.png)

ロードバランサーの設定は各々の環境に合わせて設定してください

ターゲットグループを新規作成し、 `ターゲットの種類` が `Lambda` になっていることを確認し、ターゲットの登録ボタンを押下
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142518/f604ef73-fdef-35f8-e0db-0b711674babf.png)

リストからターゲットにしたいLambdaを選択し、確認を押下
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142518/818952d2-d9ee-d6aa-5887-5b9a6d38685d.png)

確認して問題なければ作成ボタンを押下

#### 動作確認

作成したALBのDNS名をコピーして、実際にアクセスしてみて確認
![スクリーンショット 2020-02-24 21.50.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142518/7df9c9aa-7a60-8220-621c-abb234526a52.png)

`Hello from Lambda!` と表示されればOK

```sh
$ curl <ALBのDNS名> -D -
HTTP/1.1 200 OK
Server: awselb/2.0
Date: Mon, 24 Feb 2020 12:47:12 GMT
Content-Type: application/octet-stream
Content-Length: 18
Connection: keep-alive

Hello from Lambda!
```

# Lambda関数として動くようにする

- やること
    - サーバとしてではなく関数として動作するようにする
    - `*http.Request` と `http.ResponseWriter` を `ALBTargetGroupRequest` と `events.ALBTargetGroupResponse` へ置き換える

## 置き換え後

<details><summary>置き換え後ソース</summary><div>

```go
package main

import (
    "encoding/json"
    "fmt"
    "math/rand"
    "context"
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
)

var fortune = []string{"大吉", "中吉", "小吉"}

func fortuneHandler() (events.ALBTargetGroupResponse, error) {
    res, err := json.Marshal(fortune[rand.Intn(3)])

    if err != nil {
        return events.ALBTargetGroupResponse {
	    StatusCode: 500,
            Body: fmt.Sprintf("%s", err),
        }, err
    }

    return events.ALBTargetGroupResponse {
	Headers: map[string]string{
	    "content-type": "application/json",
	},
	Body: fmt.Sprintf("%s", string(res)),
    }, nil
}

func versionHandler() (events.ALBTargetGroupResponse, error) {
    return events.ALBTargetGroupResponse {
	Headers: map[string]string{
	    "content-type": "text/plane; charset=utf-8",
	},
	Body: fmt.Sprintf("%s", string("version 1.0.0")),
    }, nil
}

func listHandler() (events.ALBTargetGroupResponse, error) {
    return events.ALBTargetGroupResponse {
	Headers: map[string]string{
	    "content-type": "application/json",
	},
	Body: fmt.Sprintf("%s", fortune),
    }, nil
}

func notFoundHandler() (events.ALBTargetGroupResponse, error) {
    return events.ALBTargetGroupResponse {
	StatusCode: 404,
	Headers: map[string]string{
	    "content-type": "text/plain; charset=utf-8",
	},
	Body: fmt.Sprintf("404 not found\n"),
    }, nil
}

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

func handleRequest(ctx context.Context, request events.ALBTargetGroupRequest) (events.ALBTargetGroupResponse, error) {

    switch request.Path {
        case "/fortune": return fortuneHandler()
        case "/version": return versionHandler()
        case "/list": return listHandler()
	default: return notFoundHandler()
    }
}
```
</div></details>

<details><summary>修正差分</summary><div>

```diff
6d5
<     "log"
8c7,9
<     "net/http"
---
>     "context"
>     "github.com/aws/aws-lambda-go/events"
>     "github.com/aws/aws-lambda-go/lambda"
13c14
< func fortuneHandler(w http.ResponseWriter, r *http.Request) {
---
> func fortuneHandler() (events.ALBTargetGroupResponse, error) {
17,18c18,21
<         http.Error(w, err.Error(), http.StatusInternalServerError)
<         return
---
>         return events.ALBTargetGroupResponse {
> 	    StatusCode: 500,
>             Body: fmt.Sprintf("%s", err),
>         }, err
21,22c24,29
<     w.Header().Set("Content-Type", "application/json")
<     fmt.Fprint(w, string(res))
---
>     return events.ALBTargetGroupResponse {
> 	Headers: map[string]string{
> 	    "content-type": "application/json",
> 	},
> 	Body: fmt.Sprintf("%s", string(res)),
>     }, nil
25,27c32,38
< func versionHandler(w http.ResponseWriter, r *http.Request) {
<     w.Header().Set("Content-Type", "text/plane; charset=utf-8")
<     fmt.Fprint(w, string("version 1.0.0"))
---
> func versionHandler() (events.ALBTargetGroupResponse, error) {
>     return events.ALBTargetGroupResponse {
> 	Headers: map[string]string{
> 	    "content-type": "text/plane; charset=utf-8",
> 	},
> 	Body: fmt.Sprintf("%s", string("version 1.0.0")),
>     }, nil
30,32c41,47
< func listHandler(w http.ResponseWriter, r *http.Request) {
<     w.Header().Set("Content-Type", "application/json")
<     fmt.Fprint(w, fortune)
---
> func listHandler() (events.ALBTargetGroupResponse, error) {
>     return events.ALBTargetGroupResponse {
> 	Headers: map[string]string{
> 	    "content-type": "application/json",
> 	},
> 	Body: fmt.Sprintf("%s", fortune),
>     }, nil
35,38c50,57
< func notFoundHandler(w http.ResponseWriter, r *http.Request) {
<     w.Header().Set("Content-Type", "text/plain; charset=utf-8")
<     w.WriteHeader(404)
<     fmt.Fprint(w, "404 not found")
---
> func notFoundHandler() (events.ALBTargetGroupResponse, error) {
>     return events.ALBTargetGroupResponse {
> 	StatusCode: 404,
> 	Headers: map[string]string{
> 	    "content-type": "text/plain; charset=utf-8",
> 	},
> 	Body: fmt.Sprintf("404 not found\n"),
>     }, nil
42,46c61,64
<     http.HandleFunc("/fortune", fortuneHandler)
<     http.HandleFunc("/version", versionHandler)
<     http.HandleFunc("/list", listHandler)
<     http.HandleFunc("/", notFoundHandler)
<     log.Fatal(http.ListenAndServe(":8080", nil))
---
>     lambda.Start(handleRequest)
> }
>
> func handleRequest(ctx context.Context, request events.ALBTargetGroupRequest) (events.ALBTargetGroupResponse, error) {
47a66,71
>     switch request.Path {
>         case "/fortune": return fortuneHandler()
>         case "/version": return versionHandler()
>         case "/list": return listHandler()
> 	default: return notFoundHandler()
>     }
```
</div></details>

### 差分解説

```go
func main() {
    lambda.Start(handleRequest)
}

func handleRequest(ctx context.Context, request events.ALBTargetGroupRequest) (events.ALBTargetGroupResponse, error) {

    switch request.Path {
        case "/fortune": return fortuneHandler()
        case "/version": return versionHandler()
        case "/list": return listHandler()
    default: return notFoundHandler()
    }
}
```
- lambda.Start
> lambda.Start(HandleRequest) を追加すると、Lambda 関数が実行されます。
  - [Go の AWS Lambda 関数ハンドラー](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/go-programming-model-handler-types.html)より引用

- ALBTargetGroupRequest
    - ここにリクエストのパスなどが入ってきます
    - golangの `*http.Request` 相当

---

```go
func listHandler() (events.ALBTargetGroupResponse, error) {
    return events.ALBTargetGroupResponse {
    Headers: map[string]string{
        "content-type": "application/json",
    },
    Body: fmt.Sprintf("%s", fortune),
    }, nil
}
```

- ALBTargetGroupResponse
    - この構造体をLambdaからreturnする事で任意のレスポンスを返すことが出来ます
    - 基本的に、 `http.ResponseWriter` を `ALBTargetGroupResponse` へ置き換えるだけで任意のレスポンスを返すように出来ます

### ビルド&動作確認
#### ビルド
ここでのポイントはクロスコンパイルするところです

```sh
$ GOOS=linux GOARCH=amd64 go build -o fortune
```
ここで作ったバイナリをLambdaへアップロードして、動作確認をします！

#### 動作確認

ここで大吉・中吉・小吉のどれかが出力されればOKです

```sh
$ curl <ALBのDNS名>/fortune -D -
HTTP/1.1 000
Server: awselb/2.0
Date: Mon, 24 Feb 2020 16:18:30 GMT
Content-Type: application/json
Content-Length: 8
Connection: keep-alive

"小吉"
```

## 記事外で関数に移植する際に困ったところ
### 条件によって特定のヘッダーを付与してALBTargetGroupResponseを返したい

ALBTargetGroupResponseを一度変数に入れて変数値を操作したものをreturnすればいい
下記コードだと `if err {` の部分

```golang
response := events.ALBTargetGroupResponse {
    StatusCode: http.StatusInternalServerError,
    Body: fmt.Sprintf("%d", http.StatusInternalServerError),
    Headers: map[string]string{},
}

if err {
    response.Headers["X-Error-Message"] = fmt.Sprintf("error: %v", err)
}

response.StatusCode = statusCode
response.Headers["Content-Type"] = "text/plane; charset=utf-8"
response.Body = http.StatusInternalServerError
return response
```

# おわりに
置き換えはLambdaのお作法に従うだけで意外と簡単に移植できるみたいです。


# 参考資料

[AWS Lambda で Go が使えるようになったので試してみた](https://dev.classmethod.jp/cloud/aws/aws-lambda-supports-go/)
[Go の AWS Lambda 関数ハンドラー](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/go-programming-model-handler-types.html)
[README_ALBTargetGroupEvents.md](https://github.com/aws/aws-lambda-go/blob/master/events/README_ALBTargetGroupEvents.md)
[type ALBTargetGroupResponse](https://godoc.org/github.com/aws/aws-lambda-go/events#ALBTargetGroupResponse)