DynamoDB テーブル設計と PII 配慮 — On-demand と PITR

本シリーズの問い合わせフォームは、入力データを DynamoDB に保存します。RDS じゃなく DynamoDB を選んだ理由・PK 選定の考え方・PITR や暗号化の設定について書きます。

なぜ RDS ではなく DynamoDB か

個人ポートフォリオ規模で「データを保存する」と聞くと、最初に思い浮かぶのが RDS。でも本シリーズでは DynamoDB を採用しました:

観点RDSDynamoDB (本シリーズ採用)
料金(無風時)最小 t4g.micro でも 月 $13〜On-demand なら 0 円
Lambda との相性同時接続枯渇問題(RDS Proxy 必須)HTTP API なので問題なし
運用マイナーバージョン適用必要完全マネージド
スキーマ固定スキーマスキーマレス
クエリSQL(柔軟)PK/SK ベース(制約あり)
個人サイト規模過剰最適

本シリーズのデータは 「問い合わせを 1 件保存・あとで一覧で見る」 程度なので、DynamoDB の制約はまったく問題になりません。

テーブル設計

contacts テーブルの設計:

属性用途
id (PK)StringUUID v4 で一意
nameString名前
emailStringメールアドレス
messageString本文
created_atString (ISO 8601)作成時刻
source_ipString送信元 IP(スパム対策の参考)
user_agentStringUA 文字列

Terraform 定義

resource "aws_dynamodb_table" "contacts" {
  name         = "${var.name_prefix}-contacts"
  billing_mode = "PAY_PER_REQUEST"            # On-demand
  hash_key     = "id"                          # PK

  attribute {
    name = "id"
    type = "S"                                  # String
  }

  point_in_time_recovery {
    enabled = true                              # PITR 有効
  }

  server_side_encryption {
    enabled = true                              # SSE 有効(デフォルト鍵)
  }

  tags = var.tags
}

PK と PITR・暗号化だけ書けば動きます。短い。

PK に email を入れない理由(PII 配慮)

初期設計では「PK = email」も検討しました。「同じメアドからの 2 回目はどう扱う?」と考える時にメアドベースが楽だから。でも次の理由で UUID PK + email は通常属性 にしました:

個人特定情報(メール / 氏名 / 電話 / 住所等)を PK にするのは 原則 NG。PK は不変・公開可能な値を使う。

billing_mode の選び方

モード料金適合ケース
PAY_PER_REQUEST (On-demand)読み書き 1 回ごとに課金アクセス薄い・予測困難
PROVISIONEDキャパシティ予約 + 超過分従量定常的に高負荷・読み書き量予測可能

本シリーズの「月数件の問い合わせ」想定なら On-demand 一択。月額 0〜数円。Provisioned だと最低でも月 $1〜2 が確実に発生する。

Point-in-Time Recovery (PITR)

PITR を有効化すると、過去 35 日間の任意の時点までテーブルを復旧できます:

料金: 保存サイズに応じて月額 $0.20/GB 程度。本シリーズ規模では実質無料。

暗号化(SSE)

server_side_encryption {
  enabled = true
}

これだけで AWS 管理キーで自動暗号化されます。料金 0 円。コンプライアンス(PCI-DSS / SOC2 等)でカスタマー管理キー (CMK) が必要なら、kms_key_arn を追加で指定。

Lambda からの書込

Lambda 側のコード(抜粋):

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "node:crypto";

const ddbClient = new DynamoDBClient({});       // ハンドラ外で初期化(再利用最適化)
const ddb = DynamoDBDocumentClient.from(ddbClient);

const TABLE_NAME = process.env.DDB_TABLE;

export const handler = async (event) => {
  const payload = JSON.parse(event.body);

  const item = {
    id:         randomUUID(),                    // UUID v4
    name:       payload.name.trim(),
    email:      payload.email.trim().toLowerCase(),
    message:    payload.message.trim(),
    created_at: new Date().toISOString(),
    source_ip:  event.requestContext?.http?.sourceIp,
    user_agent: event.headers?.["user-agent"],
  };

  await ddb.send(new PutCommand({ TableName: TABLE_NAME, Item: item }));

  return { statusCode: 200, body: JSON.stringify({ ok: true }) };
};

ポイント:

動作確認とデータ閲覧

# 件数確認
aws dynamodb scan --table-name iigtn-lab-prod-contacts --select COUNT

# 直近 5 件取得
aws dynamodb scan --table-name iigtn-lab-prod-contacts --max-items 5

# 1 件取得(id 指定)
aws dynamodb get-item --table-name iigtn-lab-prod-contacts \
  --key '{"id":{"S":"<UUID>"}}'

件数が増えてきたら(数千件超)scan ではなく query + GSI(Global Secondary Index)で時系列取得するのが定石。本シリーズではまだ追加していない。

後で GSI を追加する想定

「最新の問い合わせ 10 件を表示したい」要件が出たら、GSI を追加します:

# GSI: created_at で時系列ソート
attribute {
  name = "pk_dummy"
  type = "S"
}
attribute {
  name = "created_at"
  type = "S"
}

global_secondary_index {
  name            = "created_at-index"
  hash_key        = "pk_dummy"
  range_key       = "created_at"
  projection_type = "ALL"
}

pk_dummy に「contact」固定値を入れることで、全件を 1 つのパーティションに集約 → created_at でソート可能、という DynamoDB 定番テクニック。

次の記事

API Gateway + Lambda + DynamoDB が動いたら、次は https://lab.iigtn.com/api/contact という同一ドメインで API を公開する」 構成を作ります。CloudFront に /api/* 用 behavior を追加して API GW にルーティングする方法を解説します。

📚 用語集

DynamoDB
AWS のフルマネージド NoSQL データベース(KVS)。スキーマレス・自動スケール・サーバレス。
RDS (Relational Database Service)
AWS の RDB マネージドサービス。MySQL / PostgreSQL / Oracle 等。インスタンスベースで常時課金。
RDS Proxy
Lambda と RDS の同時接続枯渇を解消する中継プロキシ。Lambda + RDS の本番では事実上必須。
PK (Partition Key)
DynamoDB の主キー。全アイテムを一意に識別する。Hash Key とも呼ぶ。
SK (Sort Key)
DynamoDB の補助キー。同じ PK の中で並び順を決める。Range Key とも呼ぶ。
GSI (Global Secondary Index)
DynamoDB の追加インデックス。PK / SK と違うキーで検索できる。
scan
DynamoDB テーブルの全件取得 API。コスト高いので件数増えたら避ける。
query
DynamoDB の PK 指定取得 API。scan より遥かに高速・安価。
PutItem
DynamoDB にアイテム 1 件を書き込む API。
billing_mode
DynamoDB の課金モード設定。PAY_PER_REQUEST(On-demand)/ PROVISIONED
On-demand (PAY_PER_REQUEST)
読み書き 1 回ごとに課金される DynamoDB 課金モード。アクセス薄いなら最安。
Provisioned
キャパシティ予約型の DynamoDB 課金モード。定常負荷が予測できるなら安くなる。
PITR (Point-in-Time Recovery)
DynamoDB の連続バックアップ機能。過去 35 日間の任意時点に復旧可能。
SSE (Server-Side Encryption)
DynamoDB / S3 の保存時自動暗号化。AWS 管理キー(無料)/ KMS マネージド / カスタマー管理 (CMK) から選べる。
PII (Personally Identifiable Information)
個人特定情報。氏名・メール・電話・住所・マイナンバー等。GDPR / 個人情報保護法でも管理が義務付けられる。
UUID v4
ランダムベースの 128 ビット一意識別子。xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx 形式。衝突確率は実質 0。
DocumentClient (AWS SDK)
DynamoDB のラッパークライアント。型変換({"S": "foo"}"foo")を自動でやってくれる。
ハンドラ外初期化
Lambda の handler function の外で AWS SDK クライアントを初期化するパターン。Lambda コンテナ再利用時に高速化される。
ISO 8601
日時の国際標準形式。2026-04-26T13:00:00.000Z のように表現。DynamoDB に時刻を String で入れる時の定番。