ステート管理とバックエンド:永続化、ロック、マイグレーション
前提知識
- ›第1回:アーキテクチャとコードベースのナビゲーション
- ›Terraformステートの基本的な理解(terraform.tfstate、リモートバックエンド)
ステート管理とバックエンド:永続化、ロック、マイグレーション
Terraformのステートは、設定ファイルで宣言した意図と、実際に存在するインフラとをつなぐ橋渡し役です。ステートがなければ、どのリソースがどの設定ブロックに対応するかをTerraformは知る術がなく、ドリフトの検出も、最小限の変更計画も不可能になります。それだけにステートサブシステムはTerraformの中でも特に重要な位置を占めており、そのアーキテクチャには慎重な層構造が反映されています。
本記事では、3層ステートモデル、ステートの保存先を決定するバックエンドの抽象化、そして terraform init 実行時にバックエンドを切り替える際のマイグレーションフローを詳しく見ていきます。
インメモリのステートモデル
internal/states/state.go#L27-L52 にある State 構造体が、インメモリ表現のトップレベルです。
type State struct {
Modules map[string]*Module
RootOutputValues map[string]*OutputValue
CheckResults *CheckResults
}
Modules マップのキーはモジュールインスタンスのパス(module.network や module.network[0] など)で、ルートモジュールは常に含まれます。各 Module はリソースのマップを持ち、各リソースはさらにインスタンスのマップを持ちます(count や for_each に対応するためです)。
classDiagram
class State {
+Modules map[string]*Module
+RootOutputValues map[string]*OutputValue
+CheckResults *CheckResults
+Empty() bool
+Module(addr) *Module
}
class Module {
+Addr ModuleInstance
+Resources map[string]*Resource
+OutputValues map[string]*OutputValue
}
class Resource {
+Addr AbsResource
+Instances map[InstanceKey]*ResourceInstance
+ProviderConfig AbsProviderConfig
}
class ResourceInstance {
+Current *ResourceInstanceObjectSrc
+Deposed map[DeposedKey]*ResourceInstanceObjectSrc
}
State --> Module : contains
Module --> Resource : contains
Resource --> ResourceInstance : contains
ResourceInstance の Deposed マップは特筆に値します。create_before_destroy によってリソースを置き換える必要が生じた場合、Terraformはまず新しいインスタンスを作成し、古いインスタンスを「デポーズ (deposed)」スロットに移動した上で、新しいインスタンスが正常に作成されてから古いものを削除します。作成と削除の間にapplyが失敗した場合、デポーズされたインスタンスはランダムな DeposedKey で追跡されたままステート内に残り、次回の成功したapplyでクリーンアップされます。
ヒント: ステート内に "deposed" オブジェクトが残っている場合、それは途中で中断された
create_before_destroyの痕跡です。terraform applyを再実行すると、それらを削除するプランが作成されます。
SyncState:グラフウォーク中のスレッドセーフなアクセス
第3回で見たように、グラフウォークは頂点を並列に実行します。生の State 型は明示的に並行アクセス非対応です。internal/states/sync.go#L36-L40 にある SyncState ラッパーがこの問題を解決します。
type SyncState struct {
state *State
writable bool
lock sync.RWMutex
}
classDiagram
class SyncState {
-state *State
-writable bool
-lock sync.RWMutex
+Module(addr) *Module
+SetResourceInstanceCurrent(addr, obj, provider)
+RemoveResourceInstanceDeposed(addr, key)
+OutputValue(addr) *OutputValue
+Lock() / Unlock()
}
class State {
+Modules map
+RootOutputValues map
}
SyncState --> State : wraps
読み取りメソッドはすべて RLock() を取得し、要求されたデータのディープコピーを返します。これは重要な安全策です。もしグラフノードが実際のステートデータへの参照を受け取り、ロックなしで変更を加えれば、データ競合は避けられません。コピーを返すことで SyncState はノードがローカルビューを自由に操作でき、他の並行ノードに影響を与えないことを保証しています。
書き込みメソッドは完全な Lock() を取得し、下位のステートを直接変更します。writable フィールドは追加の安全チェックとして機能します。planウォーク中、「前回実行ステート」と「リフレッシュステート」のビューは読み取り専用ですが、「計画済みステート」のビューは書き込み可能です。
State Manager:永続化とロック
インメモリの State はどこかに永続化する必要があります。ローカルファイル、S3バケット、ConsulのKVストアなど、保存先はさまざまです。statemgr パッケージはそのための階層的なインターフェースを定義しています。
internal/states/statemgr/filesystem.go#L29-L64 にある Filesystem state managerがローカル実装です。
type Filesystem struct {
mu sync.Mutex
path string
readPath string
backupPath string
stateFileOut *os.File
lockID string
created bool
file *statefile.File
readFile *statefile.File
backupFile *statefile.File
writtenBackup bool
}
これは Full インターフェースを実装しており、Reader・Writer・Refresher・Persister・Locker を組み合わせたものです。この分離には意味があります。Reader/Writer は一時的なインメモリコピーを扱い、Refresher はディスクから読み込み、Persister はディスクへ書き込みます。Locker インターフェースは複数のTerraformプロセスによる同時アクセスを防ぐための Lock()/Unlock() を追加します。
classDiagram
class Reader {
<<interface>>
+State() *State
}
class Writer {
<<interface>>
+WriteState(*State) error
}
class Refresher {
<<interface>>
+RefreshState() error
}
class Persister {
<<interface>>
+PersistState(schemas) error
}
class Locker {
<<interface>>
+Lock(LockInfo) (string, error)
+Unlock(string) error
}
class Full {
<<interface>>
}
Reader <|-- Full
Writer <|-- Full
Refresher <|-- Full
Persister <|-- Full
Locker <|-- Full
Full <|.. Filesystem
ローカルバックエンドのロックはOSレベルのファイルロックを使用します。S3などのリモートバックエンドではDynamoDBテーブルを使うこともあります。この抽象化により、Terraform Coreはロックの仕組みを知る必要がありません。
バックエンドインターフェースの階層
バックエンドの抽象化は internal/backend/backend.go#L44-L106 で定義されています。
type Backend interface {
ConfigSchema() *configschema.Block
PrepareConfig(cty.Value) (cty.Value, tfdiags.Diagnostics)
Configure(cty.Value) tfdiags.Diagnostics
StateMgr(workspace string) (statemgr.Full, tfdiags.Diagnostics)
DeleteWorkspace(name string, force bool) tfdiags.Diagnostics
Workspaces() ([]string, tfdiags.Diagnostics)
}
これはベースインターフェースで、すべてのバックエンドはステートの保存と取得の方法を知っている必要があります。しかし internal/backend/backendrun/operation.go#L38-L53 にはさらに強力な第2のインターフェースが定義されています。
type OperationsBackend interface {
backend.Backend
Operation(context.Context, *Operation) (*RunningOperation, error)
ServiceDiscoveryAliases() ([]HostAlias, error)
}
この区別はアーキテクチャ上、非常に重要です。OperationsBackend を実装しているバックエンドは 2つだけです。local と cloud(HCP Terraform)です。S3・GCS・AzureRM・Consulなど他のすべてのバックエンドは Backend のみを実装します。これらのバックエンドを使用する場合、Terraformは local.Local でラップすることで Operation() メソッドを提供し、ステートをリモートに保存しながらplan/applyはローカルで実行します。
flowchart TD
subgraph "OperationsBackend"
Local["local backend<br/>(runs operations locally)"]
Cloud["cloud backend<br/>(runs operations remotely)"]
end
subgraph "Backend only (state storage)"
S3["s3"]
GCS["gcs"]
Azure["azurerm"]
Consul["consul"]
Others["pg, http, cos, oss,<br/>kubernetes, oci, inmem"]
end
S3 -->|"wrapped in"| Local
GCS -->|"wrapped in"| Local
Azure -->|"wrapped in"| Local
Consul -->|"wrapped in"| Local
Others -->|"wrapped in"| Local
組み込みバックエンドと登録
すべてのバックエンドは internal/backend/init/init.go#L52-L76 にハードコードされています。
backends = map[string]backend.InitFn{
"local": func() backend.Backend { return backendLocal.New() },
"remote": func() backend.Backend { return backendRemote.New(services) },
"azurerm": func() backend.Backend { return backendAzure.New() },
"consul": func() backend.Backend { return backendConsul.New() },
"cos": func() backend.Backend { return backendCos.New() },
"gcs": func() backend.Backend { return backendGCS.New() },
"http": func() backend.Backend { return backendHTTP.New() },
"inmem": func() backend.Backend { return backendInmem.New() },
"kubernetes": func() backend.Backend { return backendKubernetes.New() },
"oss": func() backend.Backend { return backendOSS.New() },
"pg": func() backend.Backend { return backendPg.New() },
"s3": func() backend.Backend { return backendS3.New() },
"oci": func() backend.Backend { return backendOCI.New() },
"cloud": func() backend.Backend { return backendCloud.New(services) },
}
このマップの上のコメント(33〜43行目)には、バックエンドがプラグイン化できない理由が説明されています。
バックエンドはTerraformにハードコードされています。バックエンドのAPIは複雑な構造体を使用しており、プラグインシステム越しにそれをサポートするのは現状著しく困難なためです。カスタムバックエンドを実装したい場合は、ソースコードを変更して再コンパイルする形で対応できます。
これは合理的な判断です。バックエンドは statemgr.Full・configschema.Block・tfdiags.Diagnostics といった内部型に直接アクセスする必要があります。これらをプラグインの境界を越えて公開するには、プロバイダプロトコルと同等の複雑さが必要であり、バックエンドの種類がほとんど変わらないことを考えると、その恩恵は限定的です。
Init() 関数は起動時に main.go#L212 で一度だけ呼び出され、グローバルな backends マップを初期化します。また、廃止されたバックエンド(artifactory・etcd・swift など)のための RemovedBackends マップも用意されており、init実行時に分かりやすいエラーメッセージを表示します。
バックエンドの初期化とステートマイグレーション
terraform init を実行すると、最も複雑な処理のひとつがバックエンドの設定です。フローは InitCommand から始まり、command/meta_backend.go のバックエンド処理コードに委譲されます。
初期化では次のシナリオが処理されます。
- 初回セットアップ — バックエンドが未設定の場合
- 同じバックエンド、設定変更 — S3バケットを切り替えるなど
- 別のバックエンドへ移行 — ローカルからS3へ移行するなど
- バックエンドの削除 — ローカルストレージに戻す場合
sequenceDiagram
participant User
participant Init as InitCommand
participant Meta as meta_backend
participant OldBE as Old Backend
participant NewBE as New Backend
User->>Init: terraform init
Init->>Meta: BackendForPlan / configureBackend
Meta->>Meta: Detect backend change
alt Backend changed
Meta->>OldBE: StateMgr("default")
OldBE-->>Meta: old state manager
Meta->>OldBE: RefreshState()
Meta->>NewBE: Configure(newConfig)
Meta->>NewBE: StateMgr("default")
NewBE-->>Meta: new state manager
Meta->>User: "Do you want to migrate state?"
User-->>Meta: "yes"
Meta->>NewBE: WriteState(oldState)
Meta->>NewBE: PersistState()
end
Meta-->>Init: configured backend
ステートマイグレーションはデフォルトで対話的に進みます。バックエンド間でステートを移動する前に、Terraformはユーザーに確認を求めます。自動化環境(TF_IN_AUTOMATION=1)では、-input=false と事前設定済みの変数を使ってこれらのプロンプトに応答できます。
ヒント: バックエンドのマイグレーション中、Terraformは古いバックエンドと新しいバックエンドの両方にロックをかけ、マイグレーション中の同時アクセスを防ぎます。ロックが取得できない場合、データを失わない形でマイグレーションは安全に中止されます。
次回予告
ステートとバックエンドは永続化レイヤーです。次の第6回は、CLIという表示レイヤーに焦点を当てます。共有Metaを中心にコマンドがどう構成されているか、ビューレイヤーがレンダリングロジックとビジネスロジックをどう分離しているか、そして診断システムが従来のGoエラーハンドリングをどのようにしてリッチでソース情報付きのメッセージに置き換えているかを詳しく解説します。