DynamoDBについて備忘録

最近、DynamoDB（とLambda）を使ったサーバーレスなサービスを開発する機会があり、その時に得た知見についていくつか

- ローカルで[dynamodb-local](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html)とServerless Frameworkを使ってLambda関数を開発
- dynamodb-localへのテーブル作成は別途立てたawscliコンテナからAWS CLIのコマンドによって作成
- 本番環境はServerless Frameworkのresources句の設定によってデプロイ（スタックにDynamoDBテーブルの作成を含める）

## 開発環境
- WSL2（Ubuntu20.04）
- Docker（20.10.24）
- docker-compose（v2.17.2）
    - app
        - [node:18-slim](https://hub.docker.com/_/node/tags?page=1&name=18-slim)イメージをベースとしたLambda開発用のコンテナ
        - Node.jsのコンテナなのでAWS SDK for JavaScript v3を使用 (以後aws-sdk)
        - Serverless Framework v3
            - [serverless-layers](https://www.serverless.com/plugins/serverless-layers)、[serverless-plugin-typescript](https://www.serverless.com/plugins/serverless-plugin-typescript)プラグインを使用
    - dynamodb
        - [amazon/dynamodb-local](https://hub.docker.com/r/amazon/dynamodb-local)イメージを使用したローカル開発用のDynamoDBを動かすコンテナ
    - awscli
        - [amazon/aws-cli](https://hub.docker.com/r/amazon/aws-cli)イメージを使用したdynamodb-local操作用のコンテナ

---
以下、開発環境を作るための設定ファイル等
```yml:docker-compose.yml
services:
  app:
    build:
      context: ./
      dockerfile: ./.docker/app/Dockerfile
    volumes:
      - ./serverless:/serverless:cached
    tty: true
    environment:
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
      - AWS_DEFAULT_REGION=ap-northeast-1

  dynamodb:
    image: amazon/dynamodb-local
    command: -jar DynamoDBLocal.jar -sharedDb -dbPath . -optimizeDbBeforeStartup
    volumes:
      - dynamodb:/home/dynamodblocal
    ports:
      - 8000:8000

  awscli:
    image: amazon/aws-cli
    environment:
      - AWS_ACCESS_KEY_ID=fake_access_key
      - AWS_SECRET_ACCESS_KEY=face_secret_access_key
      - AWS_DEFAULT_REGION=ap-northeast-1
    tty: true
    command:
      - /bin/sh
    entrypoint: [""]

volumes:
  dynamodb:
    driver: local
```
```Dockerfile:.docker/app/Dockerfile
FROM node:18-slim
SHELL ["/bin/bash", "-oeux", "pipefail", "-c"]

ENV TZ=Asia/Tokyo

USER node

WORKDIR /serverless
```
```:.env
COMPOSE_PATH_SEPARATOR=:
COMPOSE_FILE=docker-compose.yml

AWS_ACCESS_KEY_ID=<AWSアクセスキー>
AWS_SECRET_ACCESS_KEY=<AWSシークレットアクセスキー>
```

- awscliコンテナに対して設定しているAWSアクセスキー/シークレットアクセスキーはダミーでOKみたい
- appコンテナはLambda関数の開発およびServerless Frameworkを使用したデプロイを行うためのもの。AWS本番にデプロイを行うのに必要となるポリシーがついたIAMアカウントのAWSアクセスキー/シークレットアクセスキーを.envより指定（次項）

### AWS本番へのデプロイに必要となったポリシー
マネジメントコンソール上のポリシーエディタより取得
`※Lambda関数を作成するためのもの、Lambda関数の中で行っているDynamoDBテーブルの操作で必要となるもの、Lambda関数にアタッチされるAPI GatewayやEventBridgeを作成するのに必要となるポリシーなどを含みます。`
```json:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "dynamodb:DeleteItem",
                "cloudformation:DescribeStackResource",
                "cloudformation:CreateChangeSet",
                "dynamodb:DeleteTable",
                "logs:DeleteLogStream",
                "cloudformation:DeleteChangeSet",
                "logs:CreateLogStream",
                "cloudformation:DescribeStackEvents",
                "dynamodb:DescribeTable",
                "dynamodb:GetItem",
                "cloudformation:DescribeChangeSet",
                "cloudformation:ExecuteChangeSet",
                "events:*",
                "cloudformation:ListStackResources",
                "dynamodb:PutItem",
                "apigateway:*",
                "logs:DeleteLogGroup",
                "s3:*",
                "dynamodb:Scan",
                "dynamodb:Query",
                "logs:TagResource",
                "s3-object-lambda:*",
                "logs:CreateLogGroup",
                "cloudformation:DescribeStacks",
                "iam:*",
                "dynamodb:CreateTable",
                "cloudformation:GetTemplate",
                "cloudformation:DeleteStack",
                "lambda:*",
                "cloudformation:ValidateTemplate",
                "dynamodb:GetRecords"
            ],
            "Resource": "*"
        }
    ]
}
```

## 備忘録
以下、自分用
### awscliのcreate-tableではTTLの設定はできない
CloudFormationのAWS::DynamoDB::Tableのリソースを作成するときにはTTLの設定もできる

- [awscliのcreate-tableコマンド](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/dynamodb/create-table.html)
- [CloudFormationのAWS::DynamoDB::Tableリソース](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-table.html)

### aws-sdkのCreateTableやUpdateTableについて
DynamoDBの[CreateTable](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-dynamodb/classes/createtablecommand.html)や[UpdateTable](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-dynamodb/classes/updatetablecommand.html)などのコマンドは非同期でテーブルに関する指示をしているのであって、作成や更新の完了は待っていない（CreateTableコマンドの先頭の方に書いてあった）。

> CreateTable is an asynchronous operation. Upon receiving a CreateTable request, DynamoDB immediately returns a response with a TableStatus of CREATING. After the table is created, DynamoDB sets the TableStatus to ACTIVE. You can perform read and write operations only on an ACTIVE table. 

> You can use the DescribeTable action to check the table status.

CreateTableコマンド実行直後にテーブルに対してTTLを設定したりレコードをインサートしたい場合は[DescribeTable](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-dynamodb/classes/describetablecommand.html)コマンドを数秒ごとに実行するなどしてテーブルのステータスがアクティブになったことを確認する再帰の処理を書く必要がありました。
なお、dynamodb-localを使って開発していたときは即時でテーブルがアクティブとなっていたため、AWS環境にデプロイするまで問題に気づけませんでした。。

### テーブル作成時のattribute-definitionsに関するエラーについて
- attribute-definitionsで定義した属性について、KeySchema（プライマリキー）、ローカルセカンダリインデックス（LSI）、グローバルセカンダリインデックス（GSI）等で全てを消費していない場合にエラーが発生する（間違っているかも。。）

以下のような文字列の属性を2つ、数値の属性を1つ持つテーブルを作成するコマンドを例とした場合

```bash:エラーなし
$ aws dynamodb --endpoint-url http://dynamodb:8000 \
    create-table \
    --table-name sample1 \
    --attribute-definitions \
      AttributeName=attr1,AttributeType=S \
      AttributeName=attr2,AttributeType=N \
      AttributeName=attr3,AttributeType=S \
    --key-schema \
      AttributeName=attr1,KeyType=HASH \
      AttributeName=attr2,KeyType=RANGE \
    --billing-mode PROVISIONED \
    --provisioned-throughput \
      ReadCapacityUnits=1,WriteCapacityUnits=1 \
    --local-secondary-indexes \
      '[{ "IndexName": "attr3_index", "KeySchema": [ { "AttributeName": "attr1", "KeyType": "HASH" },{ "AttributeName": "attr3", "KeyType": "RANGE" } ], "Projection": { "ProjectionType": "ALL" } } ]'
```

```bash:エラーあり(attr3がどこでも使用されていない)
$ aws dynamodb --endpoint-url http://dynamodb:8000 \
    create-table \
    --table-name sample2 \
    --attribute-definitions \
      AttributeName=attr1,AttributeType=S \
      AttributeName=attr2,AttributeType=N \
      AttributeName=attr3,AttributeType=S \
    --key-schema \
      AttributeName=attr1,KeyType=HASH \
      AttributeName=attr2,KeyType=RANGE \
    --billing-mode PROVISIONED \
    --provisioned-throughput \
      ReadCapacityUnits=1,WriteCapacityUnits=1

An error occurred (ValidationException) when calling the CreateTable operation: The number of attributes in key schema must match the number of attributes defined in attribute definitions.
```

```bash:エラーなし(attr3をプライマリキーとしたQuery、Scanを行わないのであれば作らない方が良い？)
$ aws dynamodb --endpoint-url http://dynamodb:8000 \
    create-table \
    --table-name sample3 \
    --attribute-definitions \
      AttributeName=attr1,AttributeType=S \
      AttributeName=attr2,AttributeType=N \
      AttributeName=attr3,AttributeType=S \
    --key-schema \
      AttributeName=attr1,KeyType=HASH \
    --billing-mode PROVISIONED \
    --provisioned-throughput \
      ReadCapacityUnits=1,WriteCapacityUnits=1 \
    --global-secondary-indexes \
      '[{ "IndexName": "attr3_index", "KeySchema": [ { "AttributeName": "attr3", "KeyType": "HASH" },{ "AttributeName": "attr2", "KeyType": "RANGE" } ], "Projection": { "ProjectionType": "ALL" }, "ProvisionedThroughput": { "ReadCapacityUnits": 1, "WriteCapacityUnits": 1 } } ]'
```

- 属性はデータをインサートするときに任意で追加できるため、ソート条件などで使用しない属性はテーブル作成のタイミングで含める必要が無さそう。
- LSIはともかく、attribute-definitionsに含めるためだけに使わないGSIを作ると不要なプロビジョンドスループットの消費が行われてしまうはず。

ちなみに上記のsample1テーブルをserverless.ymlのresources句で表現すると以下のような感じ
```yml:serverless/serverless.yml
resources:
  Resources:
    SampleTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: sample1
        AttributeDefinitions:
          - AttributeName: attr1
            AttributeType: S
          - AttributeName: attr2
            AttributeType: N
          - AttributeName: attr3
            AttributeType: S
        KeySchema:
          - AttributeName: attr1
            KeyType: HASH
          - AttributeName: attr2
            KeyType: RANGE
        BillingMode: PROVISIONED
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
        LocalSecondaryIndexes:
          - IndexName: attr3_index
            KeySchema:
              - AttributeName: attr1
                KeyType: HASH
              - AttributeName: attr3
                KeyType: RANGE
            Projection:
              ProjectionType: ALL
```

### 動作確認について
[serverless-offline](https://www.serverless.com/plugins/serverless-offline)プラグインを使うことでローカルでAPI Gatewayを模した動作確認が行えるみたいですが、今回は使わずに以下のようなコードを書いて確認しました。
appコンテナ内から以下のようなコマンドで動作確認。

```bash
$ npx serverless invoke local \
	  --stage local \
	  --function Sample \
	  --data '{"queryStringParameters": {"param1": "aaa", "param2": "bbb"} }'
```

```typescript:sample.ts
import { APIGatewayEventRequestContextV2, APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";

export const handler = async (event: APIGatewayProxyEventV2, context: APIGatewayEventRequestContextV2): Promise<APIGatewayProxyResultV2> => {
  const param1 = event.queryStringParameters.param1;
  const param2 = event.queryStringParameters.param2;
...
}
```

### その他、よくわかってないこと
DynamoDBについて開発環境はAWS CLIを使ったテーブル操作を行いましたが、本番にはServerless Frameworkを使用してデプロイ時のCloudFormationのスタック作成で作成する都合上、1つのテーブルを定義するのに2種類の記述が必要となってしまいました。。
こちらどう解決すればよかったのか悩み中。。

## 参考リンク
今回のDynamoDBとLambdaを使ったサービスを作った際に読んだページ
- [Amazon DynamoDB のコアコンポーネント - Amazon DynamoDB](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html)
    - DynamoDB固有の用語などの理解が進むページ(公式)
- [コンピュータ上で DynamoDB をローカルでデプロイする - Amazon DynamoDB](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html)
    - 記事内のdocker-compose.ymlに記載したdynamodbとawscliコンテナはこちらのページを参考にしました(公式)
- [DynamoDB でのエラー処理 - Amazon DynamoDB](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Programming.Errors.html)
    - DynamoDBの例外がまとまったページ(公式)
- [AWS::DynamoDB::Table - AWS CloudFormation](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-table.html)
    - CloudFormationを使用してDynamoDBテーブルを作成するときに読んだページ(公式)
- [Amazon DynamoDB のテーブル設計で悩んだら最初に読もう -これだけ知ればある程度の検索には対応できる-  &#8211;  TechHarmony](https://blog.usize-tech.com/table-design-for-amazon-dynamodb/)
    - DynamoDBの入門としてGSI、LSIについて理解するのに参考になりました。
- [DynamoDBの各データを自動で削除する機能（TTL:Time to Live）を試してみた | DevelopersIO](https://dev.classmethod.jp/articles/try-dynamodb-ttl/)
    - 今回の記事の中では特に書かなかったですがTTLの存在はこちらの記事で知りました
    - 今回作成したサービスでは直近数日のデータのみを扱うことになっており、不要となったデータが自動で消えてくれるというDynamoDBのTTL機能が非常に便利でした
- [え、CloudFormationでdynamoDBを作成するときは全カラムをプライマリー・ソートキーにしなきゃいけないんですか!? - Qiita](https://qiita.com/soana/items/036a5684d274f62bcee5)
    - DynamoDB入門中、テーブル設計中に発生したエラーの原因を理解するのに参考になりました
- [無料枠で頑張るためにDynamoDBのキャパシティを理解する - ITと筋トレの二刀流](https://tatsuyashi.hatenablog.com/entry/2019/01/09/080000)
    - キャパシティユニットの見積り方などの参考になりました