AWS DynamoDB を CloudFormation で作成して Python でアクセスしてみる

<iframe width="560" height="425" src="https://www.youtube.com/embed/fjoT45O5bxY" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

このドキュメントは毎週土曜日に配信しているポッドキャスト「テックチューン」からピックアップしたトピックスです。

時代の傍観者、宴会部長、マカ―の生き残り、生きた化石のいしたんがお届けする、人生の吹き溜まりのラジオ番組「テックチューン」の時間です。テックチューンは最近話題のテクノロジーや私の身の回りで起こったこと、興味があることなどをお話する番組です。特に目標もなく無気力に続けていきたいと思いますので、お付き合いいただけますと幸いです。

# 目的
DynamoDBの事を全く知らない人間が、DynamoDBの概念や使い方を理解するためのPoCです。AWS Lambda から DynamoDB へアクセスする為の事前準備としてDynamoDBにテーブルを作成する事と、ローカルからPythonでアクセスしてinsertやselectをできるようにします。

# 前提
下記の様なテーブルを作成して、DynamoDBの構造や特徴を理解します。

| テーブル名 |	User |
|---|---|
|テーブルの項目	|	Email（String型）<br />Name（String型）<br />Gender（String型）<br />Birthday（String型） |
|パーティションキー|	Email|
|ソートキー|	Name|
|テーブルのキャパシティ|	RCU：3<br />WCU：3|
|グローバルセカンダリインデックス①|	インデックス名：GSI-Name<br />	パーティションキー：Name<br />	ソートキー：Birthday<br />	射影される属性：Email<br />	RCU：1<br />	WCU：1<br />|
|グローバルセカンダリインデックス②|	インデックス名：GSI-Gender<br />	パーティションキー：Gender<br />	ソートキー：Birthday<br />	射影される属性：Email<br />	RCU：1<br />	WCU：1|

まずここで上記の仕様の中で分からないキーワードがたくさん出てきましたので一つずつ調べて行きたいと思います。

参考
https://blog.usize-tech.com/table-design-for-amazon-dynamodb/

## DynamoDB の特徴

DynamoDB の特徴としてはキーバリュー型のデータベースという事は何となく知っていたのですが、RDBと何が違うのか理解できていませんでした。RDBの様にJOINしたりはできないみたいで、キーバリュー型として割り切って使う必要がありそうです。
> Amazon DynamoDB は NoSQL と言われる類のデータベースですが、検索条件の指定に限りがあり、また検索条件を増やすための設定が構築後に追加できない箇所があります。
> そのため、最初のテーブル設計の時点で実際に使用する検索条件を意識して構築しないと、最悪データベースの作り直しになってしまいます。

DynamoDB ではテーブルのレコードを検索するためにパーティションキーとソートキーというものがあるようです。逆にパーティションキーとソートキーでしか検索できないようです。
> フィルターは全件取得の後に条件でデータをフィルターするので、データ数や実行回数が多いと課金が高額になる恐れがあります。
> また、データ量が多いとレスポンスが一気に遅くなるので、実用的でなくなるケースがあります。
> パーティションキーのみ、またはパーティションキーとソートキーの組み合わせでデータが一意になるようにする必要があります。

DynamoDB ではパーティションキーとソートキー以外にも属性を持たせて、複数の項目を１レコードとして保存することができます。ただ、複数の項目として保存したものを検索することはできないようです。
> パーティションキーおよびソートキーにしか検索条件をかけられません。属性に対して検索をかけることはできません。
> パーティションキーは必ず検索条件に指定する必要があります。ソートキーのみを検索条件にすることはできません。
> パーティションキーには、完全一致条件のみ指定できます。
> 指定した条件にマッチした行のデータを1つまたは複数返してくれる、というのが検索でできることです。

パーティションキーは完全一致でしか検索できないのですが、ソートキーはいろいろな条件を付けて前方一致等の条件で検索することができるようです。

> EQ | LE | LT | GE | GT | BEGINS_WITH | BETWEEN

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Query.html

では、DynamoDB はテーブル作成時に決めたパーティションキーとソートキーだけしか検索できないとなると、そんな使い勝手で果たしてプログラム上、使えるものだろうかという疑問がわいてくるのですが、どうも別の設定を追加することもできるようです。

### ローカルセカンダリインデックス
データはそのままでソートキーを別の属性に変更することができます。ただしテーブルの初回構築時にしか設定できない点があります。パーティションキーは変更できません。

### グローバルセカンダリインデックス
データはそのままでパーティションキーとソートキーを別の属性に変更することができます。
1 つのテーブルにつき 20 個まで設定可能で、テーブル構築後に後から追加で設定できます。

とのことで、グローバルセカンダリインデックス等の用語については理解できました。

### テーブルキャパシティ

さらにに使用を細かく見ていくと、テーブルキャパシティという項目があり、RCU, WCU という数値が設定されています。この数値はいったい何なのか、何を元にこの数値を決めればよいのかが分からない為、調べていきます。

参考
https://www.magata.net/memo/index.php?DynamoDB%A4%CE%A5%AD%A5%E3%A5%D1%A5%B7%A5%C6%A5%A3%A5%E6%A5%CB%A5%C3%A5%C8%A4%CB%A4%C4%A4%A4%A4%C6#j1b271ce

テーブルキャパシティとはDynamoDBの利用料金に関わる項目の様で、１秒間に何回アクセスできるかを指定する数値の様です。DynamoDB の利用料計算は下記の様になっているようです。

> データ保管にかかる料金
> 毎月最初の 25 GB の保管は無料　※それ以降は月額 0.285USD/GB（東京リージョン）
> 
> 読み書きにかかる料金
> 読み書きの度に料金がかかる
> 読み込み/書き込みキャパシティーモードとして「プロビジョニング」と「オンデマンド」から選択できる
> 

### プロビジョニングモード
月あたり 25WCU、25RCU は無料利用可能
読み込み/書き込み キャパシティユニット単位 で料金がかかる
テーブルや索引(GSI/LSI)に事前にキャパシティユニットを割り当てる必要がある
事前に割り当てたキャパシティユニットを超えるスループットは出せない
キャパシティユニットの Auto Scaling は可能。

### オンデマンドモード
読み書きにかかる無料利用枠は無い
読み込み/書き込み リクエスト単位 で料金がかかる
読み込みは 4KB単位で 1リクエスト(強力な整合性のある読み込みの場合) または 0.5リクエスト(結果整合性のある読み込みの場合) として計算される
書き込みは 1KB単位で 1リクエストとして計算される
0.2854USD/100万読み込みリクエスト、1.4269USD/100万書き込みリクエスト （東京リージョン）

プロビジョニングモードの方がかなり安いようです。

> 読み書き
> 読み込み (強力な整合性)	1秒に1回 (4KBまでは)
> 読み込み (結果整合性)	1秒に2回 (4KBまでは)
> 書き込み	1秒に1回 (1KBまでは)

無料枠は下記の様になっているので、これを超えなければ利用料は発生しないという事になりそうです。
> 25GBのストレージ
> 25ユニットの書き込みキャパシティ
> 25ユニットの読み込みキャパシティ

キャパシティユニットとは1秒あたりにN回の読み書きできる容量という事が分かりました。
そうなると読込は大量にするけど、書込みはあまりしないという場合は３でなくてもよさそうです。

## CloudFormation を理解する
では実際にDynamoDBにテーブルを作成していきたいところなのですが、その前にCloudFormationを覚えたいと思います。通常であればAWSコンソールを操作してテーブルを作成するのですが、コンソールの操作は記録に残らない為、CloudFormation を使用して設定ファイルから作成してみます。

実際の作業に入る前にいろいろと勉強しなければならないことがあるのがAWSの難易度の高い所かと思います。

まず CloudFormation が何かを理解するために、CloudFormation で S3 のバケットを作ってみます。
CloudFormation というのはJSONやYAMLで設定ファイルを記述しておけば、その設定ファイルに基づいてAWS上のサービスを作成することができる機能です。

CloudFormationを使用することで下記の様なメリットがあります。
- 設定ファイルをGitでバージョン管理できる
- 変更箇所を記録に残すことができる
- 構築を他の環境にも適応することができる

良い所しかないように思えるのですが最大の欠点として設定ファイルが書けないと使えないという超難関ハードルがあります。ただ、設定ファイルは一度作ってしまってテンプレートを持っておけば他のプロジェクトにも使いまわしができるので良いかと思います。

では事前検証としてS3バケットをCloudFormationで作成する方法を確認します。下記ではS3のバケット作成と同時にバケットのパーミッションも付与しています。パーミッションの付与がいらない人は「TestS3BucketPolicy: 」より前の行までで良いです。

```test01.yaml
AWSTemplateFormatVersion: 2010-09-09
Description: sample
Resources:
  TestS3:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: "20230420-s3-tokyo"

  TestS3BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket:
        Ref: "TestS3"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          Action: "s3:*"
          Effect: "Allow"
          Principal:
                AWS:
                   - "arn:aws:iam::XXXX:user/ishida"
                   - "arn:aws:iam::XXXX:role/lambda-role"
          Resource:
            - "arn:aws:s3:::20230420-s3-tokyo/*"
```

まず、下記のコマンドで上記のテンプレートに問題ないかをチェックすることができます。
```
$ aws cloudformation validate-template --template-body file://test01.yaml
```

チェックして問題なければ、実際にAWS上にバケットを作成してみます。
```
$ aws cloudformation create-stack --template-body file://test01.yaml --stack-name test01
```

そうすると、こんな感じでCloudFormationのコンソールに自動実行の結果が表示されます。
![キャプチャ.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514807/044136da-d88c-ebcf-1d6c-b2e8a1a7a633.png)

作成したものを丸ごと削除することもできます。（ロールバック）
```
$ aws cloudformation delete-stack --stack-name test01
```

私は、これらのコマンドを覚えられないので、check.sh, create.sh, delete.sh 等のシェルスクリプトにして簡単に実行することができるようにしました。

ちなみに、言わずもがな上記の実行の結果によってS3上にバケットが作成され、なおかつPermissionも設定されていることが確認できました。

![キャプチャ_20230421_2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514807/e7e4a208-33c7-a9f3-1d74-9e91f971c349.png)

## 実際に DynamoDB を作成してみる

やっとここまできました。とうとう DynamoDB を作成してみましょう。
冒頭のテーブル仕様に基づき CloudFormation の YAML を書きました。

```dynamo01.yaml
AWSTemplateFormatVersion: 2010-09-09
Description: "DynamoDB Test"

Resources:
  User:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: "User"
      AttributeDefinitions:
        - AttributeName: "Email"
          AttributeType: "S"
        - AttributeName: "Name"
          AttributeType: "S"
        - AttributeName: "Gender"
          AttributeType: "S"
        - AttributeName: "Birthday"
          AttributeType: "S"
      KeySchema:
        - AttributeName: "Email"
          KeyType: "HASH"
        - AttributeName: "Name"
          KeyType: "RANGE"
      ProvisionedThroughput:
        ReadCapacityUnits: "3"
        WriteCapacityUnits: "3"
      GlobalSecondaryIndexes:
        - IndexName: "GSI-Name"
          KeySchema:
            - AttributeName: "Name"
              KeyType: "HASH"
            - AttributeName: "Birthday"
              KeyType: "RANGE"
          Projection:
            NonKeyAttributes:
              - "Email"
            ProjectionType: "INCLUDE"
          ProvisionedThroughput:
            ReadCapacityUnits: "1"
            WriteCapacityUnits: "1"
        - IndexName: "GSI-Gender"
          KeySchema:
            - AttributeName: "Gender"
              KeyType: "HASH"
            - AttributeName: "Birthday"
              KeyType: "RANGE"
          Projection:
            NonKeyAttributes:
              - "Email"
            ProjectionType: "INCLUDE"
          ProvisionedThroughput:
            ReadCapacityUnits: "1"
            WriteCapacityUnits: "1"
```

これを CloudFront で実行して構築します。

```
$ aws cloudformation create-stack --template-body file://dynamo01.yaml --stack-name dynamo01
```

すると、DynamoDB コンソールに User テーブルができているのが分かります。
![キャプチャ_20230421_3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514807/40d415bc-573b-80a5-cbd3-c00ea19dfdfd.png)

## PythonでDynamoDBにアクセス
これでテーブル作成ができたのでいよいよ、Python でアクセスしてみたいと思います。
いきなり Lambda で実行するのではなく、まずはローカルで実行してみてアクセスできるかを確認します。
また、書込みや読込など一通りの操作ができるのかも確認します。

### テーブル一覧の取得
まずはテーブルの一覧を取り出してみましょう
```dynamodb_table_list.py
# -*- coding: utf-8 -*-

import boto3

dynamodb = boto3.resource('dynamodb')

for table in dynamodb.tables.all():
    print(table.name)
```
実行すると下記の様になります。
```
$ python3 dynamodb_table_list.py
User
```
User とだけ表示されていますが、これは先程 CloudFormation で作成した User テーブルが一つだけ見えているという結果になります。

### データの書込み
```dynamodb_table_insert.py
# -*- coding: utf-8 -*-

import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('User')

item = {
        "Email" : "ishida@buchi.jp",
        "Name" : "ishida yoshiteru",
        "Gender" : "male",
        "Birthday" : "1974/02/19"
}

table.put_item( Item=item )
```

こんな感じでデータを追加してみます。

### データの参照
追加したデータを検索してみます。とりあえず上位５件だけ取得してみます。
（といっても１件しか登録していないのだが）

```dynamodb_table_select.py
# -*- coding: utf-8 -*-

import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('User')

response = table.scan(Limit=5, ReturnConsumedCapacity='TOTAL')

items = response['Items']
rcu = response['ConsumedCapacity']['CapacityUnits']
print('Scanned items: %d' % len(items))
print('Consumed capacity: %.2f RCU' % rcu)


for item in items:
    print(item['Email'], item['Name'], item['Gender'], item['Birthday'])
```
上記を実行した結果
```
$ python3  dynamodb_table_select.py
Scanned items: 1
Consumed capacity: 0.50 RCU
ishida@buchi.jp ishida yoshiteru male 2023/01/02
$
```

### データの取得（１件のみ）
１件だけデータを取得したい場合は下記の様になります。

```dynamodb_table_get.py
# -*- coding: utf-8 -*-

import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('User')

response = table.get_item( Key={
        'Email':'ishida@buchi.jp',
        'Name':'ishida yoshiteru'
} )


print( response )

if "Item" in response:
        item = response["Item"]

        for key in item:
                print( key + ":" + item[key] )
```

上記を実行した結果は
```
$  python3 dynamodb_table_get.py
{'Item': {'Birthday': '2023/01/02', 'Gender': 'male', 'name': 'Yoshiteru Ishida', 'Email': 'ishida@buchi.jp', 'Name': 'ishida yoshiteru'}, 'ResponseMetadata': {'RequestId': 'D3T2OSQISOMP6LV7R6G1KB19RBVV4KQNSO5AEMVJF66Q9ASUAAJG', 'HTTPStatusCode': 200, 'HTTPHeaders': {'server': 'Server', 'date': 'Fri, 21 Apr 2023 07:08:16 GMT', 'content-type': 'application/x-amz-json-1.0', 'content-length': '158', 'connection': 'keep-alive', 'x-amzn-requestid': 'D3T2OSQISOMP6LV7R6G1KB19RBVV4KQNSO5AEMVJF66Q9ASUAAJG', 'x-amz-crc32': '2782734996'}, 'RetryAttempts': 0}}
Birthday:2023/01/02
Gender:male
name:Yoshiteru Ishida
Email:ishida@buchi.jp
Name:ishida yoshiteru
```
### データ更新
さらにデータを更新する場合
```dynamodb_table_update.py
# -*- coding: utf-8 -*-

import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('User')

response = table.update_item( Key={
                'Email':'ishida@buchi.jp',
                'Name':'ishida yoshiteru'
        },
        UpdateExpression="set Birthday = :Birthday",
        ExpressionAttributeValues= {
                ':Birthday': '2023/01/02'
        },
        ReturnValues='UPDATED_NEW'
)


print (response)
```

###  データ削除
 データを削除する場合は下記の様になります。
```dynamodb_table_delete.py
# -*- coding: utf-8 -*-

import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('User')

response = table.delete_item( Key={
        'Email':'ishida@buchi.jp',
        'Name':'ishida yoshiteru'
} )


print( response )
```

# おわりに
かなり、ここまでの道のりが長かったです。
これでやっとDynamoDBの読み書きができたので、各テーブルごとにモデルクラスやDAOを作成していけば、MVCモデルを作成できそうです。

次回はPtyhonのコードをLambdaに展開して、Lambda からDynamoDBの操作を行いたいと思います。