Lambda + ALBでAPIを構築してみた（Photocreate Advent Calendar  19日目 ）

エンジニア の @mackagy1 です。
この記事は [Photocreate Advent Calendar 2018](https://qiita.com/advent-calendar/2018/photocreate) の 19日目 の記事です。

## はじめに
WEBサイトやアプリで住所入力フォームに郵便番号を入力すると住所が自動補完されるUIありますよね。

仕組み的には単純で、郵便番号を入力したときに住所検索するAPIを呼び出して、そのレスポンスをフォームに表示するという流れになると思います。

こういうちょっとしたAPIを作るためにわざわざWEBサーバをたててそこにAPIのっけて・・というのは大げさな感じがします。

ということでLambdaを使って手軽に住所検索のAPIを作ってみたいなと思ってやってみました。

## 構成
クライアント → ALB → Lambda → DynamoDB

DynamoDBには郵便番号と住所情報をつっこんでおきます。
今回はとりあえず1レコード入れて動作確認してみます。

## 対象
こんな方が読むと何か得るものがあると思います。

- Lambdaの開発方法に興味がある
- [ALBのターゲットにLambdaが追加になった](https://aws.amazon.com/jp/blogs/news/lambda-functions-as-targets-for-application-load-balancers/)のが気になっている

## いざ開発
### Serverless Framework
まずは[Serverless Framework](https://serverless.com/)をインストールします。(以降、Serverless)
ServerlessはLambda関数の作成、デプロイ、ローカルでのデバッグをらくちんにしてくれる優れもののフレームワークです。アプリケーションのコードと簡単な設定ファイルを用意すればすぐにAWS上に公開することもできます。Lambda開発をとても便利にしてくれます。

Serverlessは以下のようにインストールします。

```console:
$ npm install serverless -g
```

以下のように表示されれば成功です

```console:
$ serverless -v
1.34.1
```

尚、slsというエイリアスが用意されています。

```console:
$ sls -v
1.34.1
```

### サービスの作成
Serverlessはサービスという単位で開発します。

```console:
$ sls create --template aws-nodejs --path zipcode-app
Serverless: Generating boilerplate...
Serverless: Generating boilerplate in "/Users/shotaro.shimizu/develop/zipcode-app"
 _______                             __
|   _   .-----.----.--.--.-----.----|  .-----.-----.-----.
|   |___|  -__|   _|  |  |  -__|   _|  |  -__|__ --|__ --|
|____   |_____|__|  \___/|_____|__| |__|_____|_____|_____|
|   |   |             The Serverless Application Framework
|       |                           serverless.com, v1.34.1
 -------'

Serverless: Successfully generated boilerplate for template: "aws-nodejs"
```

```--path``` オプションにはサービスのルートディレクトリ名を、```--template``` にはLambdaで使用する言語のテンプレートを指定します。

zipcode-appディレクトリが作成され、中に以下のファイルが作成されます。

- serverless.yml
- handler.js

serverless.ymlはデフォルトで以下のようになっています。
コメント行が長いので有効な行だけ抽出してます。

```yaml:
service: zipcode-app # NOTE: update this with your service name

provider:
  name: aws
  runtime: nodejs8.10 #本記事執筆時点のLambdaで対応しているnodeの最新バージョンは8.10なので注意してください

functions:
  hello:
    handler: handler.hello
```

ファンクションとしてhelloが定義されていますね。

また、handler.jsはデフォルトで以下のようになっています。

```js:
'use strict';

module.exports.hello = async (event, context) => {
  return {
    statusCode: 200,
    body: JSON.stringify({
      message: 'Go Serverless v1.0! Your function executed successfully!',
      input: event,
    }),
  };

  // Use this code if you don't use the http event with the LAMBDA-PROXY integration
  // return { message: 'Go Serverless v1.0! Your function executed successfully!', event };
};
```
helloファンクションの実体です。
「Hello World」的なレスポンスを返却するようになっていますね。

これでサービスの作成は完了です。

尚、サービス作成時に使用可能なtemplateは以下で確認可能です。

```console:
$ sls create --help
--template / -t .................... Template for the service. Available templates: 
 "aws-clojure-gradle","aws-clojurescript-gradle","aws-nodejs", "aws-nodejs-typescript", "aws-alexa-typescript",
 "aws-nodejs-ecma-script", "aws-python", "aws-python3", "aws-groovy-gradle", "aws-java-maven",
 "aws-java-gradle","aws-kotlin-jvm-maven","aws-kotlin-jvm-gradle", "aws-kotlin-nodejs-gradle",
 "aws-scala-sbt", "aws-csharp", "aws-fsharp", "aws-go", "aws-go-dep", "aws-go-mod",
 "aws-ruby", "azure-nodejs", "cloudflare-workers", "cloudflare-workers-enterprise", "fn-nodejs",
 "fn-go", "google-nodejs", "kubeless-python", "kubeless-nodejs", "openwhisk-java-maven",
 "openwhisk-nodejs", "openwhisk-php", "openwhisk-python", "openwhisk-ruby", "openwhisk-swift",
 "spotinst-nodejs", "spotinst-python", "spotinst-ruby", "spotinst-java8", "plugin" and "hello-world"
```

使いたい言語に応じて指定するテンプレートを切り替えましょう。
今回はnodeを使います。

### AWSアカウントの設定
続いて、AWSのアカウントの設定を行います。
Serverlessでは、credential(~/.aws/credentials)を利用しています。

今回はまだこのファイルがないので作成します。

```console:
$ sls config credentials --provider aws --key EXAMPLE --secret EXAMPLEKEY
Serverless: Setting up AWS...
Serverless: Saving your AWS profile in "~/.aws/credentials"...
Serverless: Success! Your AWS access keys were stored under the "default" profile.
```
keyとsecretには開発に使用するawsアカウントの適切な値を設定してください。

成功すると```~/.aws/credentials```に認証情報が作成されます。

```console:
$ cat ~/.aws/credentials
[default]
aws_access_key_id = EXAMPLE
aws_secret_access_key = EXAMPLEKEY
```


### DynamoDB Local
一通り準備ができたので本題に入ります。
改めて今回のAPIでやりたいことを整理します。

- 郵便番号をAPIに渡すと、対応する住所が返却される

これを実現するためには郵便番号と住所のペアが格納されたマスタが必要そうですね。
今回はマスタ管理にDynamoDBを利用します。

#### インストール
ということでDynamoDBをローカルで動かせるようにします。
これもServerlessを使うことで簡単に実現することができます。
必要となるのは以下のツール、プラグインとなります。

- DynamoDB Local 
    - AWSが公式で提供しているローカルでDynamoDBを動かすツールです
- serverless-dynamodb-local 
    - Serverlessプラグイン、上記DynamoDB Localのインストールやテーブル作成を自動的に行ってくれます。

まずはserverless-dynamodb-localをインストールします。
サービスのルートディレクトリで以下を実行します。

```console:
$ yarn add --dev serverless-dynamodb-local
info No lockfile found.
[1/4] 🔍  Resolving packages...

/ ** 長いので略 **/

✨  Done in 8.85s.
```

次にserveless.ymlに以下を追記します。
一番下とかでOKです。

```
plugins:
  - serverless-dynamodb-local

custom:
  dynamodb:
    start:
    port: 8000
```

この状態でslsコマンドを実行するとdynamodb系のサブコマンドが増えていることがわかります。

```console:
$ sls
Commands
* You can run commands with "serverless" or the shortcut "sls"
* Pass "--verbose" to this command to get in-depth plugin info
* Pass "--no-color" to disable CLI colors
* Pass "--help" after any <command> for contextual help

Framework
* Documentation: https://serverless.com/framework/docs/

/** 長いので略 **/

dynamodb ...................... undefined
dynamodb migrate .............. Creates local DynamoDB tables from the current Serverless configuration
dynamodb seed ................. Seeds local DynamoDB tables with data
dynamodb start ................ Starts local DynamoDB
dynamodb remove ............... Removes local DynamoDB
dynamodb install .............. Installs local DynamoDB

/** 長いので略 **/

```

次にserverless.ymlにDynamodbのテーブル定義+@を追加します。
よくわからない箇所は[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-table.html)をご覧ください。

```yaml:
provider:
  name: aws
  runtime: nodejs8.10
  stage: ${opt:stage, self:custom.defaultStage} ### 追記
  region: ap-northeast-1 ### 追記

### ここから追記 ###
resources:
  Resources:
    Addresses:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:provider.stage}_addresses
        AttributeDefinitions:
          - AttributeName: zipcode
            AttributeType: S
        KeySchema:
          - AttributeName: zipcode
            KeyType: HASH
        ProvisionedThroughput: ##### 注1
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
### ここまで追記 ###

custom:
  dynamodb:
    start:
    port: 8000
  defaultStage: dev ### 追記
```
※注１：serverless-dynamodb-localがまだBillingMode：PAY_PER_REQUEST(オンデマンドモード)の指定に対応していない[issue](https://github.com/99xt/serverless-dynamodb-local/issues/189)があるため(2018/12/19現在)、ローカルにDynamoDB作るときはProvisionedThroughput(プロビジョニングモードになります）を指定します。awsにデプロイする時はBillingMode：PAY_PER_REQUEST指定にすることを忘れずに！！

テーブル名は動作させる環境によって変えたいので接頭辞を変数化しています。

```yaml:
TableName: ${self:provider.stage}_addresses
```

${self:provider.stage}には、以下の箇所で定義したstageの値が入ります。

```yaml:
provider:
  stage: ${opt:stage, self:custom.defaultStage} #これ
```

ここも変数化されててややこしいのですが、```opt:stage```はオプションでstageを利用した時の値が入ります。stageはdeployや、invokeする時の環境を指定するオプションです。stageが指定されなかった場合、```self:custom.defaultStage```の値が利用されます。これは以下で定義されています。

```yaml:
custom:
  defaultStage: dev #これ
```

**今回、ローカル開発の環境をdev、デプロイ先の環境をstage(ややこしいですがオプションのstageとは違います)として考えてください。**

ここまで準備ができたら以下のコマンドでDynamoDB Localをインストールします。

```console:
$ sls dynamodb install
```

インストールしたら起動します。

```console:
$ sls dynamodb start
Serverless: Load command config
Serverless: Load command config:credentials
Serverless: Load command create
Serverless: Load command install
Serverless: Load command package
Serverless: Load command deploy
Serverless: Load command deploy:function
Serverless: Load command deploy:list
/** 略 **/
Serverless: Load command dynamodb:install
Serverless: Invoke dynamodb:start
Dynamodb Local Started, Visit: http://localhost:8000/shell
```

これで最後に出てくる ``` http://localhost:8000/shell``` にアクセスしてコンソールが表示されれば起動成功です。

#### テーブル作成
次にテーブルを作成します。

```console:
$ sls dynamodb migrate
Serverless: DynamoDB - created table dev_addresses
```

実際にテーブルが作成されているか確認してみましょう。

```console:
$ aws dynamodb list-tables --endpoint-url http://localhost:8000
{
    "TableNames": [
        "dev-addresses"
    ]
}
```

作成されてますね！

#### テストデータ登録
これまたaws-cliを使います。
郵便番号と紐づく住所を登録します。

```console:
$ aws dynamodb put-item --table-name dev_addresses --item '{"zipcode":{"S":"2160007"},"address":{"S":"神奈川県川崎市宮前区小台"}}' --endpoint-url http://localhost:8000
```

登録できたか確認します。

```console:
$ aws dynamodb get-item --table-name dev_addresses --key '{"zipcode":{"S":"2160007"}}' --endpoint-url http://localhost:8000
{
    "Item": {
        "zipcode": {
            "S": "2160007"
        },
        "address": {
            "S": "神奈川県川崎市宮前区小台"
        }
    }
}
```

登録されてますね！

### Handlerの実装
やっとコード書きます。環境変数を使うのでserverless.ymlも編集します。

コードは以下の通りです。devとstageで分ける必要がある箇所はところどころ環境変数を利用しています。

```handler.js:
var AWS = require('aws-sdk');
var dynamo = new AWS.DynamoDB.DocumentClient(
  {
    region: process.env["DYNAMO_DB_REGION"],
    endpoint: process.env["DYNAMO_DB_ENDPOINT"]
  }
);

module.exports.address = async (event) => {

  var params = {
    TableName: `${process.env["STAGE"]}_addresses`,
    Key: {"zipcode": event.pathParameters.zipcode}
  };

  return dynamo.get(params).promise().then((data) => {
    if (data.Item === undefined) {
      var error404 = {
         message : "Resource not found"
      };
      return response = {
        statusCode: 404,
        statusDescription: '404 Resource Not Found',
        isBase64Encoded: false,
        headers: {
          'Content-Type': 'application/json; charset=utf-8'
        },        
        body: `${JSON.stringify(error404)}`
      };
    }

    return response = {
        statusCode: 200,
        statusDescription: '200 OK',
        isBase64Encoded: false,
        headers: {
          'Content-Type': 'application/json; charset=utf-8'
        },        
        body: `${JSON.stringify(data.Item)}`
      };

  }).catch((err) => {
    console.error(`[Error]: ${JSON.stringify(err)}`);
    var error500 = {
       message : "Internal Server Error"
     };
    return response = {
      statusCode: 500,
      isBase64Encoded: false,
      statusDescription: '500 Internal Server Error',
      headers: {
        'Content-Type': 'application/json; charset=utf-8'
      },      
      body: `${JSON.stringify(error500)}`
    };    
  });


```

レスポンスにいろいろ情報を格納していますがALBから返却する際に必要となる情報です。詳しくは[公式ドキュメント](https://aws.amazon.com/jp/blogs/networking-and-content-delivery/lambda-functions-as-targets-for-application-load-balancers/)をご覧ください。

続いて、serveless.ymlの完成形です。全体を載せます。


```serverless.yaml:
service: zipcode-app

provider:
  name: aws
  runtime: nodejs8.10
  stage: ${opt:stage, self:custom.defaultStage}
  region: ap-northeast-1
  environment: 
   STAGE: ${self:provider.stage} ### 全体で使用できる環境変数追加
  ### ここからIAMロールの定義追加 ###
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:GetItem
      Resource: "arn:aws:dynamodb:${self:provider.region}:*:table/${self:provider.stage}_*"
  ### ここまでIAMロールの定義追加 ###

### ここからまるっと書き換え ###
functions:
  address:
    handler: handler.address
    environment: ${self:custom.environment.${self:provider.stage}}
    events:
      - http:
          path: zipcode/{zipcode}
          method: get

resources:
  Resources:
    Addresses:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:provider.stage}_addresses
        AttributeDefinitions:
          - AttributeName: zipcode
            AttributeType: S
        KeySchema:
          - AttributeName: zipcode
            KeyType: HASH
        BillingMode: PAY_PER_REQUEST # 注1で説明したようにProvisionedThroughputの指定から変更

plugins:
  - serverless-dynamodb-local

custom:
  dynamodb:
    start:
    port: 8000
  defaultStage: dev
  ### ここから追記 ###
  environment:
    dev:
     DYNAMO_DB_REGION: localhost
     DYNAMO_DB_ENDPOINT: http://localhost:8000
    stage:
     DYNAMO_DB_REGION: ap-northeast-1
     DYNAMO_DB_ENDPOINT: dynamodb.ap-northeast-1.amazonaws.com
 ### ここまで追記 ###
```

### ローカルでの動作確認
まずはローカルで正しくデータが取得できるか試してみましょう。

```console:
$ sls invoke local -f address --data '{ "pathParameters": {"zipcode":"2160007"}}'
{
    "statusCode": 200,
    "body": "{\"zipcode\":\"2160007\",\"address\":\"神奈川県川崎市宮前区小台\"}"
}
```

invokeの後にlocalをつけることでローカルの環境で動作します。
また、パラメータの郵便番号をパスで受け取るように定義してあるので```--data```でパスパラメータを渡しています。

### AWSヘのデプロイ
とても簡単にデプロイできます。

```console:
$ sls deploy --stage stage
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 (16.36 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..........
Serverless: Stack update finished...
Service Information
service: zipcode-app
stage: stage
region: ap-northeast-1
stack: zipcode-app-stage
api keys:
  None
endpoints:
  GET - https://bzeiy8uas9.execute-api.ap-northeast-1.amazonaws.com/stage/zipcode/{zipcode}
functions:
  address: zipcode-app-stage-address
layers:
  None
```

これだけです。
serverless.ymlに定義されている内容でLambda関数の作成、DynamoDBのテーブル作成、IAMロールの設定、ソース一式を格納するS3バケットの作成など全て行ってくれます。内部的にはCloud Formationが使用されているようです。

1点、前述した通りデプロイ先の環境はstageとして定義しているので、stageオプションでstageを指定してください。

### DynamoDBにデータ登録

ローカルで登録した時とほぼ一緒です。

```console:
$ aws dynamodb put-item --table-name stage_addresses --item '{"zipcode":{"S":"2160007"},"address":{"S":"神奈川県川崎市宮前区小台"}}'
```

テーブル名の接頭辞がdevではなく、stageになっている点に注意してください。
取得してみます。

```console:
$ aws dynamodb get-item --table-name stage_addresses --key '{"zipcode":{"S":"2160007"}}'
{
    "Item": {
        "zipcode": {
            "S": "2160007"
        },
        "address": {
            "S": "神奈川県川崎市宮前区小台"
        }
    }
}
```

取得できましたね！

### Lambdaを呼び出す
デプロイした関数を呼び出すには、slsのinvokeコマンドを利用します。

```console:
$ sls invoke -f address -s stage --data '{ "pathParameters": {"zipcode":"2160007"}}'
{
    "statusCode": 200,
    "body": "{\"zipcode\":\"2160007\",\"address\":\"神奈川県川崎市宮前区小台\"}"
}
```
無事結果が返却されました！

尚、invokeコマンドは下記のようになっています。

```console:
$ sls invole --help
Plugin: Invoke
invoke ........................ Invoke a deployed function
invoke local .................. Invoke function locally
    --function / -f (required) ......... The function name
    --stage / -s ....................... Stage of the service
    --region / -r ...................... Region of the service
    --path / -p ........................ Path to JSON or YAML file holding input data
    --type / -t ........................ Type of invocation
    --log / -l ......................... Trigger logging data output
    --data / -d ........................ Input data
    --raw .............................. Flag to pass input data as a raw string
```

```-f``` でファンクションのaddressを指定したことになります。

### ALBとLambdaを繋げる
最後にALBからLambdaを呼び出せるようにします。

まずALBを作成します。
ルーティングの設定で「ターゲットの種類」にLambda関数を選択してください。

---
<img width="670" alt="1.png" src="https://qiita-image-store.s3.amazonaws.com/0/42437/c2fe50aa-e48c-7eef-ea8d-377ca6328b1d.png">
---

次にターゲットの登録でリストからLambda関数を選択します。

---

<img width="636" alt="2.png" src="https://qiita-image-store.s3.amazonaws.com/0/42437/192ff984-99bd-cc44-22ba-a920c23e5816.png">

---

これで準備完了です！

### 動作確認
さっそくブラウザから呼び出してみます。

```
https://****.amazonaws.com/zipcode-app-stage-address/zipcode/2160007
```

・・・502が返却されますね。

どうやらログをみてみるとLambdaの

```hanlder.js:
  var params = {
    TableName: `${process.env["STAGE"]}_addresses`,
    Key: {"zipcode": event.pathParameters.zipcode}
  };
```

の箇所で「event.pathParameters.zipcodeなんてないよ」と怒られているようです。
あらためて[ドキュメント](https://aws.amazon.com/jp/blogs/networking-and-content-delivery/lambda-functions-as-targets-for-application-load-balancers/)を読み直してみると確かにeventとしてpathParametersが送られてこないようです・・。queryStringParametersはちゃんと渡ってきているのになぜ・・・。

ちなみに記載を省略しましたが、APIGatewayを経由したLambda呼び出しの場合はちゃんとevent.pathParametersが送られてきて正しく動作していました。
これは困った、、

### 苦渋の決断
しかし幸いなことにパスは送られてきているようです。
散々調べたのですが解決方法が見つからず苦渋の決断としてコードの一部を以下のように変更しました。

```js:
var zipcode;
if (event.pathParameters === undefined) {
 zipcode = event.path.split("/").pop(); // pathをsplitして最後の文字列を郵便番号に
} else {
 zipcode = event.pathParameters.zipcode;
}
var params = {
 TableName: `${process.env["STAGE"]}_addresses`,
 Key: {"zipcode": zipcode}
};
```

これで無事動作しました・・
素直にqueryStringParametersを利用した方が綺麗かもしれませんね・・。

## 後日談的なもの
本記事を書き終えた日の夜、たまたま勉強会でawsの中の人にお会いすることができました。そこで、pathParametersの件を聞いてたところ、APIGatewayはAPIを構築のためのもの(それだけではありませんが)であるためRESTで利用されるであろう、pathParametersに対応しているが、ALBはそうではないためまだ対応していないとの旨を教えていただきました。納得。ありがとうございました。

ちなみにALBの後ろにLambdaを置けるメリットとしては、たとえば既存システム改修の際、APIを呼び出すパスを変えずに、特定のリクエストの時だけ処理をLambda流すというように、徐々にServerlessな構成にバックエンドを置き換えていくことができるなどが考えられそうです。

## まとめ
いかがでしたでしょうか。ServerlessはLambda開発をとても快適にしてくれる一品だと思うのでまだ利用したことのない方は、是非試してみてください。
尚、今回の記事で紹介したserverless.ymlの設定は動作確認するための必要最低限なものになります。
実際にプロダクション環境で運用していくにはもう少し設定をいろいろやらないとワークしないので、また機会があれば紹介したいと思います。

<!--
## 明日は

明日の記事は @%{@アカウント} さんが %{テーマ／タイトル} について です。
（次の人が更新したらあとで記事を更新してリンクを自分で張る）

-->

フォトクリエイトでは新しい技術に興味がある方からの [ご応募をお待ちしております](https://recruit.photocreate.co.jp/entry/?utm_source=qiita&utm_medium=advc2018&utm_campaign=2349941808b6b1c54eab)。