Terraform 敏感信息管理:sensitive 标记、外部密钥源与 state 加密

第一次把 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. 关联文章

10. 参考

相关推荐
mengge.cloud29 分钟前
prometheus+k8s+弹性伸缩,综合项目
云原生·容器·kubernetes·prometheus
Z21-X2 小时前
云原生运维学习笔记:ECShop全链路容器化部署与Docker底层资源隔离深度剖析
运维·笔记·学习·云原生
那明眸似水5 小时前
云原生项目三:Docker秒级启动NextCloud个人网盘存储办公系统
linux·运维·docker·云原生·容器
腾飞开源6 小时前
03_Kubernetes 核心概念拆解:从 Spec/Status 收敛到工作负载控制器的设计逻辑
云原生·容器·kubernetes
知已难寻17 小时前
Docker 入门:容器技术与 Docker 基础
java·运维·服务器·docker·云原生·容器
阿里云云原生1 天前
生产更可信,订阅更耐用丨Higress 社区进展报告
云原生
阿里云云原生1 天前
如何用 STAROps Elasticsearch 智能助手看穿“搜索变慢”
云原生
雾时之林1 天前
国内Docker 镜像加速器配置
云原生·eureka
牛奶咖啡131 天前
云计算平台应用实践——云计算与云平台
云计算·云平台·云平台的三大服务模式·云计算与云平台的关系·laas paas saas·云平台的主流架构·国内外的主流云平台对比与选型
我是koten2 天前
【运维杂谈】K8s遇到Terminating的名称空间删除不了怎么办?(解决方案亲测有效)
linux·运维·服务器·云原生·kubernetes·k8s·名称空间