Read OSS

プランニングとアプライ: リソースインスタンスの変更ライフサイクル

上級

前提知識

  • 第1回: アーキテクチャとコードベースのナビゲーション
  • 第2回: グラフエンジンと DAG ウォーク
  • ユーザーとして Terraform の plan/apply 二段階ワークフローを理解していること

プランニングとアプライ: リソースインスタンスの変更ライフサイクル

第1回では main() から Context.Plan() に至るパスをたどりました。第2回では並列実行を可能にするグラフエンジンを解剖しました。今回は、単一のリソースインスタンスが完全なライフサイクルをどのように経るかを追います。Context.Plan() が呼ばれる瞬間から始まり、グラフの構築とウォーク、プロバイダーの PlanResourceChange 呼び出し、そして変更が実際のインフラに反映される Context.Apply() まで、一気に見ていきましょう。

この記事では、二段階ワークフローを支える中心的な抽象化について解説します。具体的には、Context オーケストレーター、グラフノードが外部とのやり取りに使う EvalContext インターフェース、この2つの世界をつなぐ ContextGraphWalker、そしてリアルタイムな進行状況の報告を可能にする Hook システムです。

terraform.Context: The Orchestrator

internal/terraform/context.go#L90-L109 にある Context 構造体は、Terraform の中心に位置する存在です:

type Context struct {
    meta        *ContextMeta
    plugins     *contextPlugins
    hooks       []Hook
    sh          *stopHook
    uiInput     UIInput
    graphOpts   *ContextGraphOpts
    l           sync.Mutex
    parallelSem Semaphore
    providerInputConfig map[string]map[string]cty.Value
    runCond     *sync.Cond
    runContext  context.Context
    runContextCancel context.CancelFunc
}
classDiagram
    class Context {
        -plugins *contextPlugins
        -hooks []Hook
        -parallelSem Semaphore
        +Plan(config, state, opts) (*Plan, Diagnostics)
        +Apply(plan, config, opts) (*State, Diagnostics)
        +Validate(config) Diagnostics
        +Stop()
    }
    class ContextOpts {
        +Hooks []Hook
        +Parallelism int
        +Providers map[Provider]Factory
        +Provisioners map[string]Factory
        +PreloadedProviderSchemas map
    }
    class contextPlugins {
        -providerFactories map
        -provisionerFactories map
        -preloadedProviderSchemas map
    }
    ContextOpts --> Context : NewContext()
    Context --> contextPlugins

120〜166行目NewContext() では最小限のセットアップのみが行われます。Hook をコピーし、内部の stopHook を追加し、並列度を検証(デフォルトは 10)し、プロバイダー/プロビジョナーのファクトリーを contextPlugins にラップします。実際の重い処理は各オペレーションメソッドに委ねられています。

parallelSem セマフォも注目に値します。par == 0 の場合はデフォルト値の 10 が使われますが、これは CPU 負荷とプロバイダー API のレート制限リスクを両方抑えるための意図的な設計です。このセマフォはグラフウォークのコールバック内で取得され、Walker が生成するゴルーチンの数に関わらず、同時に実行されるバーテックス数を制限します。

The Plan Phase: PlanAndEval()

internal/terraform/context_plan.go#L180-L183Context.Plan() を呼び出すと、処理は PlanAndEval() に委譲されます:

func (c *Context) Plan(config *configs.Config, prevRunState *states.State, opts *PlanOpts) (*plans.Plan, tfdiags.Diagnostics) {
    plan, _, diags := c.PlanAndEval(config, prevRunState, opts)
    return plan, diags
}

194〜250行目以降PlanAndEval() が実際のエントリーポイントです。まず実行ロック(acquireRun("plan"))を取得して同時に1つの操作のみが実行されることを保証し、その後以下の処理を行います:

  1. 設定の依存関係と状態の依存関係を検証する
  2. 外部プロバイダーの設定を確認する
  3. プランモード(Normal、Destroy、RefreshOnly)を検証する
  4. リファクタリング用の moved ブロックと removed ブロックを処理する
  5. プラングラフを構築してウォークする
  6. 結果として得られる Changes を収集する

32〜163行目PlanOpts 構造体がプランの動作を制御します:

sequenceDiagram
    participant Caller
    participant Ctx as Context
    participant PGB as PlanGraphBuilder
    participant Walker as ContextGraphWalker
    participant Node as NodePlannableResourceInstance

    Caller->>Ctx: PlanAndEval(config, state, opts)
    Ctx->>Ctx: acquireRun("plan")
    Ctx->>Ctx: checkConfigDependencies
    Ctx->>Ctx: process moved/removed blocks
    Ctx->>PGB: Build(rootModule)
    PGB-->>Ctx: *Graph
    Ctx->>Walker: graph.Walk(walker)
    Walker->>Node: Execute(evalCtx, walkPlan)
    Node->>Node: managedResourceExecute()
    Node-->>Walker: changes recorded
    Walker-->>Ctx: walk complete
    Ctx-->>Caller: *plans.Plan, Diagnostics

ヒント: Terraform はデファードアクションループをサポートしています。PlanOptsDeferralAllowed が設定されている場合、プランフェーズは複数ラウンド実行されることがあり、countfor_each をまだ評価できないリソースは後回しにされます。これが「deferred changes」機能の内部メカニズムです。

EvalContext and ContextGraphWalker

グラフノードの Execute() メソッドが実行されると、EvalContext が渡されます。これはノード自身の外側にあるあらゆるものへのアクセスを提供するインターフェースです。internal/terraform/eval_context.go#L36-L99 で定義されており、主なメソッドは次のとおりです:

type EvalContext interface {
    StopCtx() context.Context
    Path() addrs.ModuleInstance
    Hook(func(Hook) (HookAction, error)) error
    InitProvider(addr, configs) (providers.Interface, error)
    Provider(addrs.AbsProviderConfig) providers.Interface
    ProviderSchema(addrs.AbsProviderConfig) (ProviderSchema, error)
    ConfigureProvider(addrs.AbsProviderConfig, cty.Value) Diagnostics
    // ... state access, changes, evaluation, etc.
}

実際の実装は BuiltinEvalContext ですが、グラフノードがこれを直接扱うことはありません。インターフェースに対してプログラミングされているため、モック eval context を使ったテストが可能になっています。

internal/terraform/graph_walk_context.go#L34-L78ContextGraphWalker は、グラフウォークと Context を橋渡しする役割を担います:

type ContextGraphWalker struct {
    NullGraphWalker
    Context          *Context
    State            *states.SyncState
    RefreshState     *states.SyncState
    PrevRunState     *states.SyncState
    Changes          *plans.ChangesSync
    Checks           *checks.State
    NamedValues      *namedvals.State
    InstanceExpander *instances.Expander
    Deferrals        *deferring.Deferred
    Operation        walkOperation
    // ...
}
classDiagram
    class EvalContext {
        <<interface>>
        +Path() ModuleInstance
        +Hook(cb) error
        +Provider(addr) Interface
        +State() *SyncState
        +Changes() *ChangesSync
    }
    class ContextGraphWalker {
        +Context *Context
        +State *SyncState
        +Changes *ChangesSync
        +Operation walkOperation
        +EvalContext() EvalContext
        +enterScope(scope) EvalContext
    }
    class BuiltinEvalContext {
        -walker *ContextGraphWalker
        -scope evalContextScope
    }
    EvalContext <|.. BuiltinEvalContext
    ContextGraphWalker --> BuiltinEvalContext : creates
    ContextGraphWalker --> Context : references

StateSyncState にラップされ、ChangesChangesSync にラップされている点に注目してください。これらはスレッドセーフなラッパーで、並行して動作するグラフノードが共有データを安全に読み書きできるようにしています。第2回で説明したとおり、Walker はすべてのバーテックスに対してゴルーチンを生成するため、この並行安全性は不可欠です。

Node Execution: Planning a Resource Instance

Walker がリソースのバーテックスに到達すると、internal/terraform/node_resource_plan_instance.go#L73-L89NodePlannableResourceInstance に対して Execute() を呼び出します:

func (n *NodePlannableResourceInstance) Execute(ctx EvalContext, op walkOperation) tfdiags.Diagnostics {
    addr := n.ResourceInstanceAddr()
    switch addr.Resource.Resource.Mode {
    case addrs.ManagedResourceMode:
        return n.managedResourceExecute(ctx)
    case addrs.DataResourceMode:
        return n.dataResourceExecute(ctx)
    case addrs.EphemeralResourceMode:
        return n.ephemeralResourceExecute(ctx)
    case addrs.ListResourceMode:
        return n.listResourceExecute(ctx)
    default:
        panic(fmt.Errorf("unsupported resource mode %s", n.Config.Mode))
    }
}

リソースモードによるディスパッチはシンプルで拡張性も高い設計です。マネージドリソース(managedResourceExecute)の場合、処理の流れは次のようになります:

  1. 前の状態を読み取るSyncState からリソースの現在の状態を取得する
  2. リフレッシュ(スキップされない場合)— provider.ReadResource() を呼び出して最新の実際の状態を取得する
  3. 新しい状態を提案する — リソースの設定を評価して望ましい状態を生成する
  4. 変更を計画する — 前の状態、提案された状態、設定を渡して provider.PlanResourceChange() を呼び出す
  5. 変更を記録するResourceInstanceChangeChangesSync に書き込む
sequenceDiagram
    participant Node as NodePlannableResourceInstance
    participant ECtx as EvalContext
    participant Provider as providers.Interface
    participant State as SyncState
    participant Changes as ChangesSync

    Node->>State: Read prior state
    State-->>Node: priorState
    Node->>Provider: ReadResource(priorState)
    Provider-->>Node: refreshedState
    Node->>Node: Evaluate config → proposedNewState
    Node->>Provider: PlanResourceChange(prior, proposed, config)
    Provider-->>Node: plannedNewState + requiresReplace
    Node->>Changes: AppendResourceInstanceChange(change)

プロバイダーから返される PlanResourceChange のレスポンスには、計画済みの新しい状態(computed 属性に unknown 値が含まれる場合があります)と、インプレースの更新ではなく置き換えが必要な属性のリスト(省略可)が含まれます。

The Apply Phase: ApplyAndEval()

internal/terraform/context_apply.go#L81-L84Context.Apply() は、95〜150行目以降ApplyAndEval() に処理を委譲します:

func (c *Context) Apply(plan *plans.Plan, config *configs.Config, opts *ApplyOpts) (*states.State, tfdiags.Diagnostics) {
    state, _, diags := c.ApplyAndEval(plan, config, opts)
    return state, diags
}

アプライフェーズはプランニングと比べていくつかの重要な違いがあります:

  1. プランを生成するのではなく、*plans.Plan を入力として受け取る
  2. プランが適用可能かどうかを検証する(エラーなし、変更の一貫性確認)
  3. プランの変更セットから ApplyGraphBuilder を構築する
  4. NodeApplyableResourceInstancePlanResourceChange() の代わりに provider.ApplyResourceChange() を呼び出す
  5. 状態は SyncState を通じてウォークに更新される(完了後まとめて更新するのではない)

グラフウォークは internal/terraform/context_walk.go#L86-L99 の同じ Context.walk() インフラを使用します:

func (c *Context) walk(graph *Graph, operation walkOperation, opts *graphWalkOpts) (*ContextGraphWalker, tfdiags.Diagnostics) {
    walker := c.graphWalker(graph, operation, opts)
    watchStop, watchWait := c.watchStop(walker)
    diags := graph.Walk(walker)
    close(watchStop)
    <-watchWait
    // ...
}

watchStop ゴルーチンは StopContext を監視し、キャンセルシグナルが届くとアクティブなすべてのプロバイダーに対して provider.Stop() を呼び出します。実行中の apply に対して Ctrl-C が優雅に割り込めるのは、この仕組みのおかげです。

The Hook System: Observing Execution

internal/terraform/hook.go#L56-L120+Hook インターフェースは約20個のオブザーバーコールバックを提供します:

type Hook interface {
    PreApply(id HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value) (HookAction, error)
    PostApply(id HookResourceIdentity, dk addrs.DeposedKey, newState cty.Value, err error) (HookAction, error)
    PreDiff(id HookResourceIdentity, dk addrs.DeposedKey, priorState, proposedNewState cty.Value, err error) (HookAction, error)
    PostDiff(id HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value, err error) (HookAction, error)
    PreRefresh(...)
    PostRefresh(...)
    PreProvisionInstance(...)
    PostProvisionInstance(...)
    ProvisionOutput(...)
    // ... and more
}
classDiagram
    class Hook {
        <<interface>>
        +PreApply() HookAction
        +PostApply() HookAction
        +PreDiff() HookAction
        +PostDiff() HookAction
        +PreRefresh() HookAction
        +PostRefresh() HookAction
        +PreProvisionInstance() HookAction
        +ProvisionOutput()
    }
    class NilHook {
        +PreApply() HookAction
        +PostApply() HookAction
    }
    class UiHook {
        +PreApply() HookAction
        +PostApply() HookAction
    }
    class JSONHook {
        +PreApply() HookAction
        +PostApply() HookAction
    }
    Hook <|.. NilHook
    NilHook <|-- UiHook
    NilHook <|-- JSONHook

NilHook 基底構造体はすべてのメソッドを何もしない(HookActionContinue を返すだけの)実装として提供します。具体的な Hook は NilHook を埋め込み、必要なメソッドだけをオーバーライドします。これは Go のインターフェースに適用した古典的な「ヌルオブジェクト」パターンです。

Hook は、人間が読みやすい CLI 出力(スピナー、処理時間、リソース数)と、CI/CD 連携で使われる JSON ストリーミング出力の両方を支えています。ノードの実行は EvalContext.Hook() を通じて Hook を呼び出し、登録されたすべての Hook を順番に呼び出して、いずれかが HookActionHalt を返した時点で停止します。

ヒント: HookAction の戻り値により、Hook は操作をキャンセルできます。Hook が HookActionHalt を返すと、現在のノードの操作は即座に停止します。これは stopHookContext.Stop() を実装するために利用しており、停止要求があると Hook はその後のすべてのコールバックで HookActionHalt を返し始めます。

Changes and State: The Accumulation Pattern

プランウォーク中、変更は internal/plans/changes.go#L22-L41plans.Changes 構造体に蓄積されます:

type Changes struct {
    Resources         []*ResourceInstanceChange
    Queries           []*QueryInstance
    ActionInvocations ActionInvocationInstances
    Outputs           []*OutputChange
}

アプライウォーク中は、SyncState を通じて状態の更新がその場で行われます。NodeApplyableResourceInstance はそれぞれ ApplyResourceChange() の結果を直接状態に書き込むため、ウォークが進むにつれて状態は継続的に更新されます。アプライの途中で失敗した場合でも、どのリソースが正常に変更されたかが状態に正確に反映されます。この部分的な状態は、障害からの復旧において非常に重要です。

What's Ahead

プランとアプライフェーズを通じたリソースの流れを追ってきました。しかしここまでは、プロバイダーをブラックボックスとして扱ってきました。つまり PlanResourceChange() 呼び出しを受け取り、計画済みの状態を返すものという扱いです。第4回では、そのボックスを開けてプロバイダープラグインシステムを探ります。プロバイダーがどのように検出され、独立した OS プロセスとして起動され、gRPC で接続されるかを見ていきます。さらに三層の抽象化(providers.InterfaceGRPCProvider → protobuf)が Terraform の型システムとワイヤフォーマットの間でどのように変換するかを解説します。