OpenTofu 核心架构解析:从 CLI 命令到图执行的完整请求链路
【免费下载链接】opentofuOpenTofu lets you declaratively manage your cloud infrastructure.项目地址: https://gitcode.com/gh_mirrors/op/opentofu
本篇技术指南以 OpenTofu 官方架构文档(docs/architecture.md)为骨架,系统讲解 OpenTofu Core 的主要组件,以及一次用户命令(如tofu plan、tofu apply)在数据与请求层面如何在各组件间流转。阅读完本文,你将掌握从 CLI 入口、Backend、配置加载器、状态管理器,到图构建器、图遍历与顶点求值的完整执行链路,并了解count等动态展开场景背后的子图机制,为深入阅读internal/源码打下基础。
一次命令的完整请求流(OpenTofu Request Flow)
OpenTofu 的架构文档用一张总览图(即上文配图)概括了一次用户命令在 Core 中的执行近似流程。图中每个实线方框代表一个独立子系统,均在下文有对应章节展开。
一个关键前提是:这条完整链路并不适用于所有命令,它主要适用于 OpenTofu 的核心工作流命令tofu plan、tofu apply,以及少数其他命令。诸如tofu version、tofu fmt之类的命令走的是更轻量的路径。
整条链路可以概括为四个阶段:
- CLI 解析阶段:命令实现读取并解析命令行参数、选项与环境变量,构造出一个描述"要做什么"的
backend.Operation对象; - Backend 调度阶段:Operation 被交给当前选中的 backend,由具备执行能力的 backend(
Enhanced)真正执行该操作,或由localbackend 包装后在本地进程内执行; - Core 执行阶段:
localbackend 用状态管理器取回当前状态、用配置加载器加载配置,构造tofu.Context,并调用Plan/Apply等方法; - 图驱动阶段:
tofu.Context使用图构建器构造依赖图,再通过图遍历器按"必须在其后发生"的依赖边顺序求值每个顶点。
CLI 层:command包与命令分发
每次用户运行tofu程序时,除根包中少量初始化引导逻辑(图中未展示)外,执行会立即转入internal/command包中的某个"命令实现"。
用户面对的命令名(如plan、apply)与command包中对应类型的映射关系,记录在 cmd/tofu/commands.go(main包)中。该文件同时承担了更底层的引导职责:commandMain会创建工作目录对象(workdir.NewWorkdirExplicit,处理-chdir与TF_DATA_DIR)、构造 provider 源(providerSource)、建立 CLI 配置(cliconfig.Config)、服务发现(disco.Disco)等,最终通过command.RootCommander与commandToCli将命令树组装为 urfave/cli 的命令结构并运行。
对于plan/apply这类工作流命令,命令实现的核心职责是:
- 读取并解析所需的命令行参数、选项与环境变量;
- 用它们产出一个描述"待执行动作"的
backend.Operation对象。
一个operation由以下要素构成:
| 要素 | 说明 |
|---|---|
| 动作类型(Type) | 要执行的动作,如 "plan"、"apply"(见 internal/backend/operation_type.go) |
| Workspace | 动作将在其中执行的 workspace 名称 |
| Variables | 该动作使用的根模块输入变量 |
| ConfigDir | 对于 plan 操作:包含配置根模块的目录路径 |
| PlanFile | 对于 apply 操作:要应用的已保存计划文件 |
| 其他选项 | Targets(-target地址)、Excludes、ForceReplace、AutoApprove("force" 标志)、PlanMode、PlanOutPath等 |
这个 Operation 随后被传递给当前选中的 backend。每个 backend 名称都对应 internal/backend/init/init.go 中backend.Init初始化的一张映射表里backend.Backend接口的一个实现。
Backend 层:状态存储与操作执行的分工
backend 决定了 OpenTofu 将状态快照存储在哪里。但需要特别强调的是文档中的一条重要澄清:backend 执行操作只是架构实现细节,而不是 backend 的通用特性。作为 OpenTofu 特性术语,"backend" 指的是决定状态快照存放位置的插件——在 internal/backend/init/init.go 中可以看到当前硬编码注册的全部 backend:
backends = map[string]backend.InitFn{ "local": func(enc encryption.StateEncryption) backend.Backend { return backendLocal.New(enc) }, "remote": func(enc encryption.StateEncryption) backend.Backend { return backendRemote.New(services, enc) }, // Remote State backends. "azurerm": ... backendAzure.New(enc), "consul": ... backendConsul.New(enc), "cos": ... backendCos.New(enc), "gcs": ... backendGCS.New(enc), "http": ... backendHTTP.New(enc), "inmem": ... backendInmem.New(enc), "kubernetes": ... backendKubernetes.New(enc), "oss": ... backendOSS.New(enc), "pg": ... backendPg.New(enc), "s3": ... backendS3.New(enc), // Terraform Cloud 'backend'(cloud 包内部实现细节) "cloud": func(enc encryption.StateEncryption) backend.Backend { return backendCloud.New(services, enc) }, }其中只有默认的local、remote和cloud三个 backend 会执行操作;其余都是纯远程状态存储 backend。backend/init还维护了RemovedBackends表,记录了artifactory、etcd、etcdv3、manta、swift等已被移除的旧后端名称及提示信息。由于 backend 的 API 依赖复杂结构,通过插件系统支持不现实,因此 backend 是硬编码进 OpenTofu 的(源码注释明确说明:想要自定义 backend 只能通过重新编译实现)。
接口层面(见 internal/backend/backend.go):
backend.Backend:最小接口,包含ConfigSchema(返回配置结构描述)、PrepareConfig(校验配置值并插入缺省值)、Configure(一次性设置后端字段)、StateMgr(按 workspace 返回状态管理器)、DeleteWorkspace、Workspaces;backend.Enhanced:在Backend之上增加Operation(执行 refresh/plan/apply 等操作,非阻塞,通过返回的RunningOperation阻塞等待完成,并负责为运行期间使用的状态加锁)和ServiceDiscoveryAliases;backend.Local:增加LocalRun,为console、import、graph等需要直接访问配置、变量等更多数据的命令提供支持。
大多数 backend 并不实现Enhanced接口,因此command包会把这些 backend 包装进一个local.Local实例,使操作在tofu进程内部本地执行。这就是为什么你可以在不配置任何远程后端的情况下直接运行tofu plan——所有远程状态 backend 的操作最终都借道localbackend 完成。
localbackend:本地执行的枢纽
localbackend 在执行操作时依次完成以下工作:
- 使用一个状态管理器(state manager)取回操作所指定 workspace 的当前状态。如果直接使用 local backend,则该管理器是
statemgr.Filesystem;如果包装了其他 backend,则使用该 backend 提供的实现; - 使用配置加载器加载操作中指定的配置,并完成初始处理/校验;
- 结合上述两者及 Operation 中的其他设置,构造
tofu.Context——真正执行 OpenTofu 操作的核心对象; - 在 Context 上调用相应方法开始执行,例如
Context.Plan或Context.Apply,这些方法随后会使用图构建器构造依赖图。
localbackend 的实现在 internal/backend/local/ 目录下,其中backend_plan.go、backend_apply.go、backend_refresh.go分别对应 plan、apply、refresh 三类操作的编排逻辑。
配置加载器(Configuration Loader)
顶层配置结构由 internal/configs 包中的模型类型表示:整个配置(根模块加上所有后代模块)用一个configs.Config对象表示。
configs包包含一些构造配置对象的底层功能,但主要入口在子包 internal/configs/configload 的configload.Loader中。加载器负责处理所有子模块安装的细节(在tofu init期间),并在 backend 加载配置时再次定位这些模块。它接收根模块的路径,递归加载所有子模块,最终产出一个代表整个配置的configs.Config。
OpenTofu 期望配置文件使用 OpenTofu 语言编写,这是一种构建在 HCL 之上的 DSL。配置中有一部分内容在构建并遍历图之前无法解释,因为它们依赖配置中其他部分的结果,因此这部分配置会保留为低层的 HCL 类型(hcl.Body与hcl.Expression),允许 OpenTofu 在更合适的时机(即顶点求值阶段)再解释它们——这正是后文"表达式求值"一节存在的原因。
状态管理器(State Manager)
状态管理器负责为特定 workspace 存储和检索 OpenTofu 状态快照。每个管理器都是 internal/states/statemgr 包中接口的某种组合的实现,绝大多数实用的管理器都实现了statemgr.Full描述的全部操作,且由某个backend提供。那些较小的接口主要存在于其他函数签名中,用于显式说明该函数可能对状态管理器执行哪些动作;从架构角度看,几乎没有理由编写一个不实现全部statemgr.Full的状态管理器。
- 默认实现
statemgr.Filesystem由localbackend 使用,负责大多数用户熟悉的本地文件terraform.tfstate(OpenTofu 用户在切换到远程状态前通常从它开始); - 其他
statemgr.Full实现用于实现远程状态,各自通过所属 backend 对应的远程网络服务保存和检索状态。
状态管理器以states.State对象的形式接受和返回状态快照。对象如何被序列化和存储完全由状态管理器决定,不过在写作本文时,所有状态管理器都使用相同的 JSON 序列化格式,将序列化后的 JSON 字节存入某种任意的 blob 存储中。
值得一提的配套设施是states.SyncState(见 internal/states/sync.go):由于图遍历会并发求值多个顶点,共享的states.State对象需要并发保护,绝大多数情况下代码使用SyncState这个辅助包装器来安全地实现共享状态的并发读写。
图构建器(Graph Builder)
图构建器由tofu.Context的方法(如Plan或Apply)调用,用于产出表示该操作所需步骤及步骤间依赖关系的图。
在大多数情况下,OpenTofu 图的**顶点(vertices)**各自代表配置中的某个具体对象,或由这些配置对象派生出的东西。例如,配置中的每个resource块,在 "plan" 图中都有一个对应的GraphNodeConfigResource顶点。(注意:OpenTofu Core 的术语使用并不完全一致,在很多地方把图的vertices也称为nodes,二者描述的是同一个概念。)
图中的**边(edges)**表示"必须在其后发生"(happens after)的关系,定义了顶点被求值的先后顺序,确保例如某个资源在其依赖的资源之后才被创建。
每个操作都有自己的图构建器,因为图构建过程各不相同:
- "plan" 操作需要直接从配置构建图;
- "apply" 操作则从被应用的计划所描述的一组变更中构建图。
所有图构建器都基于一系列transforms工作,它们是tofu.GraphTransformer接口的实现(该接口只有一个方法:Transform(context.Context, *Graph) error,接收一个图并任意修改它)。由于实现只需"接收图并按其需要修改",可用的 transform 种类非常丰富,几个重要示例:
| Transform | 作用 |
|---|---|
ConfigTransformer | 为配置中的每个resource块创建一个图顶点 |
StateTransformer | 为状态中当前跟踪的每个资源实例创建一个图顶点 |
ReferenceTransformer | 分析配置,找出资源与其他对象之间的依赖关系,并为这些依赖创建必要的 "happens after" 边 |
ProviderTransformer | 将每个资源/资源实例与恰好一个 provider 配置关联(实现 provider 继承规则),并创建 "happens after" 边,确保 provider 在其所属资源被操作前完成初始化 |
还有更多不同的图 transform,可通过阅读不同图构建器的源码发现;每个图构建器根据所执行操作的需求使用其中不同的子集。
图构建的产物是一个tofu.Graph,它可以交给图遍历器进一步处理。
图遍历(Graph Walk)
遍历图的过程会以一种尊重图中 "happens after" 边的方式访问每个顶点。遍历算法本身实现在底层 internal/dag 包中("DAG" 是Directed Acyclic Graph有向无环图的缩写),具体是AcyclicGraph.Walk。
不过,OpenTofu 层面"有意思"的遍历功能实现在tofu.ContextGraphWalker中,它在图遍历期间实现了一小组更高级的操作:
EnterPath:对配置中的每个模块调用一次,接收一个模块地址,返回一个在该模块内跟踪对象的tofu.EvalContext。tofu.Context是整个操作的全局上下文,而tofu.EvalContext是单个模块内处理的上下文,也是各模块命名空间保持隔离的主要手段。
每个顶点都被求值,求值顺序保证尊重 "happens after" 边。如果可能,图遍历算法会并发求值多个顶点,因此顶点求值代码必须谨慎使用 mutex 等并发原语来协调对共享对象(如states.State)的访问。
顶点求值(Vertex Evaluation)
图遍历期间对每个顶点采取的动作称为execution(执行)。执行会运行一系列对该顶点类型有意义的具体动作。
例如,plan 操作中对代表某个资源实例的顶点求值,包含如下高层步骤:
- 从
EvalContext中取回该资源关联的 provider。由于资源节点与 provider 节点之间存在 "happens after" 边,provider 此时应已由它自己的图顶点提前初始化完成; - 从状态中取回与当前被求值的资源实例相关的部分;
- 求值配置中为该资源给出的属性表达式。这通常涉及取回其他资源实例的状态,以便将它们的值复制或转换为当前实例的属性——这一协调由
EvalContext完成; - 将当前实例状态与资源配置一起交给 provider,要求 provider 产出表示状态与配置差异的instance diff(实例差异);
- 将该 instance diff 保存为本次操作正在构造的计划的一部分。
Execute 与 EvalContext
顶点的每个执行步骤都是tofu.GraphNodeExecutable接口的实现,该接口只有一个Execute(context.Context, EvalContext, walkOperation) tfdiags.Diagnostics方法。与图 transform 类似,这些实现的行为差异很大:图 transform 可以对图采取任何动作,而Execute实现可以对EvalContext采取任何动作。
实际处理(区别于测试)中使用的tofu.EvalContext实现是tofu.BuiltinEvalContext,它通过EvalContext接口方法提供对插件、当前状态和当前计划的协调访问。
典型的Execute实现包括:
NodePlannableResourceInstance.Execute——处理 plan 操作;NodeApplyableResourceInstance.Execute——处理主 apply 操作;NodeDestroyResourceInstance.Execute——处理主 destroy 操作。
一个顶点必须成功完成后,图遍历才会开始对具有 "happens after" 边的其他顶点求值。求值可能因一个或多个错误而失败,此时图遍历被中止,错误返回给用户。
表达式求值(Expression Evaluation)
对大多数顶点类型而言,顶点求值的一个重要部分是求值与该顶点关联的配置块中的任何表达式——这补齐了配置加载器未处理的那部分配置(即前文提到的保留为低层 HCL 类型的部分)。
表达式求值的高层过程为:
- 分析依赖:分析配置表达式,确定它们引用哪些其他对象。例如
aws_instance.example[1]引用配置中resource "aws_instance" "example"块创建的某个实例。该分析由lang.References执行,或更常使用其辅助包装器lang.ReferencesInBlock/lang.ReferencesInExpr; - 取回被引用对象的数据:从状态中取回被引用对象的数据,创建 HCL 求值代码可引用的值查找表;
- 准备内置函数表:准备内置函数表,供 HCL 求值引用;
- 执行 HCL 求值:让 HCL 对每个属性的表达式(
hcl.Expression对象)对照数据和函数查找表进行求值。
实际中,步骤 2 至 4 通常用lang.Scope上的方法一次性完成,最常用的是lang.EvalBlock或lang.EvalExpr。
表达式求值产生一个以cty.Value表示的动态值。这个 Go 类型代表 OpenTofu 语言中的值,此类值最终会被传递给 provider 插件。
子图(Sub-graphs):count的动态展开机制
某些顶点在求值步骤完成后还有一个特殊行为:顶点实现会获得机会构建另一个独立的图,该图将作为该顶点求值的一部分被遍历。
最典型的例子是resource块设置了count参数的情况。此时,plan 图最初为每个resource块只包含一个顶点,但该图随后会动态展开,产生一个包含count所请求的每个实例各自顶点的子图。也就是说,aws_instance.example的子图可能包含aws_instance.example[0]、aws_instance.example[1]等顶点。这是必要的,因为count参数可能引用其他对象,而这些对象的值在构造主图时尚未可知,但在主图中其他顶点求值期间会变为已知。
这个特殊行为适用于实现了tofu.GraphNodeDynamicExpandable接口的顶点对象(该接口只有一个DynamicExpand方法,返回将被视为接收节点动态子图的新图)。这样的顶点拥有自己嵌套的图构建器、图遍历和顶点求值步骤,行为与主图的对应章节描述相同;区别仅在于:构造子图时使用了哪些图 transform,以及子图中节点适用哪些求值步骤。
总结:用一张时序图串联全文
将全文内容串联起来,一次tofu plan的典型旅程是:
tofu plan │ (cmd/tofu 引导 + command 包解析参数) ▼ command 构造 backend.Operation(动作=plan、workspace、变量、ConfigDir…) │ ▼ 当前 backend 被包装为 local.Local(除非本身是 local/remote/cloud) │ ▼ local backend:statemgr 取回状态 + configload 加载配置 │ ▼ 构造 tofu.Context → 调用 Context.Plan │ ▼ 图构建器(ConfigTransformer/StateTransformer/ReferenceTransformer/ProviderTransformer…) ▼ tofu.Graph → dag.AcyclicGraph.Walk 并发遍历(ContextGraphWalker / EvalContext) ▼ 每个顶点 GraphNodeExecutable.Execute → 表达式求值(lang.Scope)→ provider 产出 instance diff ▼ (cout 资源动态展开子图 → 对每个实例求值) ▼ 生成 plans.Plan 返回给命令层展示这套架构的最大价值在于模块间清晰的职责边界:CLI 只负责把用户意图翻译成 Operation;backend 只负责状态存储与操作编排;图构建器负责把配置/状态/引用关系折叠成可执行的有向无环图;遍历器与求值器负责并发、安全地把每个顶点翻译成对 provider 的实际调用。理解这条链路后,再阅读internal/tofu下context_plan.go、context_apply.go、transform.go、execute.go以及internal/dag、internal/lang等源码,就能按图索骥、事半功倍。
【免费下载链接】opentofuOpenTofu lets you declaratively manage your cloud infrastructure.项目地址: https://gitcode.com/gh_mirrors/op/opentofu
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考