Terraform 秘密管理指南
本文详细介绍了Terraform中秘密管理的核心问题和解决方案。

发布于 2026 年 7 月 9 日,星期四
在基础设施即代码中保护秘密安全
Terraform 是一个出色的工具。与其在控制台中点击并运行一次性设置脚本,不如用代码描述整个基础设施:三个应用服务器、一个 Postgres 数据库、一个用于上传的存储桶。Terraform 读取这些描述,检查已有资源,然后创建、更新或删除必要的资源,使实际状态与描述匹配。
为此,它需要访问你的基础设施。因此你向它提供凭据:一个用于创建服务器和存储桶的云密钥,以及一个用于设置 Postgres 数据库的管理员密码。
然后,Terraform 将这些秘密以明文形式写入磁盘上的 Terraform 状态文件中。
这带来了双重问题。状态文件会四处传播:它们被复制到笔记本电脑上,通过远程后端共享,并作为 CI 产物存储,每一次复制都携带着秘密。当秘密需要更改时,所有包含该副本的文件都必须随之更改。
用 Terraform 保护秘密安全意味着解决一个特定问题:在不沿途以明文形式存储任何秘密的前提下,给予 Terraform 所需的访问权限。解决方法是将其正值保存在专用的秘密管理平台中,向 Terraform 提供引用而非实际值,并利用较新的机制,使值在运行过程中传递而不被写入状态。
这需要进行一些设置,但回报丰厚:一旦秘密管理器接入,添加一个秘密只需一次写入,轮换一个秘密只需一次更改,之后无需从 Git 或状态中清理任何内容。
Terraform 秘密的问题是什么?
Terraform 处理的任何秘密,无论是你传入的凭据还是 Terraform 生成的值,都会以明文形式写入其状态文件。任何能读取该文件的人都能读取该秘密。
让我们看看这是如何发生的。以下是一个配置,它不涉及云提供商、数据库或任何真正的秘密。它只是生成一个随机密码并将输出标记为敏感:
terraform {
required_version = ">= 1.10"
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
resource "random_password" "db" {
length = 20
special = true
}
output "db_password" {
value = random_password.db.result
sensitive = true
}
执行 apply,Terraform 的行为完全符合预期。该值在通常打印的所有地方都被隐藏了:
$ terraform apply -auto-approve
random_password.db: Creating...
random_password.db: Creation complete after 0s [id=none]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
db_password = <sensitive>
到目前为止,一切安全。计划中是 <sensitive>,输出中也是 <sensitive>。但是,如果我们用 grep 搜索 Terraform 刚刚写入当前目录的文件,我们会看到一些令人担忧的东西:
$ grep -n '"result":' terraform.tfstate
35: "result": "YBG$nq2(UQjIBVSyekYB",
就在这里。Terraform 非常小心地不打印的密码,却以明文形式躺在 terraform.tfstate 中。而且不止一处:
$ grep -n -A1 '"db_password"' terraform.tfstate
7: "db_password": {
8- "value": "YBG$nq2(UQjIBVSyekYB",
资源属性被存储了,输出也被存储了,状态还额外携带了同一密码的 bcrypt 哈希值。sensitive = true 标志控制了值的显示方式,但对存储方式毫无影响。
这就是整个问题的缩影。状态默认是明文的 JSON。每个资源的每个属性都写入其中,Terraform 需要这样:状态是它了解已有资源的途径,以便计算下一次差异。这种泄露是 Terraform 正常工作时的副作用。
秘密是如何进入状态文件的?
秘密进入状态的方式有几种:
- 你传入的值。资源的密码参数,或提供商的 access_key。
- Terraform 生成的值。上面的
random_password是最清晰的例子:Terraform 创建了秘密,所以它当然会存储它。 - 你读取的值。数据源获取信息并将其记录在状态中,以便未来的计划稳定。如果该信息是秘密,它现在也在状态中。
- 输出。输出也存储在状态中,这就是为什么从秘密派生的输出本身就是一个静态的秘密。
注意 sensitive = true 不在此列表中,因为它不改变任何上述情况。
这到底是不是一个大问题?是的。状态文件会到处出现。它们落在共享的远程后端中,被复制到笔记本电脑上用于快速执行 terraform state 命令,作为 CI 产物出现,或者被某个没有意识到其中内容的人提交。每一个都是你秘密的副本,这就是秘密扩散的开始。
那么,你需要做什么?让真正的秘密值远离状态。这意味着要处理 Terraform 处理的两种秘密:它管理的秘密,比如它在基础设施上设置的数据库密码和 API 密钥;以及它使用的凭据,即让 Terraform 本身能够调用你的云和你的秘密管理器的密钥。
在 plan/apply 时拉取秘密
我们在第一个例子中使用了 random_password,因为它保持了演示的自包含性:Terraform 生成了秘密,因此不需要任何真实的东西。实际项目通常反过来。数据库密码或 API 密钥已经存在,并且被直接写入配置中:
locals {
db_user = "app_user"
db_password = "correct-horse-battery-staple" # 存在于每个克隆和 Git 历史中
}
这是需要消除的模式。密码位于你的仓库中,因此每个对仓库有读取权限的人都拥有它,并且即使你删除了那一行,它也会在 Git 历史中永久保留。轮换它意味着一次代码更改。
解决方法是将值移到 Infisical(一个开源秘密管理器)中,并在 Terraform 运行时让其获取。Infisical 将秘密存储为键值对,按项目、环境(开发、预发布、生产)和文件夹路径组织,所有读取它们的人或机器都使用自己的身份和权限进行身份验证。对于本次运行,这意味着上述两个值保存在 Infisical 项目中,作为名为 DB_USER 和 DB_PASSWORD 的秘密,位于名为 /database 的文件夹中,并从代码中删除。
你使用机器身份对 Infisical 提供商进行身份验证,然后使用 infisical_secrets 数据源读取秘密:
terraform {
required_providers {
infisical = {
source = "infisical/infisical"
version = "~> 0.16"
}
}
}
provider "infisical" {
# host 默认为 https://app.infisical.com;如果是自托管则设置它。
auth = {
universal = {
# 两个变量默认都为 null,此时提供商会回退
# 到下面显示的 INFISICAL_UNIVERSAL_AUTH_* 环境变量。
client_id = var.infisical_client_id
client_secret = var.infisical_client_secret
}
}
}
data "infisical_secrets" "backend" {
env_slug = "dev"
workspace_id = var.infisical_workspace_id
folder_path = "/database"
}
locals {
db_user = data.infisical_secrets.backend.secrets["DB_USER"].value
db_password = data.infisical_secrets.backend.secrets["DB_PASSWORD"].value
}
## 从秘密派生的输出本身必须标记为敏感,否则 apply 会出错。
output "db_user" {
value = local.db_user
sensitive = true
}
你需要设置通用认证。完成这些后,运行过程会以正确的方式平淡无奇:
$ terraform apply -auto-approve -var infisical_workspace_id=<project-id>
data.infisical_secrets.backend: Reading...
data.infisical_secrets.backend: Read complete after 0s
Changes to Outputs:
+ db_user = (sensitive value)
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
Outputs:
db_user = <sensitive>
Reading... 和 Read complete 行表示拉取操作:Terraform 在运行过程中调用了 Infisical 的 API 并拉取了值。没有构建任何东西,因此 0 added, 0 changed, 0 destroyed。它是一个数据源,所以只读取。之前的两个 locals 仍然存在,输出打印为 <sensitive>。改变的是值存在的位置。
以下是这一步带来的收益和未解决的问题:
| 硬编码 | 数据源 | |
|---|---|---|
| 秘密所在位置 | 你的仓库 | Infisical,背后有访问控制、审计日志和轮换 |
| Git 中有什么 | 值本身 | 只有坐标:项目、环境、文件夹路径、键名 |
| 如何轮换 | 编辑代码、提交、重新部署 | 在 Infisical 中轮换,下一次 plan 会获取 |
| 状态中有什么 | 值 | 仍然是值 |
最后一行是关键。数据源会记录它读取的内容到状态中,因此本次运行后,状态文件将以明文形式保存这两个值,就像第一个例子一样。大约 2 KB 的状态,密码在 resources 下,输出就在其 "sensitive": true 标志旁边。
你解决了秘密在代码中的问题:值存在于一个地方,更改它只需在 Infisical 中更改,无需编辑你的仓库。但你还未解决秘密在状态中的问题。为此,要么加固存储状态的后端,要么使用临时资源将值完全排除在状态之外。
使用临时资源将秘密排除在状态之外
Terraform 1.10 引入了临时资源:在运行期间获取并刻意永不写入状态或计划文件的值。这解决了上一张表格中的最后一行问题。
在 Infisical 中的设置为此改变了形状:一个名为 DB_CREDENTIALS 的单个秘密,保存在项目根目录下,其值是一个包含 host、username 和 password 键的 JSON 对象。提供商通过 ephemeral 块读取它:
ephemeral "infisical_secret" "db_creds" {
name = "DB_CREDENTIALS"
env_slug = "dev"
workspace_id = var.infisical_workspace_id
folder_path = "/"
}
locals {
credentials = jsondecode(ephemeral.infisical_secret.db_creds.value)
}
provider "postgresql" {
host = local.credentials["host"]
username = local.credentials["username"]
password = local.credentials["password"]
}
将其放在数据源配置旁边,工作上的区别在于块类型:ephemeral 代替了 data,加上单数的 infisical_secret,它读取一个命名的秘密而不是一个文件夹。然后 jsondecode 将 JSON 值解包到本地变量中。
postgresql 提供商就是这些凭据的去向。这个配置中没有创建数据库资源,所以 apply 不会显示任何 postgres 活动。它会显示临时生命周期:
$ terraform apply -var infisical_workspace_id=<project-id>
ephemeral.infisical_secret.db_creds: Opening...
ephemeral.infisical_secret.db_creds: Opening complete after 0s
ephemeral.infisical_secret.db_creds: Closing...
ephemeral.infisical_secret.db_creds: Closing complete after 0s
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
那些 Opening... 和 Closing... 行就是临时生命周期:为运行而获取,在运行结束时显式丢弃。比较数据源运行,它显示 Reading... 并保留了读取的内容。这个获取有一个结束。
然后是最重要的部分。像之前那样 grep 状态文件:
$ grep -i password terraform.tfstate
$ grep -iE 'credential|db_creds' terraform.tfstate
$
什么都没有。整个状态文件只有 181 字节:
$ cat terraform.tfstate
{
"version": 4,
"terraform_version": "1.10.5",
"serial": 1,
"lineage": "565cc4d1-3241-d9aa-0f87-c89437862f48",
"outputs": {},
"resources": [],
"check_results": null
}
真实的凭据从与数据源运行相同的项目中获取,用 jsondecode 解码,并可用于配置提供商。状态中没有记录任何发生过的痕迹。数据源版本的这段代码在状态中留下了 2 KB 和两个值,但这一个没有留下任何东西。
临时值有意受到限制:它们只能流入其他临时上下文,如提供商配置、其他临时资源或只写参数。这个限制是关键。它让 Terraform 保证值永远不会落在持久化的地方。如果你尝试将临时值路由到普通资源属性或常规输出,Terraform 会阻止你。
这些只写参数也关闭了前面列表中的第二条路径。random 提供商提供了一个 random_password 的临时变体,越来越多的资源接受只写属性,这些属性消费值而不存储它:
ephemeral "random_password" "db" {
length = 24
}
resource "aws_db_instance" "app" {
# ...
password_wo = ephemeral.random_password.db.result
password_wo_version = 1
}
密码是临时生成的,传递给只写参数 password_wo,并且永远不会在两端的状态中落地。
在数据源和临时资源之间做出选择时,经验法则很简单:如果一个值只需要在运行期间存在,用于配置提供商或进行身份验证,就让它成为临时的。只有当你确实需要将值具体化时才使用数据源,并且在这种情况下加密你的状态。
将变量和输出标记为敏感
自第一个例子以来,我们一直在使用 sensitive = true,但它到底做了什么?它做一件事:从 Terraform 显示中隐藏该值。它适用于变量和输出:
variable "db_admin_password" {
type = string
sensitive = true
}
## 来自数据源示例
output "db_user" {
value = local.db_user
sensitive = true
}
一个敏感变量在计划中任何出现的地方都会显示为 (sensitive value),敏感输出打印为 <sensitive>。这是一个真正的好处,让秘密远离屏幕和 CI 日志。有时它不是可选的。从敏感值派生的输出在未标记为敏感时无法 apply,这就是数据源示例中标记 db_user 的原因。
这有两个限制:
- 它不抵抗请求。对第一个例子运行
terraform output db_password,它会按需打印值。 - 它不影响存储。grep 在
terraform.tfstate中以明文形式找到了该值,因为sensitive既不加密状态,也不阻止任何内容进入状态。
把它当作显示卫生,你应该始终开启它,但它是建立在真正保护之上的,绝不能替代它们。
安全处理提供商凭据
到目前为止,所有内容都涵盖了第一种秘密,即 Terraform 管理的那些。提供商凭据是第二种,并且是设置中价值最高的秘密,因为持有它们的人通常可以访问其他秘密保护的所有内容。
来自硬编码示例的规则同样适用。以下是它读取的两个变量:
export INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=...
export INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=...
在笔记本电脑上,导出的变量没问题。在 CI 中,它们又成为一个问题:客户端密钥必须存储在某个地方供作业读取,而存储在 CI 平台中的秘密是又一个需要保护的常驻凭据。
解决方法是不拥有一个。GitHub Actions 可以为每个作业生成一个短期的 OIDC Token,而配置了 OIDC 认证 的 Infisical 机器身份接受该Token作为询问者身份的证明。Infisical 的 secrets action 处理交换,这与如何在 CI/CD 流水线中管理秘密中介绍的模式相同:
name: terraform-plan
on:
pull_request:
permissions:
id-token: write # OIDC 必需
contents: read
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 从 Infisical 获取秘密
uses: Infisical/secrets-action@v1.0.9
with:
method: "oidc"
identity-id: "your-machine-identity-id" # 公共标识符,可安全提交
project-slug: "your-project-slug"
env-slug: "prod"
- run: terraform init
- run: terraform plan
将工作流作为序列来理解:
- 作业启动,GitHub 为其生成一个短期 OIDC Token,由
id-token: write scope授权。 - 操作将该Token呈现给 Infisical。
- Infisical 根据身份的绑定检查其声明,绑定主题类似于
repo:your-org/your-repo:ref:refs/heads/main,因此只有该仓库和分支可以认证。 - 它返回短期访问权限,并且作业允许的秘密被注入为环境变量,当作业结束时这些变量消失。
两边都没有存储的秘密。工作流中的 identity-id 是一个公共标识符,可以安全提交,因为知道它本身不授予任何权限,除非有匹配绑定的Token。
Terraform 的关联点是 TF_VAR_ 约定。在 Infisical 中存储一个秘密为 TF_VAR_db_admin_password,然后计划步骤将其读取为 var.db_admin_password,而无需在命令行上传递任何内容。
Terraform 在 CI 中通常也需要云凭据,同一个Token可以覆盖。作业的 OIDC Token可以通过 aws-actions/configure-aws-credentials 交换为有范围的 AWS 角色,因此 GitHub 中也没有长期存在的云密钥。
无论在哪里,原则都是一样的:凭据应该从运行环境到达 Terraform,而不是从它运行的代码。
加固状态后端
状态记录了每个资源的每个属性,无论是否是秘密。即使你做得正确,某些秘密值最终也会在那里:你选择读取的数据源值,因为该值需要持久化;或者当 Terraform 创建资源时返回的凭据,例如 IAM 访问密钥。临时资源还不能覆盖所有情况。
当秘密必须存在于文件中时,保护文件。首先从它存储的地方开始。
到目前为止,每次运行都将状态写到了本地的 terraform.tfstate。这就是 grep 读取的文件。团队通常使用远程后端来存储状态,通常是一个存储桶,而这个存储桶就是你需要加固的内容。四个属性很重要:加密、版本控制、锁定、仅由运行 Terraform 的身份可读。
在 AWS 上,这是带有 KMS 密钥的 S3:
terraform {
backend "s3" {
bucket = "org-terraform-state"
key = "network/prod.tfstate"
region = "us-east-1"
use_lockfile = true # 锁定:不能同时运行两个 apply
encrypt = true # 静态加密
kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/abcd-1234" # 使用你自己的密钥,而不是 S3 默认密钥
}
}
该块处理加密和锁定。另外两个属性位于存储桶上:启用 S3 版本控制 以便覆盖或损坏的状态可以恢复,并限制存储桶的 IAM 范围,以便只有运行 Terraform 的身份才能读取它。
在其他云上,GCS 后端通过 kms_encryption_key 使用 Cloud KMS 密钥,并在存储桶上启用对象版本控制;Azure Blob 通过 use_oidc 使用 Entra ID 进行身份验证。
提供商凭据规则同样适用于后端,而且更严格:后端配置会被复制到 .terraform/ 中并捕获到计划文件中。后端凭据来自环境或上述 CI 流程,绝不应内联或作为 -backend-config 值传入。
完成这些后,数据源交易的权衡就不同了。值仍然会落在状态中,但状态现在是存储在已锁定、版本控制的存储桶中的密文,只有你的运行身份才能打开。
在可重用模块内管理秘密
模块是 Terraform 代码复用的方式:你编写一次数据库设置,然后每个团队和环境用自己的输入调用该模块。复用是重点,但这也正是秘密扩散的原因,因为需要凭据的模块必须从某处获取它。明显的途径是输入变量:
module "service" {
source = "./modules/service"
db_password = var.db_password # 每个调用者都必须持有并传递原始值
}
这加剧了处理问题。每个使用该模块的配置都必须获取密码并传入,并且一旦模块使用它,该值就会被记录在该配置的状态中,因此一个秘密最终被复制到每个调用该模块的堆栈中。
解决方法是传递坐标,让模块自己获取值:
module "service" {
source = "./modules/service"
workspace_id = var.infisical_workspace_id
env_slug = "prod"
secret_path = "/services/api"
}
在模块内部,之前相同的 infisical_secrets 数据源会读取该路径下的秘密,或者一个临时块可以在不触及状态的情况下做到。调用者传递一个位置。值只在其使用的地方存在,并且访问由 Infisical 控制,而不是由谁拥有变量来控制。
超越静态秘密:动态秘密和轮换
到目前为止,所有内容都在保护静态秘密:一个在有人更改之前一直有效的单一值。妥善保护它仍然值得在它存活期间被窃取。有两种方法可以缩小这个窗口,在秘密自动化:轮换 vs. 动态秘密中有更深入的介绍。动态秘密是按需创建并自动过期的。轮换保持一个常驻凭据,但按计划替换它。
动态秘密
动态秘密是按需生成并根据你设置的计划过期的。Infisical 不是保持一个单一的数据库密码,而是在每次需要时创建一个新的数据库用户,并在其租约到期时删除它(动态秘密是企业功能)。
每个后端有一个动态秘密资源(例如,SQL 数据库的 infisical_dynamic_secret_sql_database),有 AWS IAM、Kubernetes 和 MongoDB 的变体。
为了让 Infisical 能按需创建和删除数据库用户,它必须用一个可以管理用户的账户登录到数据库本身。数据源提供该登录信息。它从之前使用的 DB_CREDENTIALS 秘密中读取管理员凭据,因此密码永远不会出现在这个文件中。资源包含指令:连接详细信息,创建用户时运行的 SQL,以及删除用户时运行的 SQL:
data "infisical_secrets" "db" {
env_slug = "dev"
workspace_id = var.infisical_workspace_id
folder_path = "/"
}
locals {
creds = jsondecode(data.infisical_secrets.db.secrets["DB_CREDENTIALS"].value)
# 角色管理 DDL 需要直接端点,而不是连接池。
db_host = replace(local.creds.host, "-pooler", "")
}
resource "infisical_dynamic_secret_sql_database" "db" {
name = "postgres-dynamic"
project_slug = var.infisical_project_slug # 是 slug,不是 ID
environment_slug = "dev"
path = "/"
default_ttl = "1h"
max_ttl = "4h"
configuration = {
client = "postgres"
host = local.db_host
port = "5432"
database = local.creds.database
username = local.creds.username # 用于创建用户的特权账户
password = local.creds.password
creation_statement = <<-EOT
CREATE USER "{{username}}" WITH ENCRYPTED PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "{{username}}";
EOT
revocation_statement = <<-EOT
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM "{{username}}";
DROP ROLE "{{username}}";
EOT
}
}
应用此配置会将指令存储在 Infisical 中。它不会创建凭据。获取一个凭据是一个单独的步骤,生成一个租约:
- 在仪表板中,找到动态秘密并点击生成。
- 设置租约的 TTL,可以是配置中 max_ttl 以内的任何值。
Infisical 连接到数据库,运行 creation_statement,并返回一个新的用户名和密码以及一个过期时间。当租约过期或被删除时,运行 revocation_statement,用户消失。你可以在数据库端看到这一点:每一行租约都会在 pg_roles 中出现一行,并在过期时被移除。
应用程序实际使用的凭据是按租约生成并自动撤销的。泄露的凭据在几小时内就失效了,并且存储中没有长期存在的密码供攻击者寻找。
秘密轮换
有些系统无法使用短期用户,需要一个单一的常驻登录。对于这些系统,退路是轮换:凭据保持不变,但其密码按计划被替换,因此被窃取的密码在下一次轮换前就失效了。轮换包含在专业版中。
Infisical 使用两个数据库用户而不是一个来实现这一点。在任何时刻,其中一个是在线登录。当需要轮换时,Infisical 在空闲用户上设置新密码,并将在线秘密切换到该用户。在交换期间不会中断,因为之前的登录在消费者迁移期间保持有效。
在 apply 之前需要两样东西:
- 两个数据库用户。使用临时密码创建它们,Infisical 会在轮换开始时替换这些密码。
- 一个应用连接:Infisical 存储的对数据库的访问,与动态秘密所需的访问相同,这次在组织设置下保存一次,并作为
connection_id传入。
资源命名这两个用户,以及 Infisical 保持更新为当前在线登录的两个秘密键:
resource "infisical_secret_rotation_postgres_credentials" "db" {
name = "postgres-rotation"
project_id = var.infisical_project_id # 是 ID,不是 slug
environment = "dev"
secret_path = "/database"
connection_id = var.postgres_connection_id # 应用连接,必须已存在
parameters = {
username1 = "infisical_user_1" # 轮换在这两个角色之间交替
username2 = "infisical_user_2"
}
secrets_mapping = {
username = "POSTGRES_DB_USERNAME" # 消费者始终读取这些键
password = "POSTGRES_DB_PASSWORD"
}
auto_rotation_enabled = true
rotation_interval = 30 # 天
}
应用它,传入项目 ID 和应用连接 ID:
$ terraform apply -auto-approve \
-var infisical_project_id=<project-id> \
-var postgres_connection_id=<app-connection-id>
Plan: 1 to add, 0 to change, 0 to destroy.
infisical_secret_rotation_postgres_credentials.db: Creating...
infisical_secret_rotation_postgres_credentials.db: Creation complete after 1s [id=e19d74b0-0be6-432f-bf7d-65b91a571467]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
轮换在创建时立即触发。映射的秘密名称出现在 /database 中,与现有的秘密并列:
DB_PASSWORD
DB_USER
POSTGRES_DB_PASSWORD <- 新增,由轮换写入
POSTGRES_DB_USERNAME <- 新增,由轮换写入
为了验证轮换是否工作,我们读取了两个新秘密,并使用它们登录到数据库。登录成功,以 infisical_user_1 身份,密码为 Infisical 生成的 48 个字符。我们为用户创建的临时密码已经被替换。从现在起,Infisical 会设置密码,没有人需要知道它们。
应用程序永远不会看到交换。它们读取 POSTGRES_DB_USERNAME 和 POSTGRES_DB_PASSWORD,获得当前在线的登录信息,并且密码更改时无需重新部署。
审查中值得注意的反模式
大多数 Terraform 秘密泄漏都可以追溯到几个反复出现的习惯,一旦你命名它们,在代码审查中就很容易发现:
| 反模式 | 为什么失败 | 更好的模式 |
|---|---|---|
sensitive = true 作为唯一控制 |
隐藏显示,但值仍然会进入状态和计划 | 临时资源,或在 Terraform 外部检索 |
| 通过数据源读取秘密值 | 有效负载被写入状态 | 临时资源,或当你无法读取值时加固后端 |
| 静态、长期存在的 CI 凭据 | 静态爆炸半径和痛苦的轮换 | OIDC 和短期身份 |
| 未加密、未版本控制或未锁定的状态 | 静态明文、弱恢复、竞争条件 | KMS 加密、版本控制、锁定、最小权限访问 |
选择 Terraform 秘密管理器
一旦真实值离开你的配置,它们就需要一个地方存放。实际的选择是在 Terraform 运行时与之对话的秘密服务和随仓库移动的加密文件之间。云 KMS 位于两者之下,加密状态和存储本身,但仅凭密钥管理无法提供对单个秘密的访问控制、审计追踪或轮换。
对于 Terraform 具体需要权衡什么:
- 注册表上有官方提供商,这样秘密平台本身可以和其他东西一样作为代码管理。
- 临时读取,这样在计划时获取秘密不会写入状态。
- 动态秘密和轮换,这样一开始就没有那么多常驻凭据。
- 托管和许可条款,如果你受限于自托管或开源。
以下是解决此问题的工具选项:
| 选项 | 它是什么 | 配合 Terraform 使用 | 值得了解的 |
|---|---|---|---|
| Infisical | 开源秘密平台,云或自托管 | 官方提供商。临时秘密读取、动态秘密、轮换和同步均以代码管理 | 在一个平台中涵盖静态存储、动态凭据、轮换和向外同步 |
| HashiCorp Vault | 长期存在的企业级产品,自管理或 HCP | 官方提供商,带有临时资源 | 自运行操作负担重。自 2023 年起采用 BUSL 许可,现属于 IBM |
| 云秘密管理器 | 每个云的原生存储(AWS Secrets Manager、Azure Key Vault、Google Secret Manager) | 第一方提供商,带有数据源和临时资源 | 单云设计,覆盖三个云意味着运行三个 |
| SOPS | 文件加密,不是服务 | 社区提供商在计划时解密提交的文件 | 没有自己的访问控制、审计日志或轮换 |
这里存在功能重叠。Infisical 和 Vault 都会发布动态秘密,并且都会轮换凭据。最终重要的区别是托管、许可以及你想做多少运维工作;请参阅顶级 HashiCorp Vault 替代品进行更全面的比较。
SOPS 的定位
SOPS 会在这个讨论中出现,但它不是秘密管理器,而是文件加密。没有访问控制、审计日志或轮换。你使用 age、PGP 或云 KMS 密钥加密 YAML 或 JSON 文件中的值,然后提交密文。键保持可读,值不可读,因此文件存在于 Git 中,并且差异仍然显示更改了什么。
SOPS 是补充而非替代。如果你的 GitOps 流程需要在版本控制中有加密文件本身,请使用 SOPS 处理该文件,加固保存状态的后端,并将运行时凭据保存在可以轮换它们的系统中。
需要改变的默认设置
这些技术中没有一项是沉重的。转变主要在于默认设置:
- 将真实值保存在 Infisical 中,而不是
.tf文件或.tfvars中。 - 对于任何只需要在运行期间存在的东西,优先使用临时资源,因为它们永远不会触及状态。
- 当你必须具体化一个值时,使用数据源,并在这样做时加密你的后端。
- 把
sensitive = true当作显示卫生,始终开启,但不要作为承重用途。 - 将提供商凭据保存在环境中,并在 CI 中优先使用短期 OIDC。
- 加密、版本控制和锁定保存状态的后端。
- 向模块传递秘密引用,而不是秘密值。
- 在可能的情况下,使用动态秘密彻底消除常驻秘密,并轮换那些无法消除的。
做到这些,你的状态文件就会回到它本应有的样子:你构建了什么的一份记录,而不是你的凭据列表。
- 原文链接: infisical.com/blog/terra...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~