规划与应用:资源实例变更的完整生命周期
前置知识
- ›第 1 篇:架构与代码库导航
- ›第 2 篇:图引擎与 DAG 遍历
- ›作为用户对 Terraform plan/apply 两阶段工作流的基本理解
规划与应用:资源实例变更的完整生命周期
第 1 篇中,我们梳理了从 main() 到 Context.Plan() 的调用路径。第 2 篇中,我们深入解析了实现并行执行的图引擎。本篇将跟踪单个资源实例走完完整的生命周期:从 Context.Plan() 被调用的那一刻起,经过图的构建与遍历、provider 的 PlanResourceChange 调用,最终进入 Context.Apply(),让变更真正落地到实际基础设施中。
本篇涵盖支撑两阶段工作流的核心抽象:作为调度中枢的 Context、图节点用于所有外部交互的 EvalContext 接口、连接这两个世界的 ContextGraphWalker,以及实现实时进度上报的 Hook 系统。
terraform.Context:调度中枢
位于 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() 只做最基础的初始化:复制 hooks、添加内部 stopHook、校验并发度(默认值为 10),并将 provider/provisioner 工厂封装为 contextPlugins。真正的重量级工作都延迟到各操作方法中执行。
parallelSem 信号量值得关注。当 par == 0 时,默认取值 10——这是一个有意为之的设计,目的是同时限制 CPU 压力和 provider API 的限流风险。该信号量在图遍历的回调内部获取,无论 Walker 创建了多少 goroutine,它都能控制同时执行的顶点数量上限。
Plan 阶段: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") 获取运行锁,确保同一时刻只有一个操作在执行,随后依次完成:
- 校验配置依赖和状态依赖
- 检查外部 provider 配置
- 校验 plan 模式(Normal、Destroy、RefreshOnly)
- 处理用于重构的
moved和removed块 - 构建并遍历 plan 图
- 收集最终生成的
Changes
第 32–163 行的 PlanOpts 结构体控制着 plan 的具体行为:
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,plan 阶段可能会执行多轮,将那些count或for_each暂时无法求值的资源推迟处理。这正是"延迟变更"功能背后的实现机制。
EvalContext 与 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,但图节点永远不会直接接触它——所有编程都面向接口进行,这也使得使用 mock 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 会为每个顶点启动 goroutine,因此并发安全至关重要。
节点执行:规划一个资源实例
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)
provider 返回的 PlanResourceChange 响应包含规划后的新状态——其中计算属性可能包含未知值——以及可选的属性列表,标明哪些属性需要替换资源而非原地更新。
Apply 阶段:ApplyAndEval()
internal/terraform/context_apply.go#L81-L84 的 Context.Apply() 委托给 ApplyAndEval()(第 95–150+ 行):
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
}
apply 阶段与 plan 阶段有几个关键区别:
- 输入是
*plans.Plan,而非生成它 - 校验 plan 是否可应用(无错误、变更一致)
- 从 plan 的变更集构建
ApplyGraphBuilder NodeApplyableResourceInstance调用provider.ApplyResourceChange()而非PlanResourceChange()- 状态通过
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 goroutine 监听 StopContext,当取消信号到来时,调用所有活跃 provider 的 provider.Stop()。这正是 Ctrl-C 能够优雅中断正在执行的 apply 的实现原理。
Hook 系统:观察执行过程
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,一旦某个 hook 返回 HookActionHalt 便立即停止。
提示:
HookAction返回值意味着 hook 可以取消操作。如果某个 hook 返回HookActionHalt,当前节点的操作会立即终止。stopHook正是利用这一机制实现了Context.Stop()——当收到停止请求时,该 hook 会对所有后续回调返回HookActionHalt。
Changes 与 State:累积模式
在 plan 遍历期间,变更会累积到 internal/plans/changes.go#L22-L41 的 plans.Changes 结构体中:
type Changes struct {
Resources []*ResourceInstanceChange
Queries []*QueryInstance
ActionInvocations ActionInvocationInstances
Outputs []*OutputChange
}
在 apply 遍历期间,状态更新则通过 SyncState 实时写入。每个 NodeApplyableResourceInstance 将 ApplyResourceChange() 的结果直接写入状态,这意味着状态会随着遍历的推进持续更新。如果 apply 中途失败,状态会精确反映哪些资源已成功修改——这份部分状态对于故障恢复至关重要。
下一篇预告
我们已经看清了资源如何经历 plan 和 apply 两个阶段。但到目前为止,我们一直把 provider 当作黑盒——一个接受 PlanResourceChange() 调用并返回规划状态的存在。第 4 篇将打开这个黑盒,深入探索 provider 插件系统:provider 如何被发现、以独立 OS 进程启动、通过 gRPC 建立连接,以及三层抽象(providers.Interface → GRPCProvider → protobuf)如何在 Terraform 的类型系统与线路格式之间完成转换。