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"
- 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 + 规则,然后纳入管理:
-
写
importblock(指定"哪个 ID 对应代码里的哪个地址") -
terraform plan "-generate-config-out=generated.tf"→ 自动生成 resource 代码 - 整理生成的代码:OCID 改成变量或引用、删掉 Oracle 自动加的
defined_tags、加上统一的标签 plan(2 to import, 1 to change)→apply→ 再plan是No changes-
删掉 import block
-
import 不会自动搜索:Terraform 不会去找 OCI 上有什么,要人来指定。想按 compartment 整体导出,用 Resource Manager 的 Resource Discovery 等工具
- import ID 的格式每种资源都不一样(NSG 规则是
networkSecurityGroups/<NSG OCID>/securityRules/<规则 ID>),看 provider 文档的 Import 一节 - 有数量限制、全局唯一的资源不要 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
}
}
}