第一次把 RDS 密码写进 terraform.tfvars 时,我以为 .gitignore 加一行就够了。三个月后翻 CI 日志,plan 输出的 password = "Pr0d-Rds-2019" 明晃晃躺在第 847 行。
💡 示例基于 Terraform 1.5.7 + AWS provider 5.x + Vault provider 3.x,后端是 S3 + DynamoDB。
1. 敏感信息的落点
1.1 五个会留下明文的位置
| 位置 | 默认是否明文 | 处置方式 |
|---|---|---|
*.tfvars 提交进仓库 |
明文 | 不进仓库,改由外部密钥源提供 |
terraform.tfstate |
明文 | 后端加密 + 收窄读权限 |
plan / apply 输出 |
明文 | sensitive = true 遮蔽 |
| CI 日志 | 明文 | 日志遮蔽 + OIDC 临时凭据 |
模块 variables.tf 的 default |
明文 | 敏感项不写 default |

state 是最后一道关口,它天生就是明文。后端加密解决的是「磁盘被人拷走」,IAM 解决的是「谁能调 API 读它」。
1.2 泄露的典型路径
密码进仓库之后,清理 git 历史要重写所有分支,还得让每个克隆过的人重新拉。代价远高于一开始就不写进去。
.gitignore 只挡新增文件。已经进过 commit 的 tfvars 会一直留在历史里,git log -p 一翻就能看到。
⚠️ 已经提交过的密钥,从 git 历史里删掉不代表失效。轮转密钥是唯一可靠的补救。
2. sensitive 标记的作用范围
2.1 变量与输出
plain
variable "db_password" {
type = string
sensitive = true
}
output "db_endpoint" {
value = aws_db_instance.this.endpoint
}
标了 sensitive 的变量,在 plan 和 apply 里显示成 (sensitive value)。terraform output 也默认遮住,取值要加 -json 或 -raw。
2.2 标记的传播
敏感值参与运算、拼接、放进 list,结果仍然带标记。
plain
output "conn_string" {
value = "postgres://app:${var.db_password}@${aws_db_instance.this.endpoint}/app"
sensitive = true
}
字符串插值会把敏感值带进普通字符串。root 输出里再暴露一次必须显式标 sensitive,漏标就报 Output refers to sensitive values。
确实需要明文输出时用 nonsensitive() 降级,用完记得把这段从输出里摘掉。
2.3 标记管不到的层面
sensitive 只影响显示层,存储层原样写入。

把它当脱敏开关会踩坑:以为标了 sensitive state 就安全,实际 terraform show 一读就出来。
3. 外部密钥源接入
3.1 三种方案对比
| 方案 | 成本 | 轮转 | 版本管理 | 适用场景 |
|---|---|---|---|---|
| SSM Parameter Store | 标准参数免费 | 手动或脚本触发 | 无原生版本回滚 | 小体量配置密钥 |
| Secrets Manager | 按密钥数与 API 调用计费 | 内置自动轮转 | 版本 + staging label | 数据库凭据 |
| Vault | 自建或托管成本 | 动态凭据、TTL | KV v2 带版本 | 多云、短时凭据 |
判断依据是轮转责任。交给云厂商托管就选 Secrets Manager,需要动态凭据和多云统一入口就上 Vault。
3.2 SSM Parameter Store
plain
data "aws_ssm_parameter" "db_password" {
name = "/prod/app/db_password"
with_decryption = true
}
resource "aws_db_instance" "this" {
password = data.aws_ssm_parameter.db_password.value
}
with_decryption 漏写会拿到 KMS 密文,apply 能过,应用连不上库。参数存成 SecureString 才有加密。
3.3 Secrets Manager
plain
data "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/app/db"
}
locals {
db = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)
}
resource "aws_db_instance" "this" {
username = local.db.username
password = local.db.password
}
一次调用取回整个 JSON,用户名和密码在同一个密钥里,轮转时一起换。
3.4 Vault KV v2
plain
data "vault_kv_secret_v2" "db" {
mount = "kv"
name = "prod/app/db"
}
resource "aws_db_instance" "this" {
password = data.vault_kv_secret_v2.db.data["password"]
}
Vault 的认证走 VAULT_TOKEN 或 AppRole,provider 配置里别写死 token。
3.5 接入链路
latex
SSM SecureString ──┐
Secrets Manager ───┼──▶ data source ──▶ resource 参数 ──▶ state 明文
Vault KV v2 ───────┘

三个 data source 的取值属性在 provider schema 里都标了 sensitive,plan 里同样显示 (sensitive value)。这一点由 provider 决定,不需要自己加。
多个 root 读同一个密钥时,改一次就全部生效,省掉逐仓库同步的麻烦。
4. state 后端与权限
4.1 S3 后端配置
plain
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "prod/app/terraform.tfstate"
region = "ap-east-1"
encrypt = true
kms_key_id = "arn:aws:kms:ap-east-1:123456789012:key/abcd"
dynamodb_table = "tfstate-lock"
}
}
桶开版本化,配一条拒绝非 TLS 请求的桶策略。删错 state 时版本化是唯一的回滚手段。
4.2 读权限等于密钥权限
能读 state 的人等于能读全部密钥。权限收窄到 CI 的 role 和两三个运维账号,别给整个开发组。
桶策略按 role 收窄,前缀限定到环境目录:
json
{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::123456789012:role/gha-terraform"},
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::acme-tfstate/prod/*"
}
审计靠 S3 访问日志和 CloudTrail 的 GetObject 事件,别等到出事才想起查。
5. CI 中的凭据注入
5.1 OIDC 换取临时凭据
yaml
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-terraform
aws-region: ap-east-1
长期 AK 写在仓库 Secret 里,一旦泄露就是永久有效。OIDC 换来的凭据默认 1 小时过期,泄露窗口小得多。
Role 的 trust policy 要限定到具体仓库和分支,放开给整个 org 等于谁都能 assume。
5.2 日志遮蔽
yaml
- run: echo "::add-mask::${{ steps.secret.outputs.value }}"
外部命令的输出也可能带出密钥。set -x 打开调试时尤其要小心,脚本里对敏感变量加遮蔽。
5.3 环境变量注入
bash
export TF_VAR_db_password="$(aws secretsmanager get-secret-value \
--secret-id prod/app/db --query SecretString --output text | jq -r .password)"
TF_VAR_ 前缀的变量会直接填进对应变量,省掉 tfvars。代价是密码进了进程环境和 shell 历史,只在临时排障时用。
6. 轮转与 Terraform 的配合
6.1 轮转后的刷新路径
latex
轮转任务 ─▶ AWSCURRENT 换新版本
│
├─▶ 应用下次启动读到新密码
└─▶ terraform refresh 读到新值 ─▶ plan 报 password 变更
data source 在 refresh 阶段重新读,轮转一次,下一次 plan 就显示 password 变更。
6.2 谁负责改库密码
| 策略 | 写法 | 适用 |
|---|---|---|
| Terraform 接管 | 不写 ignore_changes,apply 时同步改库 |
密码只由 Terraform 改 |
| 轮转器接管 | ignore_changes = [password] |
Secrets Manager 自动轮转 |
plain
resource "aws_db_instance" "this" {
password = local.db.password
lifecycle {
ignore_changes = [password]
}
}
两边都管会打架:轮转器改了密码,Terraform 下一次 apply 又按 state 里的旧值改回去,应用随即连不上。
7. 常见报错
| 报错 / 现象 | 原因 | 处理 |
|---|---|---|
Output refers to sensitive values |
输出引用了敏感值没标 | 加 sensitive = true |
Invalid value for "v" parameter |
敏感值进了 for 或函数参数 |
用 nonsensitive() 明确降级 |
| apply 成功但应用连不上库 | SSM 漏 with_decryption |
补上并重新 plan |
error reading SSM Parameter |
参数名、区域或 ssm:GetParameter 权限缺失 |
核对 ARN 与 IAM 策略 |
| plan 每次都报 password 变更 | 轮转器改了值,state 里是旧的 | 加 ignore_changes 或交给 Terraform 管 |
| state 里能搜到明文密码 | 设计如此,sensitive 不管存储 |
收窄 state 读权限,开 KMS 加密 |
排查顺序先看 plan 里是 (sensitive value) 还是明文,再确认 data source 读到的版本对不对。
8. 检查清单
- 仓库里没有
*.tfvars与带 default 的敏感变量 - 敏感变量与输出都标了
sensitive - 密钥全部来自 SSM / Secrets Manager / Vault,代码里无字面量
- state 后端开了加密、版本化,读权限只给 CI role
- CI 用 OIDC 换临时凭据,没有长期 AK
- 密码的修改责任只有一方:Terraform 或轮转器
9. 关联文章
- Terraform 模块版本管理:约束语法、源类型与升级策略:https://blog.csdn.net/2401_82619496/article/details/168732832?spm=1011.2415.3001.5331
- Terraform 模块组合:VPC、安全组、EC2 的输出传递与依赖编排:https://blog.csdn.net/2401_82619496/article/details/168641805?spm=1011.2415.3001.5331
10. 参考
- Terraform 文档 · 敏感数据:https://developer.hashicorp.com/terraform/language/manage-sensitive-data
- Terraform 文档 · S3 后端:https://developer.hashicorp.com/terraform/language/settings/backends/s3
- AWS provider · SSM 参数数据源:https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ssm_parameter
- AWS provider · Secrets Manager 数据源:https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/secretsmanager_secret_version
- Vault provider · KV v2 数据源:https://registry.terraform.io/providers/hashicorp/vault/latest/docs/data-sources/kv_secret_v2