AWS SAMでローカル環境でS3とDynamoDBを扱うLambdaを実行する

# この投稿について

Serverlessconf Tokyo 2018で色々と刺激を受け、Lambdaに取り組んでみようと思い、色々と試す上でLambdaをローカル環境で開発や動作確認をするのに色々迷う部分が多かったので、メモとして残したものです。

# 動作環境

以下のものを使用しています。

- AWS SAM
- AWS CLI
- docker
- docker-compose

```bash
# Pythonはvenv
$ python --version
Python 3.6.6
$ aws --version
aws-cli/1.16.24 Python/3.7.0 Darwin/18.0.0 botocore/1.12.14
$ sam --version
SAM CLI, version 0.6.0
```

# SAMプロジェクトの作成

`sam init`コマンドを使用して空のプロジェクトを作成します。

- -r はruntime
- -n はプロジェクト名

を指定しています。

```bash
$ sam init -r python3.6 -n sam-s3-lambda-app
```

無事に成功すると以下のようなパッケージ構成が作成されます。

```
$ cd sam-s3-lambda-app
$ tree .
.
├── README.md
├── hello_world
│   ├── __init__.py
│   ├── __pycache__
│   │   ├── __init__.cpython-37.pyc
│   │   └── app.cpython-37.pyc
│   └── app.py
├── requirements.txt
├── template.yaml
├── tests
│   └── unit
│       ├── __init__.py
│       ├── __pycache__
│       │   ├── __init__.cpython-37.pyc
│       │   └── test_handler.cpython-37.pyc
│       └── test_handler.py
```

# hello_worldの動作確認

まずは自動的に作成された、hello_world関数を実行してみます。

```bash
# ライブラリのインストール
$ pip install -r requirements.txt -t hello_world/build/
$ cp hello_world/*.py hello_world/build/
# イベントの生成
$ sam local generate-event apigateway aws-proxy > api_event.json
# テスト実行
$ sam local invoke HelloWorldFunction --event api_event.json
2018-10-25 18:31:13 Invoking app.lambda_handler (python3.6)

Fetching lambci/lambda:python3.6 Docker container image......
.
.
.

{"statusCode": 200, "body": "{\"message\": \"hello world\", \"location\": \"IP Address\"}"}
```

無事に動作確認ができました。

# 実際にS3とDynamoDBを扱う部分を実装

## LocalStackコンポーネントの作成

今回はS3とDynamoDBをエミュレートするために、LocalStackを使用しました。
https://github.com/localstack/localstack

LocalStack自体はdockerで立ち上げます。

以下のように`docker-compose.yml`ファイルを作成します。

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

services:
  localstack:
    container_name: localstack
    image: localstack/localstack
    ports:
      - "4569:4569"
      - "4572:4572"
    environment:
      - SERVICES=dynamodb,s3
      - DEFAULT_REGION=ap-northeast-1
      - DOCKER_HOST=unix:///var/run/docker.sock
```

s3とdynamodbがそれぞれ使用するポートはこちら

- s3:4572
- dynamodb:4569

LocalStackの起動
※初回はイメージのダウンロードがあるため、時間がかかります。

```bash
$ docker-compose up
```

## LocalStack用credentialの作成

LocalStack用にcredential情報を追加します。

```~/.aws/credentials
[localstack]
aws_access_key_id = dummy
aws_secret_access_key = dummy
```

```~/.aws/config
[profile localstack]
region = ap-northeast-1
output = json
```

## LocalStack上にDynamoDBのテーブルを作成

AWSのドキュメントを参考にテーブルを作成します。
https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Tools.CLI.html

```bash
$ aws dynamodb create-table \
>     --table-name Music \
>     --attribute-definitions \
>         AttributeName=Artist,AttributeType=S \
>         AttributeName=SongTitle,AttributeType=S \
>     --key-schema AttributeName=Artist,KeyType=HASH AttributeName=SongTitle,KeyType=RANGE \
>     --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1 \
>     --endpoint-url http://localhost:4569 --profile localstack

$ aws dynamodb list-tables --endpoint-url http://localhost:4569 --profile localstack
TABLENAMES      Music
```

## LocalStack上のS3にテストデータを配置

LocakStackのS3にバケットの作成と、実ファイルの配置を行います。

```bash
# bucketの作成
$ aws s3 mb s3://music/ --endpoint-url=http://localhost:4572 --profile localstack
make_bucket: music
# テストデータのput
$ aws s3 cp ./testdata.json s3://music/rawdata/testdata.json --endpoint-url=http://localhost:4572 --profile localstack
upload: ./testdata.json to s3://music/rawdata/testdata.json  
```

テストデータは以下の内容で作成しました。

```testdata.json
{
        "Artist": "Acme Band",
        "SongTitle": "Happy Day",
        "AlbumTitle": "Songs About Life"
}
```

## 関数のベースを作成

まずは依存関係に`boto3`を追加します。
`boto3`はPythonでS3やDynamoDBなどのリソースを扱うためのライブラリです。
https://github.com/boto/boto3

```bash
$ pip install boto3
$ pip freeze > requirements.txt
```

関数の作成
今回はs3_dynamoという関数にしました。

```bash
$ mkdir s3_dynamo
$ touch s3_dynamo/__init__.py s3_dynamo/app.py
```

SAMのテンプレートの`Resources`セクションに、作成した関数を追記します。

```template.yml
Resources:
    S3DynamoFunction:
      Type: AWS::Serverless::Function
      Properties:
          CodeUri: s3_dynamo/build/
          Handler: app.lambda_handler
          Runtime: python3.6
```

ローカル実行用のプロファイルとして、以下のファイルも合わせて作成しました。

```env-local.json
{
  "S3DynamoFunction": {
    "AWS_SAM_LOCAL": true
  }
}
```

## S3イベントの作成

SAM CLIの機能で各Lambda関数のイベントを生成できるようになっているので、S3をputするイベント定義を作成します。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/test-sam-cli.html

わかりにくいですが、 `--key`にはS3のバケット配下のパスを渡します。

```bash
$ sam local generate-event s3 put --bucket music --key rawdata/testdata.json --region ap-northeast-1 > s3_event.json 
```

出力されたイベントファイルがこちら。
`awsRegion`,`bucket.name`,`object.key` が合っていれば正しくLambda側で判定できそうです。


```s3_event.json
{
  "Records": [
    {
      "eventVersion": "2.0",
      "eventSource": "aws:s3",
      "awsRegion": "ap-northeast-1",
.
.省略
.
      "s3": {
        "s3SchemaVersion": "1.0",
        "configurationId": "testConfigRule",
        "bucket": {
          "name": "music",
          "ownerIdentity": {
            "principalId": "EXAMPLE"
          },
          "arn": "arn:aws:s3:::music"
        },
        "object": {
          "key": "rawdata/testdata.json",
          "size": 1024,
          "eTag": "0123456789abcdef0123456789abcdef",
          "sequencer": "0A1B2C3D4E5F678901"
        }
      }
    }
  ]
}
```

## S3からの読み込み処理を実装

まずはS3からファイルを読み取れることを確認するために、以下のようにS3からファイルを読んで標準出力に表示する部分だけを実装します。

```s3_dynamo/app.py
import os
import json
import boto3
import pprint
import urllib.parse

if os.getenv("AWS_SAM_LOCAL"):
    dynamodb = boto3.resource(
        'dynamodb',
        endpoint_url='http://host.docker.internal:4569/'
    )
    s3 = boto3.client(
        's3',
        endpoint_url='http://host.docker.internal:4572/'
    )
else:
    dynamodb = boto3.resource('dynamodb')
    s3 = boto3.client('s3')


def lambda_handler(event, context):
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'], encoding='utf-8')
    print("[bucket]: " + bucket + " [key]: " + key)

    try:
        response = s3.get_object(Bucket=bucket, Key=key)
        d = json.loads(response['Body'].read())
        pprint.pprint(d)
    except Exception as e:
        print(e)
        raise e
```

`env-local.json`を使用して実行するため、今回はS3 / DynamoDBとしては以下の定義が使われます。
sam localで実行する場合もdocker内部から実行されるようで、ホスト名は`host.docker.internal`を使っています。

```py
    dynamodb = boto3.resource(
        'dynamodb',
        endpoint_url='http://host.docker.internal:4569/'
    )
    s3 = boto3.client(
        's3',
        endpoint_url='http://host.docker.internal:4572/'
    )
```

実際に実行してみます。

```bash
$ pip install -r requirements.txt -t s3_dynamo/build
$ cp s3_dynamo/*.py s3_dynamo/build/
$ sam local invoke S3DynamoFunction --event s3_event.json --profile localstack

# result
[bucket]: music [key]: rawdata/testdata.json
{'AlbumTitle': 'Songs About Life',
 'Artist': 'Acme Band',
 'SongTitle': 'Happy Day'}
```

無事にbuket、keyが渡され、S3ファイルの中身を表示することができています。

## DynamoDBへputするコードの実装

以下が最終型です。

```s3_dynamo/app.py
import os
import json
import boto3
import pprint
import urllib.parse

if os.getenv("AWS_SAM_LOCAL"):
    dynamodb = boto3.resource(
        'dynamodb',
        endpoint_url='http://host.docker.internal:4569/'
    )
    s3 = boto3.client(
        's3',
        endpoint_url='http://host.docker.internal:4572/'
    )
else:
    dynamodb = boto3.resource('dynamodb')
    s3 = boto3.client('s3')

table = dynamodb.Table('Music')


def lambda_handler(event, context):
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'], encoding='utf-8')
    print("[bucket]: " + bucket + " [key]: " + key)

    try:
        response = s3.get_object(Bucket=bucket, Key=key)
        d = json.loads(response['Body'].read())
        pprint.pprint(d)

        table.put_item(
            Item=d
        )
    except Exception as e:
        print(e)
        raise e
```

わかりにくいですが、以下のコードを追加しています。

```py
table = dynamodb.Table('Music')
.
.
.
        table.put_item(
            Item=d
        )
```


## LambdaのTimeoutを伸ばす

デフォルトではLambdaの実行時間上限は3秒になっているので、適当に伸ばしておきます。

```template.yml
Globals:
    Function:
        Timeout: 100
```

## 結果確認

再度実行してみます。

```bash
$ cp s3_dynamo/*.py s3_dynamo/build/
$ sam local invoke S3DynamoFunction --event s3_event.json --profile localstack
```

AWS CLIでMusicテーブルの中身を確認

```bash
$ aws dynamodb scan --table-name Music --endpoint-url http://localhost:4569 --profile localstack
1       1
ALBUMTITLE      Songs About Life
ARTIST  Acme Band
SONGTITLE       Happy Day
```

無事にDynamoDBのテーブルにデータがputされています。

# おわりに

今までLambdaのコンソール上で短いコードを書いて実行するといったことはやったことがありましたが、実際にローカルに開発環境を用意して実行してみるという部分は初めてだったので、ハマりどころや分からない部分も多くありました。
実際にSAMを使用してAWS上にデプロイするにはCloudFormationを使うなど、もう少し学ぶところがありそうだという印象でした。
Serverlessのメリットを享受するために、これから吸収していければと思います。