Amazon DynamoDB TTLによるデータライフサイクル管理:2025年版完全ガイド

Amazon DynamoDB TTLによるデータライフサイクル管理:2025年版完全ガイド

エンジニアブログ
最終更新日:2025年08月28日公開日:2025年08月27日
益子 竜与志
writer:益子 竜与志
XThreads

DynamoDBの「TTL(Time to Live)」機能は、データの自動削除によるストレージコストの最適化を実現する重要な機能です。しかし、その挙動や制約を正しく理解していないと、思わぬトラブルに遭遇することがあります。

本記事では、2025年最新の公式情報をもとに、TTLの正確な仕様理解から実装パターン、そして運用上の注意点まで、エンジニアが押さえるべきポイントを体系的に解説します。特に、グローバルテーブルにおける課金の落とし穴や、DynamoDB Streamsとの連携による高度なイベント駆動アーキテクチャの実現方法など、実践的な内容を盛り込みました。

Amazon DynamoDB TTLによるデータライフサイクル管理:2025年版完全ガイド

TTL機能の本質的な価値と現実的な制約

DynamoDBの「TTL(Time to Live)」は、期限切れデータを自動削除することでストレージコストを削減する機能です。しかし、この機能には多くのエンジニアが見落としがちな重要な制約があります。

最も重要な点は、TTLによる削除は期限切れ直後に即座に実行される保証がないということです。AWSの公式ドキュメントによると、削除処理はバックグラウンドで実行され、「通常は数日以内」という表現で説明されています。この「数日」という時間軸は、リアルタイム性を求めるシステムにとっては致命的な制約となる可能性があります。

私が過去に携わったプロジェクトでは、この仕様を正しく理解していなかったために、セッション管理システムで期限切れセッションが残存し続けるという問題に直面しました。結果として、アプリケーション側で期限チェックロジックを実装する必要があり、TTLはあくまでも「最終的なクリーンアップ」として位置づけることになりました。

削除タイミングの不確実性とその対策

期限切れデータの可視性問題

TTLで設定した期限を過ぎたアイテムは、削除されるまでの間、通常の読み取り・書き込み操作で参照可能な状態が続きます。この期間中、期限切れアイテムは通常のアイテムと同様に扱われ、読み取り容量単位や書き込み容量単位を消費します。

この問題に対処するための実装パターンを以下に示します。TypeScriptとAWS SDK v3を使用した実装例です。

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

// 期限切れアイテムを除外してクエリする関数
async function queryActiveItems(partitionKey: string): Promise<any[]> {
  const currentTime = Math.floor(Date.now() / 1000);

  const response = await ddb.send(new QueryCommand({
    TableName: "my-table",
    KeyConditionExpression: "pk = :pk",
    FilterExpression: "attribute_not_exists(expiresAt) OR expiresAt > :now",
    ExpressionAttributeValues: {
      ":pk": partitionKey,
      ":now": currentTime
    }
  }));

  return response.Items || [];
}

このアプローチでは、「FilterExpression」を使用して期限切れアイテムをアプリケーション側で除外しています。ただし、FilterExpressionは読み取り容量を節約しない点に注意が必要です。フィルタリングはDynamoDBがアイテムを読み取った後に適用されるため、期限切れアイテムも読み取り容量を消費します。

復活防止パターンの実装

期限切れアイテムを誤って更新してしまうと、そのアイテムは再び「生きた」状態になり、TTLによる削除対象から外れてしまいます。これを防ぐための条件付き更新の実装例を示します。

import { UpdateCommand } from "@aws-sdk/lib-dynamodb";

async function updateItemWithTTLCheck(
  itemId: string,
  updateData: Record<string, any>
): Promise<void> {
  const currentTime = Math.floor(Date.now() / 1000);

  try {
    await ddb.send(new UpdateCommand({
      TableName: "my-table",
      Key: { id: itemId },
      UpdateExpression: "SET #data = :data",
      ConditionExpression: "attribute_not_exists(expiresAt) OR expiresAt > :now",
      ExpressionAttributeNames: {
        "#data": "data"
      },
      ExpressionAttributeValues: {
        ":data": updateData,
        ":now": currentTime
      }
    }));
  } catch (error: any) {
    if (error.name === "ConditionalCheckFailedException") {
      console.log("アイテムは期限切れのため更新をスキップしました");
    } else {
      throw error;
    }
  }
}

グローバルテーブルにおける課金の落とし穴

グローバルテーブルでTTLを使用する場合、元リージョンでのTTL削除は書き込み容量を消費しませんが、レプリカリージョンへの削除伝播は書き込み容量として課金されるという重要な仕様があります。

この課金モデルの影響を具体的な数値で見てみます。以下の表は、月間100万件のTTL削除が発生するシナリオでのコスト比較です。

表 グローバルテーブルにおけるTTL削除のコスト影響

構成

レプリカ数

元リージョンコスト

レプリカコスト

月間総コスト

シングルリージョン

0

$0

$0

$0

2リージョン構成

1

$0

$1.25

$1.25

3リージョン構成

2

$0

$2.50

$2.50

5リージョン構成

4

$0

$5.00

$5.00

※オンデマンド課金モード、書き込み単価$1.25/100万リクエストで計算

一見すると小額に見えますが、大量のセッションデータやログデータを扱うシステムでは、月間数億件のTTL削除が発生することも珍しくありません。その場合、レプリカのコストは無視できない金額になります。

DynamoDB Streamsとの高度な連携パターン

TTL削除イベントの識別と活用

TTLで削除されたアイテムはDynamoDB Streamsに特別なマーカー付きで記録されるため、これを利用した高度なアーキテクチャを構築できます。TTL削除のストリームレコードには、userIdentity.type: "Service"principalId: "dynamodb.amazonaws.com"が含まれます。

Lambda関数でTTL削除イベントのみを処理する実装例を示します。

import { DynamoDBStreamEvent, DynamoDBRecord } from 'aws-lambda';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({});

export async function handler(event: DynamoDBStreamEvent): Promise<void> {
  const ttlDeletedItems = event.Records.filter(isTTLDeletion);

  if (ttlDeletedItems.length === 0) {
    console.log("TTL削除イベントが含まれていません");
    return;
  }

  // TTL削除されたアイテムをS3にアーカイブ
  for (const record of ttlDeletedItems) {
    await archiveToS3(record);
  }
}

function isTTLDeletion(record: DynamoDBRecord): boolean {
  return record.eventName === 'REMOVE' &&
         record.userIdentity?.type === 'Service' &&
         record.userIdentity?.principalId === 'dynamodb.amazonaws.com';
}

async function archiveToS3(record: DynamoDBRecord): Promise<void> {
  const deletedItem = record.dynamodb?.OldImage;
  if (!deletedItem) return;

  const key = `archives/${new Date().toISOString()}/${record.dynamodb?.Keys?.id?.S}.json`;

  await s3.send(new PutObjectCommand({
    Bucket: 'my-archive-bucket',
    Key: key,
    Body: JSON.stringify(deletedItem),
    ContentType: 'application/json'
  }));
}

イベントフィルタリングによる効率化

Lambda関数の無駄な起動を防ぐため、イベントソースマッピングでフィルタリングを設定することが推奨されます。以下は、CloudFormationでの設定例です。

AWSTemplateFormatVersion: '2010-09-09'
Resources:
  TTLProcessorFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: ttl-processor
      Runtime: nodejs20.x
      Handler: index.handler
      Code:
        ZipFile: |
          // Lambda関数のコード

  StreamEventMapping:
    Type: AWS::Lambda::EventSourceMapping
    Properties:
      EventSourceArn: !GetAtt MyTable.StreamArn
      FunctionName: !Ref TTLProcessorFunction
      StartingPosition: LATEST
      FilterCriteria:
        Filters:
          - Pattern: |
              {
                "userIdentity": {
                  "type": ["Service"],
                  "principalId": ["dynamodb.amazonaws.com"]
                }
              }

このフィルタリング設定により、TTL削除以外のイベントでLambda関数が起動されることを防ぎ、実行コストを削減できます。

実装における正しいCloudFormation定義

既存の多くの記事やサンプルコードで見られる間違いとして、TTL属性をAttributeDefinitionsに定義するという誤解があります。AttributeDefinitionsは「キー属性やインデックス属性の定義」のためのものであり、TTL属性は含めません。

正しいCloudFormation定義の例を示します。

AWSTemplateFormatVersion: '2010-09-09'
Resources:
  MyTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      TableName: session-table
      AttributeDefinitions:
        - AttributeName: sessionId
          AttributeType: S
        - AttributeName: userId
          AttributeType: S
        # TTL属性(expiresAt)はここに定義しない
      KeySchema:
        - AttributeName: sessionId
          KeyType: HASH
      GlobalSecondaryIndexes:
        - IndexName: user-index
          KeySchema:
            - AttributeName: userId
              KeyType: HASH
          Projection:
            ProjectionType: ALL
      TimeToLiveSpecification:
        AttributeName: expiresAt  # 大文字小文字を厳密に区別
        Enabled: true
      StreamSpecification:
        StreamViewType: OLD_IMAGE  # TTL削除時の旧データを含める

セキュリティとプライバシーの考慮事項

診断ログへのTTL値露出問題

TTL値は診断ログに平文で記録されるという仕様は、セキュリティ上の重要な考慮事項です。例えば、TTL属性に機密情報を含むタイムスタンプ(特定のイベント発生時刻など)を使用している場合、それが診断ログから漏洩する可能性があります。

この問題への対処方法として、以下のような設計パターンを推奨します。

// BAD: 機密性の高い情報を含むTTL値
const sensitiveExpiryTime = calculateExpiryBasedOnUserContract(userId);
await putItem({
  id: itemId,
  contractExpiry: sensitiveExpiryTime,  // TTL属性として使用(非推奠)
  data: userData
});

// GOOD: 汎用的な有効期限のみをTTL属性に使用
await putItem({
  id: itemId,
  expiresAt: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60),  // 30日後
  actualContractExpiry: encrypt(sensitiveExpiryTime),  // 機密情報は別属性で暗号化
  data: userData
});

バックアップとコンプライアンス

TTLで削除されたデータも、PITR(Point-in-time Recovery)やオンデマンドバックアップには含まれる点は、コンプライアンス観点で重要です。GDPRなどのデータ保護規制で「忘れられる権利」への対応が必要な場合、TTLだけでは不十分です。

完全削除が必要なケースでの実装パターンを示します。

class ComplianceDeletionService {
  private dynamoClient: DynamoDBDocumentClient;
  private backupClient: BackupClient;

  async performCompleteDeletion(itemId: string): Promise<void> {
    // 1. DynamoDBから削除
    await this.deleteFromDynamoDB(itemId);

    // 2. 既存のバックアップをリスト化
    const backups = await this.listBackups();

    // 3. 各バックアップから該当データを除外した新しいバックアップを作成
    for (const backup of backups) {
      await this.createFilteredBackup(backup, itemId);
    }

    // 4. 監査ログに記録
    await this.logComplianceDeletion(itemId);
  }

  private async deleteFromDynamoDB(itemId: string): Promise<void> {
    await this.dynamoClient.send(new DeleteCommand({
      TableName: 'my-table',
      Key: { id: itemId }
    }));
  }

  // 他のメソッドの実装...
}

運用監視とオブザーバビリティの確立

CloudWatchメトリクスの活用

CloudWatchメトリクスTimeToLiveDeletedItemCountを使用して、TTL削除の状況を監視することが重要です。このメトリクスから、システムの健全性や異常を検知できます。

監視ダッシュボードの設定例(CloudFormation)を示します。

TTLMonitoringDashboard:
  Type: AWS::CloudWatch::Dashboard
  Properties:
    DashboardName: DynamoDB-TTL-Monitor
    DashboardBody: !Sub |
      {
        "widgets": [
          {
            "type": "metric",
            "properties": {
              "metrics": [
                [ "AWS/DynamoDB", "TimeToLiveDeletedItemCount",
                  { "stat": "Sum", "label": "TTL削除件数" } ],
                [ ".", "UserErrors",
                  { "stat": "Sum", "label": "ユーザーエラー" } ],
                [ ".", "ConsumedReadCapacityUnits",
                  { "stat": "Sum", "label": "消費読み取り容量" } ]
              ],
              "period": 300,
              "stat": "Sum",
              "region": "${AWS::Region}",
              "title": "TTL削除状況",
              "yAxis": {
                "left": {
                  "min": 0
                }
              }
            }
          }
        ]
      }

異常検知のためのアラート設定

TTL削除が想定通りに動作していることを確認するため、以下のようなアラートを設定することを推奨します。

TTLDeletionAnomalyAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    AlarmName: DynamoDB-TTL-Deletion-Anomaly
    AlarmDescription: TTL削除数が異常に少ない場合のアラート
    MetricName: TimeToLiveDeletedItemCount
    Namespace: AWS/DynamoDB
    Statistic: Sum
    Period: 3600
    EvaluationPeriods: 2
    Threshold: 100
    ComparisonOperator: LessThanThreshold
    TreatMissingData: breaching

このアラートは、1時間あたりのTTL削除数が100件を下回った場合に発火します。TTL機能が正常に動作していない可能性を早期に検知できます。

実践的なユースケースと設計パターン

セッション管理システムの実装

Webアプリケーションのセッション管理は、TTLの典型的なユースケースです。ただし、前述の制約を考慮した実装が必要です。

class SessionManager {
  private readonly SESSION_DURATION = 3600; // 1時間
  private readonly GRACE_PERIOD = 86400;    // 1日(TTL用の猶予期間)

  async createSession(userId: string, deviceId: string): Promise<string> {
    const sessionId = generateSessionId();
    const now = Math.floor(Date.now() / 1000);

    await ddb.send(new PutCommand({
      TableName: 'sessions',
      Item: {
        sessionId,
        userId,
        deviceId,
        createdAt: now,
        lastAccessedAt: now,
        validUntil: now + this.SESSION_DURATION,
        expiresAt: now + this.SESSION_DURATION + this.GRACE_PERIOD,
        isActive: true
      }
    }));

    return sessionId;
  }

  async validateSession(sessionId: string): Promise<boolean> {
    const now = Math.floor(Date.now() / 1000);

    const result = await ddb.send(new GetCommand({
      TableName: 'sessions',
      Key: { sessionId }
    }));

    if (!result.Item) {
      return false;
    }

    // アプリケーション側での有効期限チェック
    if (result.Item.validUntil < now) {
      // 期限切れセッションを明示的に無効化
      await this.invalidateSession(sessionId);
      return false;
    }

    // セッションのアクセス時刻を更新
    await this.touchSession(sessionId);
    return true;
  }
}

イベントソーシングパターンへの応用

イベントソーシングアーキテクチャでは、イベントストアとしてDynamoDBを使用し、古いイベントをTTLで自動アーカイブする設計が有効です。

interface Event {
  aggregateId: string;
  eventId: string;
  eventType: string;
  eventData: any;
  eventTime: number;
  expiresAt?: number;
}

class EventStore {
  private readonly RETENTION_DAYS = 90;

  async appendEvent(event: Omit<Event, 'expiresAt'>): Promise<void> {
    const ttlEnabled = this.shouldEnableTTL(event.eventType);

    const item: Event = {
      ...event,
      expiresAt: ttlEnabled
        ? event.eventTime + (this.RETENTION_DAYS * 24 * 60 * 60)
        : undefined
    };

    await ddb.send(new PutCommand({
      TableName: 'events',
      Item: item,
      ConditionExpression: 'attribute_not_exists(eventId)'
    }));
  }
}
Careerバナーconsultingバナー