プランニングとアプライ: リソースインスタンスの変更ライフサイクル
前提知識
- ›第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-L183 の Context.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つの操作のみが実行されることを保証し、その後以下の処理を行います:
- 設定の依存関係と状態の依存関係を検証する
- 外部プロバイダーの設定を確認する
- プランモード(Normal、Destroy、RefreshOnly)を検証する
- リファクタリング用の
movedブロックとremovedブロックを処理する - プラングラフを構築してウォークする
- 結果として得られる
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 はデファードアクションループをサポートしています。
PlanOptsにDeferralAllowedが設定されている場合、プランフェーズは複数ラウンド実行されることがあり、countやfor_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-L78 の ContextGraphWalker は、グラフウォークと 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
State が SyncState にラップされ、Changes が ChangesSync にラップされている点に注目してください。これらはスレッドセーフなラッパーで、並行して動作するグラフノードが共有データを安全に読み書きできるようにしています。第2回で説明したとおり、Walker はすべてのバーテックスに対してゴルーチンを生成するため、この並行安全性は不可欠です。
Node Execution: Planning a Resource Instance
Walker がリソースのバーテックスに到達すると、internal/terraform/node_resource_plan_instance.go#L73-L89 の NodePlannableResourceInstance に対して 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)の場合、処理の流れは次のようになります:
- 前の状態を読み取る —
SyncStateからリソースの現在の状態を取得する - リフレッシュ(スキップされない場合)—
provider.ReadResource()を呼び出して最新の実際の状態を取得する - 新しい状態を提案する — リソースの設定を評価して望ましい状態を生成する
- 変更を計画する — 前の状態、提案された状態、設定を渡して
provider.PlanResourceChange()を呼び出す - 変更を記録する —
ResourceInstanceChangeをChangesSyncに書き込む
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-L84 の Context.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
}
アプライフェーズはプランニングと比べていくつかの重要な違いがあります:
- プランを生成するのではなく、
*plans.Planを入力として受け取る - プランが適用可能かどうかを検証する(エラーなし、変更の一貫性確認)
- プランの変更セットから
ApplyGraphBuilderを構築する NodeApplyableResourceInstanceはPlanResourceChange()の代わりにprovider.ApplyResourceChange()を呼び出す- 状態は
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を返すと、現在のノードの操作は即座に停止します。これはstopHookがContext.Stop()を実装するために利用しており、停止要求があると Hook はその後のすべてのコールバックでHookActionHaltを返し始めます。
Changes and State: The Accumulation Pattern
プランウォーク中、変更は internal/plans/changes.go#L22-L41 の plans.Changes 構造体に蓄積されます:
type Changes struct {
Resources []*ResourceInstanceChange
Queries []*QueryInstance
ActionInvocations ActionInvocationInstances
Outputs []*OutputChange
}
アプライウォーク中は、SyncState を通じて状態の更新がその場で行われます。NodeApplyableResourceInstance はそれぞれ ApplyResourceChange() の結果を直接状態に書き込むため、ウォークが進むにつれて状態は継続的に更新されます。アプライの途中で失敗した場合でも、どのリソースが正常に変更されたかが状態に正確に反映されます。この部分的な状態は、障害からの復旧において非常に重要です。
What's Ahead
プランとアプライフェーズを通じたリソースの流れを追ってきました。しかしここまでは、プロバイダーをブラックボックスとして扱ってきました。つまり PlanResourceChange() 呼び出しを受け取り、計画済みの状態を返すものという扱いです。第4回では、そのボックスを開けてプロバイダープラグインシステムを探ります。プロバイダーがどのように検出され、独立した OS プロセスとして起動され、gRPC で接続されるかを見ていきます。さらに三層の抽象化(providers.Interface → GRPCProvider → protobuf)が Terraform の型システムとワイヤフォーマットの間でどのように変換するかを解説します。