引导与状态后端自举

解决 Terraform 的「先有鸡」引导问题:引导层的边界划分与本地 state 备份、S3 与 GCS 状态后端自举与锁机制、state 桶版本控制与加密、执行角色与权限护栏初始化、私有模块仓库版本锁定,以及从零重建的灾难恢复演练与排错。

1. 先有鸡问题

一句话总结: 用 Terraform 创建存放 state 的桶,而这个桶本身又需要 state——这就是引导(bootstrap)要解决的核心矛盾。

理想状态下 terraform apply 创建 S3 桶来存 state,但第一次 apply 时桶还不存在,无法读写 state。同样的矛盾还出现在:凭据(apply 需要云凭据,而 IAM Role 本身要用 Terraform 建)、模块仓库(配置引用私有模块,而仓库的存储与权限要用 Terraform 建)、锁表(DynamoDB 锁表也需要先存在)、后端加密密钥(KMS key 要在 state 加密之前存在)。

三条解决思路:

思路做法优缺点
本地 state 起步引导层先用本地 state,建完桶后迁到远程简单直接,但本地 state 易丢
独立引导栈引导层永远用本地 state,不进主流程最常用,职责清晰
手工创建手工建桶与锁表,之后全交给 Terraform快但不可复现、易漂移

推荐「独立引导栈」:把「创建 state 存储」本身也写成 Terraform 配置,但它是一个独立的、极少变更的配置,其 state 放在本地或另一个更原始的存储里。

引导层应包含:state 桶 + 版本控制 + 加密 + 生命周期、锁表、
              state 访问所需的 IAM 角色、组织级 OIDC/SSO 角色、模块仓库
引导层不应包含:业务资源(VPC、数据库、应用)、频繁变更的任何东西

一句话: 引导层的目标不是「消灭本地 state」,而是把本地 state 的范围限制到最小且稳定——通常只有几个桶、一张锁表、几个 IAM 角色。

2. 引导层设计

一句话总结: 引导层是一个独立的根模块,通常用本地 state,只在「初始化一个新账号/新环境」时运行一次,之后几乎不再改动。

2.1 目录结构

infra/
├── bootstrap/                # 引导层,本地 state(需备份)
│   ├── main.tf
│   └── terraform.tfstate
└── envs/prod/
    ├── backend.tf            # 指向 bootstrap 建出来的桶
    └── main.tf

2.2 引导层配置

# bootstrap/main.tf
terraform {
  required_version = ">= 1.6"
  # 刻意不配置远程后端:这一步就是要创建远程后端
}

resource "aws_s3_bucket" "state" {
  bucket = "acme-tfstate"
}

resource "aws_s3_bucket_versioning" "state" {
  bucket                   = aws_s3_bucket.state.id
  versioning_configuration { status = "Enabled" }   # state 版本历史是救命稻草
}

resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
  bucket = aws_s3_bucket.state.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.state.arn
    }
    bucket_key_enabled = true
  }
}

resource "aws_s3_bucket_public_access_block" "state" {
  bucket                  = aws_s3_bucket.state.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_dynamodb_table" "lock" {
  name         = "acme-tfstate-lock"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"
  attribute { name = "LockID", type = "S" }
}

2.3 state 桶的额外保护

state 是基础设施的「唯一真相」,一旦损坏或泄露后果严重:

# 旧版本过期(有版本控制时必须配,否则存储无限增长)
resource "aws_s3_bucket_lifecycle_configuration" "state" {
  bucket = aws_s3_bucket.state.id
  rule {
    id     = "expire-old-versions"
    status = "Enabled"
    noncurrent_version_expiration { noncurrent_days = 90 }
  }
}

# 拒绝非加密传输
resource "aws_s3_bucket_policy" "deny_insecure" {
  bucket = aws_s3_bucket.state.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid = "DenyInsecureTransport", Effect = "Deny", Principal = "*"
      Action   = "s3:*"
      Resource = ["${aws_s3_bucket.state.arn}", "${aws_s3_bucket.state.arn}/*"]
      Condition = { Bool = { "aws:SecureTransport" = "false" } }
    }]
  })
}

2.4 引导层的 state 怎么管

三种做法:本地 state + 备份(加密后存进密码管理器或另一个账号的桶,简单但依赖纪律);用第二个更原始的存储(把引导层 state 存在已有账号的桶里,适合多账号);导入式重建(不保存 state,需要时用 terraform import 重新纳管)。多数团队选第一种,配合「引导层极少变更」的纪律。

3. 状态后端配置

一句话总结: 后端配置决定 state 存哪、怎么锁、怎么加密;生产上必须用远程后端 + 版本控制 + 加密,且后端配置要能通过变量注入以复用。

3.1 后端块与部分配置

不要把桶名硬编码进每个环境,用部分配置(partial configuration)注入:

# envs/prod/backend.tf
terraform {
  backend "s3" {
    encrypt = true      # bucket / key / region / dynamodb_table 由 -backend-config 提供
  }
}
terraform init -backend-config=prod.backend.tfvars
# prod.backend.tfvars: bucket / key / region / dynamodb_table

这样同一份代码可在不同环境用不同 state 路径初始化,多环境与工作区 里讨论的隔离策略正是靠这个机制落地。

3.2 各云的后端选型与锁

云后端锁机制
AWSs3DynamoDB 表,或 S3 条件写(1.10+)
GCPgcs内置(对象世代号)
AzureazurermBlob lease
通用remote(Terraform Cloud)平台内置

Terraform 1.10 起 S3 后端支持 use_lockfile = true,用 S3 条件写实现锁,可以不再依赖 DynamoDB:

terraform {
  backend "s3" {
    bucket       = "acme-tfstate"
    key          = "prod/app/terraform.tfstate"
    region       = "ap-northeast-1"
    use_lockfile = true      # 免去 DynamoDB 锁表
    encrypt      = true
  }
}

对新项目,这能省掉一张表与其权限管理。老项目迁移时要确认所有执行者的 Terraform 版本都 ≥ 1.10,否则会出现「一部分人用锁表、一部分人用锁文件」的破锁窗口。

3.3 权限收敛与后端迁移

state 桶的访问权限应当只给需要它的流水线与角色:

data "aws_iam_policy_document" "state_access" {
  statement {
    sid       = "StateBucketAccess"
    actions   = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"]
    resources = [aws_s3_bucket.state.arn, "${aws_s3_bucket.state.arn}/*"]
  }
  statement {
    sid       = "LockAccess"
    actions   = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"]
    resources = [aws_dynamodb_table.lock.arn]
  }
}

state 里可能包含敏感值,所以读 state 的权限等同于读密钥的权限,这与 资源与状态 里讨论的 state 安全一致。

迁移后端(本地 → 远程,或换桶)时先备份再迁移:

cp terraform.tfstate terraform.tfstate.backup-$(date +%F)
terraform init -migrate-state      # Terraform 会问是否复制 state
terraform plan                     # 应无意外差异

4. 账号与权限初始化

一句话总结: 引导层还要负责「让后续的 Terraform 能跑起来」——创建执行角色、配置 OIDC 联邦、建立权限护栏,这些都必须在主流程之前存在。

4.1 执行角色

# 供 CI 使用的执行角色,通过 OIDC 联邦免静态密钥
resource "aws_iam_role" "terraform_ci" {
  name = "terraform-ci"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
      Action    = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
        StringLike   = { "token.actions.githubusercontent.com:sub" = "repo:acme/infra:*" }
      }
    }]
  })
}

4.2 权限护栏与多账号铺开

引导层应建立护栏,防止后续配置越权(例如禁止关闭 S3 的公共访问阻断,用 SCP 或权限边界实现)。护栏放在引导层的好处是:它比业务资源更难被绕过,且变更需要单独审批。

多账号场景下,引导层要按账号分别运行:

management 账号 ──► 组织、SCP、SSO、创建成员账号、配置跨账号 assume role
member 账号     ──► 各自的 bootstrap(由 management 用 assume role 触发)
provider "aws" {
  alias  = "member"
  region = var.region
  assume_role {
    role_arn = "arn:aws:iam::${var.member_account_id}:role/OrganizationAccountAccessRole"
  }
}

module "member_bootstrap" {
  source    = "../modules/state-backend"
  providers = { aws = aws.member }
  bucket    = "acme-tfstate-${var.member_account_id}"
}

5. 模块仓库与版本

一句话总结: 引导层还要解决「模块从哪来」——私有模块仓库、版本控制与访问凭据都必须在主配置引用模块之前就绪。

5.1 模块来源的三种方式

方式写法适用
Git 直接引用source = "git::https://.../modules//vpc?ref=v1.2.0"小团队,起步快
私有 Registrysource = "app.terraform.io/acme/vpc/aws"需要版本语义与文档
S3 / GCS 归档source = "s3::https://.../vpc.zip"需要私有分发

关键纪律:永远用 ref= 锁定版本,不要指向分支——写成 ?ref=main 会让模块一改、所有环境同时受影响。

5.2 私有 Registry 与版本集中管理

若用 Terraform Cloud/Enterprise 或自建 Registry,注册表本身与其凭据要在引导层配置(tfe_team、tfe_registry_module 等资源)。模块的发布流程与语义化版本规范参见 模块注册表发布 。

版本用变量集中管理,便于批量升级且 diff 清晰:

locals {
  module_versions = { vpc = "v1.2.0", eks = "v2.4.1", rds = "v1.0.3" }
}

module "vpc" {
  source = "git::https://github.com/acme/terraform-modules.git//vpc?ref=${local.module_versions.vpc}"
}

6. 灾难恢复重建

一句话总结: 灾难恢复的前提是「能从零重建一切」,而重建的起点就是引导层——必须定期演练,否则出事时才发现某个环节依赖了没人记得的手工操作。

6.1 重建顺序

1. 恢复引导层:建 state 桶 + 锁表,恢复引导层本地 state(从备份)
2. 恢复权限:IAM 角色、OIDC 提供方、SCP、CI 执行角色
3. 恢复模块仓库:私有 Registry / Git 仓库
4. 恢复环境:terraform init -backend-config=... && terraform apply(逐环境)
5. 校验:用漂移检测比对期望与实际

6.2 state 丢失的应对

如果 state 桶被误删(未开版本控制),后果是「Terraform 不认识已有资源」,只能逐个导入:

terraform import aws_vpc.main vpc-0a1b...          # 资源还在,state 没了
terraform import aws_subnet.private[0] subnet-0123...

这正是必须开桶版本控制的原因——有版本历史时,恢复只是找回某个版本的对象:

aws s3api list-object-versions --bucket acme-tfstate --prefix prod/app/terraform.tfstate
aws s3api get-object --bucket acme-tfstate --key prod/app/terraform.tfstate \
  --version-id <vid> restored.tfstate

6.3 定期演练与防丢措施

演练频率:每季度一次
演练内容:在隔离账号从零跑 bootstrap → 用备份 state 恢复一个环境 → 验证 apply 后 plan 干净
产出:一份「实际步骤」文档,替代「想象中的步骤」

防丢措施:引导层 state 备份到至少两个位置(密码管理器 + 另一个账号的桶);代码与文档放在最显眼的位置;记录每个手工步骤的理由,能自动化的尽量自动化;定期用 plan 确认引导层资源未被手工改动。

一句话: 没有演练过的灾难恢复方案等于没有方案;引导层是演练的起点,也是最容易藏着「只有某个人知道的手工步骤」的地方。

7. 实战与排错

一句话总结: 引导与后端的故障集中在「init 失败、锁残留、权限不足、state 路径写错」,排查入口是 terraform init 的输出与后端存储的实际内容。

现象原因处理
Failed to get existing workspaces桶不存在或权限不足确认 bootstrap 已跑、角色有 ListBucket
Error acquiring the state lock上次 apply 异常退出确认无运行中进程后 force-unlock <ID>
Backend configuration changed后端参数变了terraform init -migrate-state
两个环境互相覆盖key 写成了同一个检查各环境的 backend.tfvars
No valid credential sources执行角色未配置检查 OIDC 或 assume role

7.1 锁残留处理

# 先确认没有正在运行的 apply
aws dynamodb get-item --table-name acme-tfstate-lock \
  --key '{"LockID":{"S":"acme-tfstate/prod/app/terraform.tfstate"}}'
terraform force-unlock <LOCK_ID>     # 最后手段

force-unlock 是最后手段——如果确实有另一个 apply 在跑,强制解锁会导致两个进程同时写 state,产生不可预测的损坏。

7.2 init 排错命令

terraform init -reconfigure                                   # 后端参数变化后重新初始化
terraform init -backend-config=prod.backend.tfvars -upgrade
terraform state pull > /tmp/state.json                        # 拉取远程 state 检查内容
terraform state list                                          # 确认资源确实在 state 里

7.3 生产清单

  • 引导层独立于业务配置,用本地 state 并已备份到两处。
  • state 桶开启版本控制、KMS 加密、公共访问阻断、非当前版本过期。
  • 锁机制明确(DynamoDB 或 use_lockfile),全团队版本一致。
  • state 访问权限收敛到具体角色,读 state 视为敏感权限。
  • 执行角色用 OIDC 联邦,无长期 AK/SK。
  • 模块引用全部用 ref= 锁定版本,版本集中管理。
  • 护栏(SCP / 权限边界)放在引导层,变更需单独审批。
  • 每季度演练一次从零重建,并更新实际步骤文档。
  • 引导层资源纳入 远程状态管理 的巡检范围。

一句话收尾: 引导层的价值在于「把不可复现的手工步骤压缩到最小且被代码化」。它是一次性投入、长期受益的工程——真正出事的那天,能不能在一小时内从零重建,取决于今天有没有认真写这个几乎不变更的目录。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「terraform」更多文章

  1. 数据平台基础设施即代码
  2. 从 CloudFormation 迁移到 Terraform
  3. Terraform 与 Ansible 协同