跳转至

Terraform

一句话

Terraform 每次都在做三方比较:代码(想要的样子)↔ state(上次建了什么)↔ 实际资源(现在是什么样)。 所以 state 是核心:要放在安全的地方、要加锁、不能放机密。变更按固定流程走:plan -out → 人确认 → apply 那个 plan 文件 → 再 plan + 用 API 确认实际状态。

本页整理自 Phase 1、Phase 3、Phase 5a、Phase 5b 的实验,以及整个学习过程中形成的作业流程。前面各页里的很多坑(计算的"in-place 也会重启"、网络的"只测了正向"),在这一页从 Terraform 的角度再总结一次。

1. 先认识这些用语

Terraform CloudFormation(AWS) 一句话
Provider (内置) 和某个云的 API 打交道的插件。本仓库用 oracle/oci、hashicorp/aws
Resource Resource 要 Terraform 建、管理的东西(VCN、VM…)
Data source (没有直接对应) 只读已有的信息,不建(例:查最新镜像、查 namespace)
Variable / Output Parameter / Output 输入值 / 输出值
Module Nested Stack 可复用的一组资源。执行 terraform 的那个目录叫 root module
State (CloudFormation 内部保存) "代码里的地址 ↔ 实际资源的 ID"的对应表
Backend — state 存在哪里(本机文件、Object Storage、S3…)
Plan Change Set 预览:要建 / 改 / 删什么
Drift Drift 有人绕过 Terraform 直接改了资源,实际状态和代码不一致
Import Import 把手动建的资源纳入 Terraform 管理
Lock file(.terraform.lock.hcl) — 记录 provider 的确切版本和哈希,让每个人、每次 CI 用同一个版本
Resource Manager 的 Stack / Job Stack / 执行 OCI 托管的 Terraform 执行环境(见第 8 节)

2. 原理:三方比较

flowchart TB
  C["代码<br/>想要的样子"] --> P{"plan"}
  S["state<br/>上次建了什么"] --> P
  R["实际资源<br/>plan 时先读一遍"] --> P
  P --> D["差异 = 要执行的操作<br/>+ / ~ / -/+ / -"]
plan 的符号 意思 注意
+ 新建
~ 原地修改(ID 不变) 不代表不重启(见 计算和负载均衡)
-/+ 删掉重建(# forces replacement,ID 会变) 看到就停下来,确认为什么要重建
- 删除

几个由三方比较推出来的性质:

  • 资源还不存在时,不管怎么改代码,plan 都只显示 +:state 是空的,没有比较对象。~ 和 -/+ 要等资源建好之后才会出现
  • state 丢了,Terraform 就不认识已经建好的资源,会想再建一套
  • ID 每次都是新的:destroy 再 apply,OCID 全部变了。Terraform 管的是"代码地址 ↔ ID"的对应,不是 ID 本身
  • 顺序由引用关系自动决定:

    resource "oci_core_internet_gateway" "igw" {
      vcn_id = oci_core_vcn.main.id   # 引用了 VCN 的属性 → 先建 VCN,删的时候反过来
    }
    

    Phase 1 的 5 个资源实际创建顺序:VCN → IGW 和 Security List(并行,都只依赖 VCN)→ Route Table → Subnet。

3. Drift:有人在 Console 里改了资源

Phase 1 的实验:在 Console 上给 Security List 手动加一条规则,然后 plan:

~ resource "oci_core_security_list" "public" {
    - ingress_security_rules {
        - protocol = "6" -> null
        - source   = "0.0.0.0/0" -> null
      }
  }
Plan: 0 to add, 1 to change, 0 to destroy.

代码里没有这条规则,所以 Terraform 要把它删掉,让实际环境回到代码的样子。处理方法二选一:

方法 做法
撤销手动改动 terraform apply(本次选这个)
把改动收进代码 写进 .tf → plan 变成 No changes(不用 apply)

IaC 的基本原则

变更都通过代码来做,不在 Console 里手动改。手动改了,下次 apply 时会被代码覆盖回去。

还有一种"假 drift":API 保存的值和代码写的不一样,每次 plan 都有差异。本仓库碰到过:DevOps 仓库的 default_branch API 存成 refs/heads/main、Budget 告警规则的 tag API 根本不保存。对策是代码写成 API 实际保存的样子(CI/CD、费用护栏)。

4. state 放在哪里

本机文件的问题

Phase 1〜2 的 state 是本机的 terraform.tfstate:PC 坏了就丢、别人没法操作、两个人同时 apply 没有锁。

remote state:Object Storage + backend "oci"

# 03-modules/env/versions.tf(摘录)
terraform {
  required_version = ">= 1.12.0"   # backend "oci" 从 1.12 开始才有
  backend "oci" {}                 # 值不写在这里,init 时传进来
}
# backend.hcl(不进 Git,提交 backend.hcl.example)
bucket    = "tflab-tfstate"
namespace = "<Object Storage namespace>"
key       = "03-modules/env/terraform.tfstate"
region    = "ap-osaka-1"
terraform init "-backend-config=backend.hcl"
  • backend 块里不能用 variable → 值放在单独的文件里,init 时传进去(叫 partial configuration)
  • 1.12 以前要用 S3 兼容 API + Customer Secret Key;现在直接用 ~/.oci/config 的认证
  • 锁:一个人 apply 时 state 被锁住,另一个人的 apply 会被挡住(Phase 9 实际碰到过:前一个 apply 还在后台跑,又执行了一次 apply → Error acquiring the state lock(412),被挡住的那次什么都没做)

存 state 的 bucket 谁来建?(先有鸡还是先有蛋)

存 state 的 bucket 没法把自己的 state 存进自己。所以单独一个 bootstrap/ 目录,只有它用本机 state,只负责建这个 bucket:

# 03-modules/bootstrap/main.tf(摘录)
resource "oci_objectstorage_bucket" "tfstate" {
  access_type = "NoPublicAccess"   # state 里有 OCID、IP 等信息
  versioning  = "Enabled"          # state 写坏了可以找回旧版本
  lifecycle {
    prevent_destroy = true         # destroy 时报错,防止误删
  }
}

AWS 也是同样的结构

AWS 对照的 10-aws-site/ 用 S3 backend。Terraform 1.10 以后有 use_lockfile = true:锁就是 bucket 里的一个 .tflock 对象,不用再另建 DynamoDB 表。

跨目录读别人的输出

一个目录一份 state。Phase 2 的 VM 要用 Phase 1 建的 subnet,用 terraform_remote_state 读 01 的 output,不手抄 OCID:

# 02-compute/main.tf(摘录)
data "terraform_remote_state" "network" {
  backend = "local"
  config = {
    path = "${path.module}/../01-network/terraform.tfstate"
  }
}
# 用的时候:data.terraform_remote_state.network.outputs.public_subnet_id

建的时候 01 → 02,删的时候 02 → 01。

5. Module:把重复的结构做成零件

03-modules/
├── bootstrap/   存 state 的 bucket(本机 state)
├── modules/     network / compute 零件
└── env/         调用 module,state 放在 bucket 里

module 里不写 provider "oci" {},只写"需要哪个 provider、最低哪个版本";认证、region、具体版本由调用方(root module)决定:

# module 里(modules/network/versions.tf):只写下限
required_providers {
  oci = {
    source  = "oracle/oci"
    version = ">= 9.0"
  }
}
# root module 里(env/versions.tf):决定用哪个系列
#   version = "~> 9.0"   → 9.x 的最新,不会自动升到 10

for_each 和 count

地址 删掉中间一个时
for_each this["app-1"](按 key) 只删那一个
count this[0]、this[1](按序号) 后面的序号全部错位 → 被重建

→ 多个同类资源用 for_each;count 只用在"建 0 个还是 1 个"(例:network module 里的 count = local.create_nat ? 1 : 0,引用时写 this[0])。

计算和负载均衡 的 app VM 就是 for_each 调用 module:app_instances = toset(["app-1", "app-2"]),加一条就多一台。

6. Import:把手动建的资源交给 Terraform

Phase 3 在 Console 手动建了一个 NSG + 规则,然后纳入管理:

  1. 写 import block(指定"哪个 ID 对应代码里的哪个地址")

    import {
      to = oci_core_network_security_group.manual
      id = "<NSG 的 OCID>"
    }
    
  2. terraform plan "-generate-config-out=generated.tf" → 自动生成 resource 代码

  3. 整理生成的代码:OCID 改成变量或引用、删掉 Oracle 自动加的 defined_tags、加上统一的标签
  4. plan(2 to import, 1 to change)→ apply → 再 plan 是 No changes
  5. 删掉 import block

  6. import 不会自动搜索:Terraform 不会去找 OCI 上有什么,要人来指定。想按 compartment 整体导出,用 Resource Manager 的 Resource Discovery 等工具

  7. import ID 的格式每种资源都不一样(NSG 规则是 networkSecurityGroups/<NSG OCID>/securityRules/<规则 ID>),看 provider 文档的 Import 一节
  8. 有数量限制、全局唯一的资源不要 import:OCI 每个 compartment 只能有 1 个 Budget。import 进来的话,destroy 时会把它删掉 → 用 data source 读(费用护栏)

7. 静态检查:apply 之前先让工具看一遍

工具 检查什么
terraform fmt / validate 格式、语法、引用是否成立
tflint 写法:版本约束、没用到的变量、缺 description 等(OCI 没有官方 ruleset → 只用 Terraform 语言本身的规则)
checkov 安全设置:加密、metadata 服务、网络开放范围等

对 Phase 4 代码的检查结果和处理:

工具 指出 处理
tflint module 没有版本约束 module 里只写下限(第 5 节)
checkov 旧版 metadata 服务(IMDS v1)没关 关掉(计算)
checkov boot volume 传输中加密没开 打开
checkov NSG 的规则不是 stateless 不改,写 skip 注释留下理由
resource "oci_core_network_security_group_security_rule" "lb_http_from_my_ip" {
  #checkov:skip=CKV_OCI_21:Low traffic lab; stateful rules are simpler and sufficient
  # ...
}

skip 注释 = 把"知道风险、有意不改"的决定和理由留在代码里,review 时看得到,checkov 的结果里也会显示。

静态检查通过 ≠ 安全

checkov 没有指出:Bastion 的 0.0.0.0/0、ADB 的 public endpoint、LB 只有 HTTP,也没指出 网络设计 里 Security List 放行过宽的问题。 工具只能查"单个属性的值对不对",设计层面的问题还是要靠人 review 和反向测试。

现在这些检查在网站的 CI 里每次 push 都会跑:fmt -check、每个目录的 validate、tflint(见 CI/CD)。

8. Resource Manager:OCI 托管的 Terraform

概念 意思
Stack 代码(zip / Git 等)+ 变量值
Job 对 Stack 执行一次 plan / apply / destroy / import
本机 Terraform Resource Manager
state 自己管(放 bucket) RM 保管,自带锁
认证 API Key 执行 Job 的用户的身份
执行记录 本机终端 Job 日志留在 OCI
其他 Drift 检测、Resource Discovery、Console 表单(schema.yaml)

Phase 5b 的观察:

  • 支持的 Terraform 最高 1.5.x(本机是 1.12+)→ 代码的 required_version 要兼容;stack 里没有 backend 块(state 交给 RM),provider 里不写认证
  • apply Job 可以指定"执行某次 plan Job 存下的计划"(FROM_PLAN_JOB_ID),和本机的 apply xxx.tfplan 是同一个思路
  • Drift 检测:用 API 改了 VCN 名 → Stack 显示 DRIFTED、那个 VCN 是 MODIFIED,能看到 expected / actual 的值。只报告,不修复:再 plan → apply 才改回来

9. 本仓库的作业流程

整个学习过程中,每个坑都让流程多了一步,最后固定成这样:

flowchart TB
  A["① 动手前<br/>读记录、看现状、查费用和参数"] --> B["② 写代码<br/>fmt / validate / tflint / checkov"]
  B --> C["③ plan -out<br/>给人看摘要"]
  C -->|"同意"| D["④ apply 那个 plan 文件"]
  D --> E["⑤ 确认<br/>再 plan = 0 差异<br/>API 查实际值<br/>正向 + 反向测试"]
  E --> F["⑥ 记录<br/>设计理由、坑、结果"]
步骤 要点 来自哪次教训
参数值不靠猜 按顺序查:terraform providers schema -json → SDK 的模型 / 枚举 → 官方文档 猜错的话 apply 到一半才报 400,留下半成品
plan -out → 人确认 摘要写清 add / change / destroy,destroy 和 replace 特别标出
apply 那个 plan 文件 terraform apply x.tfplan:执行的内容和看过的完全一致。用完删掉(里面有机密的真值) 直接 apply 会重新 plan,内容可能和看过的不一样
部分失败 已经建好的不动,先查原因。最常见:IAM 刚建的 Policy 还没生效(depends_on 只保证顺序,不保证生效)→ 等一会儿重新 plan 安全
再 plan terraform plan -detailed-exitcode → exit 0(No changes)
API 查实际值 不只看 plan / apply 的输出 传输中加密:代码 true、apply 成功,再 plan 却出现 false -> true,API 查到确实是 false(计算)
正向 + 反向测试 应该通的通了,应该被拒绝的确实被拒绝 Security List 放行过宽,只测正向没发现(网络)
记录 推测和确认过的事实分开写;不写真实 IP、OCID 等

10. 机密不要进 state

  • random_password 生成的值、直接写进代码的密码,都以明文留在 state 里。sensitive = true 只是 plan 时不显示
  • plan 文件里也有真值 → 用完就删;.gitignore 排除 *.tfstate、*.tfplan、*.tfvars
  • 推荐:让 Vault 自己生成密码(OCI 的 enable_auto_generation),Terraform 只建"框",程序运行时用自己的身份去取。实验验证见 安全

11. 设计决策一览

决策 选了 没选 理由
state 的位置 Object Storage / S3 + 锁 本机文件 不会丢、能共享、有锁、有版本
state bucket 的建法 单独的 bootstrap 目录(本机 state)+ prevent_destroy 和业务资源放一起 先有鸡还是先有蛋;误删的代价太大
backend 的值 backend.hcl(不进 Git)+ partial configuration 写在代码里 backend 块不能用 variable;namespace 等不公开
provider 版本 module 写下限,root 写 ~>,lock file 进 Git 不写约束 每个人、每次 CI 用同一个版本
多个同类资源 for_each count 删中间一个不会让后面的错位重建
手动建的资源 import block + -generate-config-out 删掉重建 / 不管 不中断服务地纳入管理
有数量限制的资源 data source 读 import import 后 destroy 会把它删掉
apply 的方式 apply 保存的 plan 文件 直接 apply 执行内容和人确认过的完全一致
静态检查不改的项 skip 注释写理由 关掉检查 / 不管 决定和理由留在代码里
执行环境 本机 Terraform(主要);RM 体验过 全部用 RM RM 最高只支持 1.5.x,backend "oci" 等新功能用不了

12. 验证

要证明的事 怎么做 结果
代码和实际一致 apply 后再 plan No changes(-detailed-exitcode 是 0)
destroy 真的删干净了 terraform state list 空
Terraform 管的是对应关系,不是 ID destroy → apply OCID 全部变了
remote state 生效 两边先后 apply 第二次 0 changed(读到了第一次的结果)
import 成功 import 后再 plan No changes
drift 被修复 RM 再做一次 drift 检测 全部 IN_SYNC

13. 坑

现象 原因 对策
-backend-config=backend.hcl 被拆成两个参数 PowerShell 5.1 在 . 处拆分参数 整个参数加引号;-out=、-generate-config-out= 也一样
No configuration files 在不是 root module 的目录执行 到 env/、bootstrap/ 里执行
count 的资源删一个,后面的都被重建 按序号管理 用 for_each
改 user_data 后 VM 被重建 / 新镜像发布后要重建 # forces replacement / data source 变了 Ansible 管配置;ignore_changes(计算)
in-place(~)apply 后服务中断 有些属性改了会重启 查文档、一台一台改
同一次 apply 里 Policy 刚建好,用它的资源报 404 IAM 生效有延迟 过一会儿再 apply
每次 plan 都有同一个差异 API 保存的值和代码不同 / 根本不保存 代码写成 API 实际保存的样子,或不写那个属性
NSG 规则的 import ID 在 Console 上看不到 规则 ID 不显示 用 data source oci_core_network_security_group_security_rules 查
checkov 报 UnicodeDecodeError: 'cp932' 用 Windows 的系统编码读文件 $env:PYTHONUTF8 = '1'
tflint --recursive 找不到配置 子目录里没有 .tflint.hcl --config 指定绝对路径
checkov 判定失败,但代码是对的 checkov 只认某一种写法 按 checkov 的写法改,或看它的源码
(注意)Resource Manager 支持的 Terraform 版本有上限(当时最高 1.5.x) 本机用的版本更新 先查版本;required_version 要兼容(本仓库的代码本来就兼容,没出问题)

14. 自测问题

1. 代码还没 apply 过,改了一个会导致重建的属性,plan 会显示 -/+ 吗?

不会,只显示 +。state 是空的,没有比较对象,所以全部是新建。

2. 同事在 Console 上给 Security List 加了一条规则,下次你 apply 会怎样?

那条规则会被删掉,因为代码里没有。要么撤销(apply),要么把规则写进代码(plan 变成 No changes)。

3. 为什么存 state 的 bucket 要单独一个目录、用本机 state?

存 state 的 bucket 没法把自己的 state 存进自己(先有鸡还是先有蛋)。单独的 bootstrap 目录只建这个 bucket,再加 prevent_destroy 防止误删。

4. module 里为什么不写 provider 块,版本也只写下限?

认证、region、具体版本应该由调用方决定。module 写死的话,调用方就没法换 region 或升级版本,多个 module 的约束还可能互相冲突。

5. 3 台 VM 用 count 建的,删掉中间那台会发生什么?

第 3 台的序号从 2 变成 1,Terraform 认为 this[1] 的属性变了、this[2] 不存在了 → 第 3 台被重建。用 for_each 就只删那一台。

6. plan -out 之后为什么一定要 apply 那个文件,而不是直接 terraform apply?

直接 apply 会重新 plan,这期间如果有人改了资源或代码,执行的内容就和你确认过的不一样了。apply plan 文件保证执行的就是看过的那份。

7. apply 成功、再 plan 也是 No changes,还需要做什么?

用 API 确认关键属性的实际值,再做正向 + 反向的功能测试。plan 会先 refresh(读实际状态),属性没生效的话通常会显示成差异(本仓库的传输中加密就是这样发现的);但 plan 发现不了"设计本身有漏洞",例如 Security List 放行过宽,那要靠反向测试。

8. 密码设了 sensitive = true,还会泄露吗?

会。sensitive 只是不在 plan 输出里显示,state 和 plan 文件里还是明文。应该让 Vault 生成密码,Terraform 不经手。

9. checkov 全部通过,可以说这套设计是安全的吗?

不能。checkov 只检查单个属性的值,本仓库里 Bastion 的 0.0.0.0/0、Security List 放行过宽这类设计问题它都没指出。

完整代码

03-modules/bootstrap/main.tf(存 state 的 bucket)
# ------------------------------------------------------------
# Phase 3-①:存放 Terraform state 的 Object Storage bucket
#   鸡和蛋的问题:存 state 的 bucket 本身也要有人建
#   → 这个目录自己的 state 留在本地(local),只负责建 bucket
#   → 其他目录(03-modules/env)的 state 放进这个 bucket
# ------------------------------------------------------------

locals {
  common_tags = {
    project    = "tf-lab"
    managed_by = "terraform"
  }
}

# Object Storage 的 namespace(每个 tenancy 固定一个,backend 设定里要用)
data "oci_objectstorage_namespace" "ns" {
  compartment_id = var.compartment_ocid
}

resource "oci_objectstorage_bucket" "tfstate" {
  compartment_id = var.compartment_ocid
  namespace      = data.oci_objectstorage_namespace.ns.namespace
  name           = "${var.project_name}-tfstate"

  access_type  = "NoPublicAccess" # state 里有 OCID、IP 等信息,绝对不能公开
  storage_tier = "Standard"
  versioning   = "Enabled" # state 被写坏时可以恢复到旧版本

  freeform_tags = local.common_tags

  # 防止误删 bucket(destroy 时会报错)。真的要删时先把这行改成 false
  lifecycle {
    prevent_destroy = true
  }
}
03-modules/env/versions.tf(backend 设定)
terraform {
  required_version = ">= 1.12.0" # backend "oci" 从 1.12 开始才有

  required_providers {
    oci = {
      source  = "oracle/oci"
      version = "~> 9.0"
    }
  }

  # state 存到 bootstrap 建的 bucket 里
  # backend 块里不能用 variable → bucket / namespace 等写在 backend.hcl 里,
  # init 时用 `terraform init -backend-config=backend.hcl` 传进来(partial configuration)
  backend "oci" {}
}
03-modules/env/main.tf(调用 module)
# ------------------------------------------------------------
# Phase 3-③:调用 module 建一套环境(state 在 Object Storage)
#   01/02 的环境(tflab-*,10.0.0.0/16)不动,这里另建 tflab3-*(10.1.0.0/16)
# ------------------------------------------------------------

locals {
  name_prefix = "tflab3"
  vcn_cidr    = "10.1.0.0/16"

  common_tags = {
    project    = "tf-lab"
    managed_by = "terraform"
    phase      = "3"
  }

  # 要建的 VM:加一条就多建一台(注意 Always Free 上限:A1 合计 4 OCPU / 24GB,boot volume 合计 200GB)
  instances = {
    web = { subnet = "public", assign_public_ip = true }
  }
}

module "network" {
  source = "../modules/network"

  compartment_ocid = var.compartment_ocid
  name_prefix      = local.name_prefix
  vcn_cidr         = local.vcn_cidr
  vcn_dns_label    = local.name_prefix

  subnets = {
    public  = { cidr = "10.1.1.0/24", dns_label = "public", is_public = true }
    private = { cidr = "10.1.2.0/24", dns_label = "private", is_public = false }
  }

  # 所在网络屏蔽了 22 端口 → 只允许 VCN 内部(Cloud Shell 的 Private Network)
  ssh_allowed_cidrs = [local.vcn_cidr]

  freeform_tags = local.common_tags
}

# module 也可以用 for_each:local.instances 有几条就调用几次
#   state 里是 module.compute["web"].oci_core_instance.this 这样的地址
module "compute" {
  source   = "../modules/compute"
  for_each = local.instances

  compartment_ocid = var.compartment_ocid
  name             = "${local.name_prefix}-${each.key}"
  vcn_id           = module.network.vcn_id
  subnet_id        = module.network.subnet_ids[each.value.subnet]
  assign_public_ip = each.value.assign_public_ip

  ssh_public_key     = file(var.ssh_public_key_path)
  user_data          = file("${path.module}/cloud-init.yaml")
  http_allowed_cidrs = ["${var.my_ip}/32"]

  freeform_tags = local.common_tags
}
03-modules/env/manual.tf(import 进来的 NSG)
# ------------------------------------------------------------
# 在 Console 手动建、后来 import 进来的资源
#   由 `terraform plan -generate-config-out=generated.tf` 生成,整理后搬到这里:
#   - 写死的 OCID → 改成 variable / 其他资源的引用
#   - defined_tags(Oracle-Tags.CreatedBy 等 OCI 自动加的标签)→ 删掉(不写也不会出差异)
#   - freeform_tags = {} → 改成 local.common_tags(apply 时会原地更新,加上标签)
# ------------------------------------------------------------

resource "oci_core_network_security_group" "manual" {
  compartment_id = var.compartment_ocid
  vcn_id         = module.network.vcn_id
  display_name   = "tflab3-manual-nsg"

  freeform_tags = local.common_tags
}

resource "oci_core_network_security_group_security_rule" "manual_https" {
  network_security_group_id = oci_core_network_security_group.manual.id
  direction                 = "INGRESS"
  protocol                  = "6"
  source                    = local.vcn_cidr
  source_type               = "CIDR_BLOCK"
  stateless                 = false
  description               = "manual rule"

  tcp_options {
    destination_port_range {
      min = 443
      max = 443
    }
  }
}
04-3tier/.tflint.hcl
# tflint 的设定(Phase 5)
#   执行:在 04-3tier 目录里 `tflint --init` → `tflint --recursive --config "$PWD/.tflint.hcl"`
#   OCI 专用的 ruleset 没有官方版本 → 只用 Terraform 语言本身的规则(写法、未使用的变量等)

plugin "terraform" {
  enabled = true
  preset  = "recommended" # 推荐规则集("all" 会更严格)
}