Read OSS

状态管理与后端:持久化、锁定与迁移

中级

前置知识

  • 第 1 篇:架构与代码库导航
  • 对 Terraform 状态概念的基本了解(terraform.tfstate、远程后端)

状态管理与后端:持久化、锁定与迁移

Terraform 状态是配置的声明式意图与真实基础设施之间的桥梁。没有状态,Terraform 就无法知道哪些真实资源对应哪些配置块,无法检测漂移,也无法规划最小化变更。状态子系统的重要性不言而喻,其架构也通过精心的分层设计体现了这一点。

本文将深入探讨三层状态模型、决定状态存储位置的后端抽象,以及 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

ResourceInstance 上的 Deposed 映射值得特别关注。当 Terraform 需要使用 create_before_destroy 替换某个资源时,它会先创建新实例,将旧实例移入"deposed"槽,然后在新实例成功创建后再销毁旧实例。如果 apply 在创建和销毁之间失败,deposed 实例会以随机 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 键值等。statemgr 包定义了一套分层的接口体系:

internal/states/statemgr/filesystem.go#L29-L64 中的 Filesystem 状态管理器是本地实现:

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 接口则提供 Lock()/Unlock(),用于防止多个 Terraform 进程并发访问。

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

对于本地后端,锁定使用操作系统级别的文件锁;对于 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 中还定义了一个功能更强大的接口:

type OperationsBackend interface {
    backend.Backend
    Operation(context.Context, *Operation) (*RunningOperation, error)
    ServiceDiscoveryAliases() ([]HostAlias, error)
}

这一区分在架构上意义重大。只有两个后端实现了 OperationsBackendlocalcloud(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 等深层内部类型。要在插件边界上暴露这些类型,需要一套与 provider 协议同样复杂的通信协议,而考虑到后端列表变动并不频繁,这样的投入性价比极低。

Init() 函数在启动时调用一次(位于 main.go#L212),用于填充全局 backends 映射。此外还有一个 RemovedBackends 映射,记录已废弃的后端(如 artifactoryetcdswift),在 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 会同时锁定新旧两个状态,以防止迁移过程中的并发访问。如果无法获取锁,迁移会安全失败,不会造成数据丢失。

下一步

状态与后端是持久化层,CLI 则是展示层。第 6 篇将介绍命令如何围绕共享的 Meta 进行组织、views 层如何将渲染逻辑与业务逻辑分离,以及诊断系统如何用带有源码归因的富信息替代 Go 的常规错误处理。