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)'
}));
}
}