Read OSS

ステート管理とバックエンド:永続化、ロック、マイグレーション

中級

前提知識

  • 第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.networkmodule.network[0] など)で、ルートモジュールは常に含まれます。各 Module はリソースのマップを持ち、各リソースはさらにインスタンスのマップを持ちます(countfor_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

ResourceInstanceDeposed マップは特筆に値します。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 インターフェースを実装しており、ReaderWriterRefresherPersisterLocker を組み合わせたものです。この分離には意味があります。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つだけです。localcloud(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.Fullconfigschema.Blocktfdiags.Diagnostics といった内部型に直接アクセスする必要があります。これらをプラグインの境界を越えて公開するには、プロバイダプロトコルと同等の複雑さが必要であり、バックエンドの種類がほとんど変わらないことを考えると、その恩恵は限定的です。

Init() 関数は起動時に main.go#L212 で一度だけ呼び出され、グローバルな backends マップを初期化します。また、廃止されたバックエンド(artifactoryetcdswift など)のための RemovedBackends マップも用意されており、init実行時に分かりやすいエラーメッセージを表示します。

バックエンドの初期化とステートマイグレーション

terraform init を実行すると、最も複雑な処理のひとつがバックエンドの設定です。フローは InitCommand から始まり、command/meta_backend.go のバックエンド処理コードに委譲されます。

初期化では次のシナリオが処理されます。

  1. 初回セットアップ — バックエンドが未設定の場合
  2. 同じバックエンド、設定変更 — S3バケットを切り替えるなど
  3. 別のバックエンドへ移行 — ローカルからS3へ移行するなど
  4. バックエンドの削除 — ローカルストレージに戻す場合
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エラーハンドリングをどのようにしてリッチでソース情報付きのメッセージに置き換えているかを詳しく解説します。