TerraformでDynamoDBテーブルを宣言的に定義する設計|GSI・TTL・ストリームの設定変更を安全に差分適用する

宮崎智広 この記事の監修:宮崎智広(Linux実務・教育歴20年以上・受講者3,100名超)
HOMELinux技術 リナックスマスター.JP(Linuxマスター.JP)Terraform > TerraformでDynamoDBテーブルを宣言的に定義する設計|GSI・TTL・ストリームの設定変更を安全に差分適用する
「TerraformでDynamoDBにGSIを追加しようとしたら、planにforces replacementが出た」「dynamodb ttl terraformで後からTTLを有効化したい。テーブルが再作成されないか心配だ」

aws_dynamodb_tableリソースは、変更内容によって「インプレース更新(テーブルはそのまま)」と「リソース置換(テーブル再作成=データ消失)」の2パターンに分かれる。どちらになるかをterraform planで読み取れないまま apply すると、本番データを失うリスクがある。

この記事では、TerraformでDynamoDBテーブルを宣言的に定義する方法を解説する。GSI追加・TTL有効化・Streams設定変更がterraform planでどう差分に出るか、そしてリソース置換を招かない変更順序を実践例で示す。
動作確認環境: Terraform v1.9 / AWS Provider v5.x

この記事のポイント

・name/hash_key/range_key変更はforces replacement(テーブル再作成・データ消失)
・GSI追加・TTL有効化・Streams変更はインプレース更新で通る
・GSIのhash_key/range_keyを変え直すときは2ステップapplyで置換を回避する
・terraform planの「forces replacement」を必ず確認してからapplyする


「このままじゃマズい」と感じていませんか?
参考書を開く気力もない、同年代に取り残される不安——
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

TerraformでDynamoDBを管理するときに最初に知るべきこと

TerraformのDynamoDB管理で最初につまずくのが「どの属性変更がテーブル再作成を引き起こすか」だ。aws_dynamodb_tableリソースの属性は変更時の挙動で大きく3種類に分類できる。

属性の種類 変更時の挙動 代表的な属性
キースキーマ forces replacement(テーブル再作成) name、hash_key、range_key
インデックス・機能設定 インプレース更新 global_secondary_index、ttl、stream_enabled
キャパシティ設定 インプレース更新 billing_mode、read_capacity、write_capacity
「キースキーマ」(name、hash_key、range_key)はテーブルの骨格であり、変更できない。ここを変えるとTerraformは既存テーブルを削除してから新しいテーブルを作り直す。本番データが入ったテーブルでこれを踏むと取り返しがつかない。

一方、GSI・TTL・Streamsはテーブルを消さずに変更できる。ただし変更の順序や組み合わせを間違えるとエラーになるため、planの差分を正しく読む習慣が必要だ。

aws_dynamodb_tableリソースの基本定義

1. テーブルの基本属性とキースキーマを書く

基本的なaws_dynamodb_tableリソースの定義は以下のようになる。

resource "aws_dynamodb_table" "orders" { name = "orders" billing_mode = "PAY_PER_REQUEST" hash_key = "order_id" range_key = "created_at" attribute { name = "order_id" type = "S" } attribute { name = "created_at" type = "S" } tags = { Environment = "production" } }

`attribute` ブロックに定義するのは、hash_key・range_key・GSIのキーとして使う属性だけでよい。DynamoDBはスキーマレスなので、テーブルに保存するすべての属性を列挙する必要はない。`attribute` ブロックで定義した `name` と `hash_key`(または `range_key`)の値が一致していないと、terraform applyでValidationExceptionが発生する。

2. プロビジョニングモードとオンデマンドを切り替える

`billing_mode` は `PAY_PER_REQUEST`(オンデマンド)と `PROVISIONED`(プロビジョニング)の2択だ。プロビジョニングを使う場合は `read_capacity` と `write_capacity` を追加する。

resource "aws_dynamodb_table" "orders" { name = "orders" billing_mode = "PROVISIONED" read_capacity = 5 write_capacity = 5 hash_key = "order_id" attribute { name = "order_id" type = "S" } }

`billing_mode` の変更(PAY_PER_REQUEST ↔ PROVISIONED)はインプレース更新で通る。テーブルは再作成されない。

GSI(グローバルセカンダリインデックス)のplan差分と安全な追加手順

1. GSIをHCLで定義する

GSIは `global_secondary_index` ブロックで定義する。GSIのキーとして使う属性は、テーブルの `attribute` ブロックにも追加が必要だ。

resource "aws_dynamodb_table" "orders" { name = "orders" billing_mode = "PAY_PER_REQUEST" hash_key = "order_id" range_key = "created_at" attribute { name = "order_id" type = "S" } attribute { name = "created_at" type = "S" } # GSIで使う属性を追加 attribute { name = "status" type = "S" } global_secondary_index { name = "GSI-ByStatus" hash_key = "status" range_key = "created_at" projection_type = "ALL" } }

`projection_type` は `ALL`(全属性)、`KEYS_ONLY`(キーのみ)、`INCLUDE`(指定属性のみ)の3択。`INCLUDE` を選ぶ場合は `non_key_attributes` に属性名のリストを追加する。

2. terraform planでGSI追加差分を読む

既存テーブルにGSIを追加するときのplan出力例は以下のようになる。

# aws_dynamodb_table.orders will be updated in-place ~ resource "aws_dynamodb_table" "orders" { id = "orders" name = "orders" + global_secondary_index { + hash_key = "status" + name = "GSI-ByStatus" + non_key_attributes = [] + projection_type = "ALL" + range_key = "created_at" + read_capacity = 0 + write_capacity = 0 } } Plan: 0 to add, 1 to change, 0 to destroy.

「will be updated in-place」と表示されればテーブルは再作成されない。`0 to destroy` であることも必ず確認する。GSI追加のapplyはDynamoDBのバックグラウンド処理が走るため、テーブルが「ACTIVE」に戻るまで数分かかることがある。その間、テーブルへの読み書き自体は続けられる。

3. GSI変更でリソース置換が起きるケース

DynamoDBはGSIを作成した後にそのキースキーマを変更できない。そのため、既存GSIの `hash_key` や `range_key` を変更しようとすると、Terraformは既存GSIを削除してから新しいGSIを作成する。テーブル自体は再作成されないが、1回のapplyで「古いGSI削除+新しいGSI追加」を同時に行おうとすると問題が起きやすい。

DynamoDBはテーブルあたりのGSIをデフォルトで最大20個まで制限している。Terraformが新しいGSIの追加を古いGSIの削除より先に試みると、一時的に上限に達してエラーになることがある。また、同名のGSIを一度削除してから同名で再作成する場合も、削除完了を待たずに作成しようとするとResourceInUseExceptionが発生する。

安全な変更手順は2ステップに分けることだ。

・ステップ1: 古いGSI定義をHCLから削除してapply(GSIがAWS上から削除される)
・ステップ2: 新しいGSI定義をHCLに追加してapply(新しいGSIが作成される)

dynamodb ttl terraformでTTLを有効化するときのplan差分

1. TTLをHCLで定義する

TTL(Time to Live)は `ttl` ブロックで定義する。`attribute_name` にTTL期限を格納する属性名を指定し、`enabled = true` にする。

resource "aws_dynamodb_table" "orders" { name = "orders" billing_mode = "PAY_PER_REQUEST" hash_key = "order_id" attribute { name = "order_id" type = "S" } ttl { enabled = true attribute_name = "expires_at" } }

`expires_at` はUnixエポック秒(Number型)で期限を格納するアプリ側の属性だ。dynamodb ttl terraformの設定でいちばん混乱しやすいのが「TTLを有効化しても、expires_at属性を持たないレコードは削除されない」という点だ。TTLを有効化するだけではデータは減らない。アプリ側で `expires_at` に値をセットして初めてTTL削除の対象になる。

2. TTL有効化のplan差分

TTLが無効の既存テーブルに対してTTLを有効化するplan出力は以下のようになる。

# aws_dynamodb_table.orders will be updated in-place ~ resource "aws_dynamodb_table" "orders" { id = "orders" ~ ttl { ~ enabled = false -> true ~ attribute_name = "" -> "expires_at" } } Plan: 0 to add, 1 to change, 0 to destroy.

インプレース更新で通り、テーブルは再作成されない。TTLの有効化は即時反映されるが、期限切れレコードが実際に削除されるのはDynamoDBのバックグラウンド処理に依存するため、期限を過ぎてから48時間程度かかることがある。

3. TTLを後から無効化するときの注意

`enabled = false` でTTLを無効化することもインプレース更新で通る。ただし、一度TTLで削除されたレコードは復元できない。TTL無効化より前にアプリ側で `expires_at` のセットを止めるほうが安全だ。

「TTLの設定はアプリチームが直接AWSコンソールで変更する」という運用ルールがある場合は、`lifecycle` ブロックで差分を無視する選択肢もある。

lifecycle { ignore_changes = [ttl] }

ただし `ignore_changes` を使うと、意図しない変更もTerraformが検知しなくなる。Terraformで一元管理できる場合は使わないほうがよい。

DynamoDB Streamsの設定変更とplan差分

1. Streamsをterraformで定義する

DynamoDB Streamsは `stream_enabled` と `stream_view_type` で定義する。

resource "aws_dynamodb_table" "orders" { name = "orders" billing_mode = "PAY_PER_REQUEST" hash_key = "order_id" attribute { name = "order_id" type = "S" } stream_enabled = true stream_view_type = "NEW_AND_OLD_IMAGES" }

`stream_view_type` は以下の4種類から選ぶ。

・`KEYS_ONLY`: 変更されたアイテムのキー属性のみ
・`NEW_IMAGE`: 変更後のアイテム全体
・`OLD_IMAGE`: 変更前のアイテム全体
・`NEW_AND_OLD_IMAGES`: 変更前後の両方(最も情報量が多い)

Lambdaのイベントソースマッピングでストリームを使う場合は、`aws_lambda_event_source_mapping` も合わせて定義し、`event_source_arn` に `aws_dynamodb_table.orders.stream_arn` を参照させる。

2. stream_view_typeを変更したときのplan差分

ストリームを無効から有効に変更するplan差分は以下のようになる。

# aws_dynamodb_table.orders will be updated in-place ~ resource "aws_dynamodb_table" "orders" { id = "orders" ~ stream_enabled = false -> true ~ stream_view_type = "" -> "NEW_AND_OLD_IMAGES" + stream_arn = (known after apply) } Plan: 0 to add, 1 to change, 0 to destroy.

インプレース更新で通る。`stream_view_type` を後から変更する場合(例: `NEW_IMAGE` → `NEW_AND_OLD_IMAGES`)も同様だ。`stream_arn` はストリーム有効化後に払い出され、Lambdaの `event_source_arn` に使える状態になる。

リソース置換(forces replacement)を招かない変更順序の設計

1. forces replacementのサインをplanで見分ける

terraform planの出力で `must be replaced` や `forces replacement` の文字列が出たとき、そのリソースは一度削除されてから再作成される。DynamoDBテーブルの場合、これはデータ消失を意味する。

# aws_dynamodb_table.orders must be replaced -/+ resource "aws_dynamodb_table" "orders" { ~ hash_key = "order_id" -> "user_id" # forces replacement ~ name = "orders" -> "orders_v2" # forces replacement } Plan: 1 to add, 0 to change, 1 to destroy.

`-/+` のシンボルが「削除してから追加(=置換)」を意味する。`+` だけなら追加のみ、`~` だけならインプレース更新だ。apply前に必ずこのシンボルと `to destroy` の件数を確認する。

2. prevent_destroyで誤削除を防止する

本番テーブルには `lifecycle` の `prevent_destroy = true` を設定しておくと、万が一 `terraform destroy` や誤った変更でテーブルが削除されようとしたときにエラーで止まる。

resource "aws_dynamodb_table" "orders" { name = "orders" billing_mode = "PAY_PER_REQUEST" hash_key = "order_id" attribute { name = "order_id" type = "S" } lifecycle { prevent_destroy = true } }

`prevent_destroy = true` の状態で forces replacement を含む apply を実行するとエラーになる。「planでforces replacementを見落としてapplyを実行した」という事故を防ぐ最後の安全網として有効だ。

3. 2ステップapplyで安全に変更する

本番テーブルで名前の変更など、やむを得ずリソース置換が必要な場合は以下の手順で進める。

・ステップ1: 新しいテーブルを別名でHCLに追加してapply(既存テーブルはそのまま残る)
・ステップ2: アプリ側の書き込み先を新テーブルに切り替える
・ステップ3: 旧テーブルのデータをDynamoDB StreamsかAWS Data Migration Serviceで移行する
・ステップ4: 移行確認後、旧テーブルのHCL定義を削除してapply

Terraformのリソースラベルを変えるだけ(HCL上の名前を `aws_dynamodb_table.orders` から `aws_dynamodb_table.orders_prod` に変更する)なら `moved` ブロックが使える。`moved` はstateの参照名を変えるだけなので、DynamoDBのテーブル名(`name` 属性)は変わらず、テーブル再作成も起きない。

# HCL上のリソースラベルを変更する場合 moved { from = aws_dynamodb_table.orders to = aws_dynamodb_table.orders_prod }

よくあるエラーと対処

「ValidationException: Number of attributes in KeySchema does not exactly match number of attributes defined in AttributeDefinitions」

GSIのキーとして使っている属性を `attribute` ブロックに定義していないときに発生する。GSIで使う `hash_key` と `range_key` はすべて `attribute` ブロックに追加すること。

「Error: creating DynamoDB Table: ResourceInUseException: Table already exists」

terraform import せずに既存テーブルと同名のリソースをapplyしようとしたときに発生する。`terraform import aws_dynamodb_table.orders orders` でstateに取り込んでからapplyする。

「LimitExceededException: Only 20 GSIs per table are allowed」

1回のapplyで古いGSIを削除しながら新しいGSIを追加しようとしたとき、削除より追加が先に試みられてGSIの上限に引っかかる。前述の2ステップapply(削除→追加)で対応する。

「Error: deleting DynamoDB Table: ResourceNotFoundException」

stateファイルにはリソースが残っているが、AWSコンソール側でテーブルが手動削除された場合に発生する。`terraform state rm aws_dynamodb_table.orders` でstateから除外するか、`terraform import` でstateを再同期する。

本記事のまとめ

TerraformでDynamoDBテーブルを安全に変更するポイントをまとめる。

やりたいこと 挙動
name/hash_key/range_keyを変更する forces replacement(テーブル再作成・データ消失)
GSIを追加する インプレース更新(テーブルはそのまま)
既存GSIのhash_keyを変更する GSI削除→再作成(2ステップapply推奨)
dynamodb ttl terraformでTTLを有効化する インプレース更新
stream_enabled = trueにする インプレース更新
billing_modeを切り替える インプレース更新
terraform planの出力で `forces replacement` が出たら必ず立ち止まること。`-/+` シンボルと `to destroy` の件数が「本当に意図した変更か」を確認してからapplyする。本番テーブルには `prevent_destroy = true` を設定しておくと、意図しない削除に対する最後の安全網になる。
現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、TerraformでDynamoDB・GSI・TTL・Streamsを安全に差分適用するスキルを、セミナーで習得できます。
>> Terraform実践セミナーの詳細はこちら

無料メルマガで学習を続ける

Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。

登録無料・いつでも解除できます

暗記不要・1時間後にはサーバーが動く

3,100名以上が実践した「型」を無料で公開中

プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。

姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

Linux無料マニュアル(図解60P) 名前とメールで30秒登録
宮崎 智広

この記事を書いた人

宮崎 智広(みやざき ともひろ)

株式会社イーネットマーキュリー代表。現役のLinuxサーバー管理者として20年以上の実務経験を持ち、これまでに累計3,100名以上のエンジニアを指導してきたLinux教育のプロフェッショナル。「現場で本当に使える技術」を体系的に伝えることをモットーに、実践型のLinuxセミナーの開催や無料マニュアルの配布を通じてLinux人材の育成に取り組んでいる。

趣味は、キャンプにカメラ、トラウト釣り。好きな食べ物は、ラーメンにお酒。休肝日が作れない、酒量を減らせないのが悩み。最近、ドラマ「フライトエンジェル」を観て涙腺が崩壊しました。