安全最佳实践
最小权限 IAM(Least Privilege)
Terraform 执行时需要云账号的访问凭证,错误的做法是直接给 AdministratorAccess。最小权限原则要求:只赋予完成当前任务所需的最小权限集合。
为什么不用 AdministratorAccess?
一旦 Terraform 执行账号的凭证泄露(如 CI 系统被攻击),攻击者会获得整个 AWS 账号的完全控制权。最小权限将泄露的影响范围限制在特定资源类型。
资源类型限制
只允许操作需要的资源类型(如只有 ec2:*、s3:*、rds:*),而不是所有资源。
区域限制
通过
aws:RequestedRegion Condition 限制只能在特定 AWS 区域操作,防止在未知区域创建资源。OIDC 替代静态凭证
在 GitHub Actions、GitLab CI 中使用 OIDC(OpenID Connect)获取临时凭证,完全避免长期 Access Key 的存在,零凭证泄露风险。
// terraform-execution-role-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TerraformComputeAndStorage",
"Effect": "Allow",
"Action": [
"ec2:*",
"s3:*",
"rds:*",
"elasticloadbalancing:*",
"autoscaling:*"
],
"Resource": "*",
"Condition": {
"StringEquals": {
// 限制只能在这两个区域操作
"aws:RequestedRegion": ["us-east-1", "us-west-2"]
}
}
},
{
"Sid": "TerraformStateBackend",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket",
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
],
"Resource": [
"arn:aws:s3:::my-company-terraform-state/*",
"arn:aws:s3:::my-company-terraform-state",
"arn:aws:dynamodb:us-east-1:123456789:table/terraform-locks"
]
}
]
}
# GitHub Actions OIDC Trust Policy(允许 GitHub Actions 扮演此角色)
# 不需要任何静态凭证!
data "aws_iam_policy_document" "github_actions_assume_role" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:my-org/my-repo:*"] # 限制只有此 repo 可以扮演
}
}
}
resource "aws_iam_role" "github_terraform" {
name = "GitHubTerraformRole"
assume_role_policy = data.aws_iam_policy_document.github_actions_assume_role.json
}
Secrets 管理:绝不硬编码,绝不入 State
State 文件中的敏感数据问题
即使你的 .tf 文件中没有明文密码,某些资源(如 aws_db_instance 的 password、aws_iam_access_key 的 secret)的值也会明文存储在 terraform.tfstate 中。这是 Terraform 的已知问题。正确做法:启用 S3 Backend 的 KMS 加密,严格限制 State 文件的访问权限,并用 Secrets Manager/Vault 动态读取密码。
# ❌ 错误做法:密码硬编码在 .tf 或 .tfvars 文件中
# db_password = "my-super-secret-password" ← 进 Git 历史,永远泄露
# ✅ 正确做法 1:从 AWS Secrets Manager 动态读取
data "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/database/credentials"
}
locals {
# Secrets Manager 存储的是 JSON 字符串,解码取字段
db_creds = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)
}
resource "aws_db_instance" "main" {
engine = "postgres"
username = local.db_creds.username
password = local.db_creds.password # 运行时从 Secrets Manager 读取
}
# ✅ 正确做法 2:从 HashiCorp Vault 读取
data "vault_generic_secret" "db" {
path = "secret/prod/database" # Vault KV 路径
}
resource "aws_db_instance" "main" {
password = data.vault_generic_secret.db.data["password"]
}
# ✅ 正确做法 3:使用 AWS Parameter Store
data "aws_ssm_parameter" "db_password" {
name = "/prod/database/password"
with_decryption = true # SecureString 类型需要解密
}
resource "aws_db_instance" "main" {
password = data.aws_ssm_parameter.db_password.value
}
生命周期保护:防止意外删除和替换
# lifecycle 块控制 Terraform 对资源的处理行为
resource "aws_db_instance" "production" {
identifier = "prod-database"
engine = "postgres"
instance_class = "db.r6g.large"
deletion_protection = true # AWS 层面的保护(需要先关闭才能删除)
lifecycle {
# prevent_destroy:Terraform 层面拒绝 destroy 操作
# 如果 terraform destroy 或 plan 中包含删除此资源,直接报错
prevent_destroy = true
# ignore_changes:忽略指定属性的变化(手工修改这些属性不会触发 Terraform 覆盖)
ignore_changes = [
engine_version, # 忽略自动小版本升级触发的 diff
password, # 密码在 Secrets Manager 中管理,Terraform 不管
]
# create_before_destroy:先创建新资源再销毁旧资源
# 避免先删再建导致的服务中断(适用于无法原地更新的资源)
create_before_destroy = true
# replace_triggered_by:当依赖资源变化时,强制替换此资源
# 例如 TLS 证书轮换时强制重新部署 EC2
replace_triggered_by = [aws_acm_certificate.main.id]
}
}
resource "aws_s3_bucket" "state" {
bucket = "my-terraform-state"
lifecycle {
prevent_destroy = true # State bucket 绝对不允许删除
}
}
团队工作流:Atlantis GitOps
Atlantis 的工作原理
Atlantis 是一个开源工具,在 Kubernetes 或 EC2 上运行,监听 GitHub/GitLab PR 事件,自动在 PR 中运行 terraform plan 并将输出作为评论展示,团队 Review 后通过评论触发 apply。
Atlantis GitOps 工作流
开发者推送分支,创建 PR
↓
Atlantis 收到 webhook
↓
自动执行 terraform plan
↓
Plan 结果作为 PR 评论显示
↓
团队成员 Code Review + Approve
↓
评论 "atlantis apply"
↓
Atlantis 执行 terraform apply
↓
Apply 结果作为评论显示
↓
PR 自动合并(配置 apply_requirements: mergeable)
↓
main 分支 = 生产基础设施的真实状态
# atlantis.yaml(放在 Terraform 仓库根目录)
version: 3
automerge: true # apply 成功后自动合并 PR
projects:
- name: production
dir: environments/prod
workspace: default # Terraform workspace
# 自动触发 plan 的条件
autoplan:
enabled: true
when_modified:
- "**/*.tf" # 任何 .tf 文件修改
- "**/*.tfvars" # tfvars 变化也触发
- "modules/**/*.tf" # 依赖的模块变化也触发
# apply 的前置条件
apply_requirements:
- approved # 必须有至少 1 个 Approve
- mergeable # PR 必须处于可合并状态(无冲突)
# - undiverged # 可选:分支不能落后于 main
# 自定义工作流(可选)
workflow: custom
workflows:
custom:
plan:
steps:
- init:
extra_args: ["-backend-config=environments/prod/backend.hcl"]
- run: tflint --recursive # plan 前先 lint
- plan:
extra_args: ["-var-file=environments/prod/terraform.tfvars"]
apply:
steps:
- apply
Atlantis 部署
# Kubernetes 部署 Atlantis(使用官方 Helm Chart)
helm repo add runatlantis https://runatlantis.github.io/helm-charts
helm repo update
# values.yaml 配置
helm install atlantis runatlantis/atlantis \
--set orgAllowlist=github.com/my-org/* \
--set github.user=atlantis-bot \
--set github.token=$GITHUB_TOKEN \
--set github.secret=$WEBHOOK_SECRET \
--set atlantisUrl=https://atlantis.my-company.com
# 配置 GitHub Webhook
# URL: https://atlantis.my-company.com/events
# Content-Type: application/json
# Events: Pull request, Issue comment, Pull request review
成本可视化:Infracost
为什么需要成本估算?
Terraform 代码一旦 apply,就会产生真实的云费用。一次不小心的配置变更(如把 RDS 实例从 db.t3.micro 改为 db.r6g.4xlarge)可能导致月费用从 $20 飙升到 $1000+。Infracost 在 PR 阶段就计算出费用变化,让团队在 merge 前看到成本影响。
# 安装 Infracost
brew install infracost
# 注册并获取 API Key(免费)
infracost auth login
# 查看当前配置的月度费用明细
infracost breakdown --path .
# 输出示例:
# Project: environments/prod
#
# Name Monthly Qty Unit Monthly Cost
# aws_instance.web (t3.large)
# Linux/UNIX usage (on-demand) 730 hours $60.00
# aws_db_instance.main (db.r6g.large, 100GB)
# Database instance (on-demand) 730 hours $192.00
# Storage (gp2) 100 GB $11.50
#
# OVERALL TOTAL $263.50
# 对比两个版本的费用差异(用于 PR 对比)
infracost diff \
--path . \
--compare-to baseline_plan.json
# 生成 JSON 报告(供 CI 使用)
infracost breakdown --path . --format json --out-file infracost.json
# .github/workflows/infracost.yml — 在 PR 中显示费用变化
name: Infracost Cost Estimation
on:
pull_request:
paths: ['terraform/**']
jobs:
infracost:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # 允许写评论
steps:
- uses: actions/checkout@v4
- name: Setup Infracost
uses: infracost/actions/setup@v3
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
# 计算 main 分支的基准费用
- name: Checkout base branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Generate Infracost baseline
run: |
infracost breakdown \
--path terraform/environments/prod \
--format json \
--out-file /tmp/infracost-base.json
# 计算 PR 分支的费用
- name: Checkout PR branch
uses: actions/checkout@v4
- name: Generate Infracost diff
run: |
infracost diff \
--path terraform/environments/prod \
--format json \
--compare-to /tmp/infracost-base.json \
--out-file /tmp/infracost-diff.json
# 在 PR 中发布费用变化评论
- name: Post Infracost comment
run: |
infracost comment github \
--path /tmp/infracost-diff.json \
--repo $GITHUB_REPOSITORY \
--github-token ${{ secrets.GITHUB_TOKEN }} \
--pull-request ${{ github.event.pull_request.number }} \
--behavior update # 更新已有评论而非新增
生产级目录结构
推荐的项目结构
terraform/
├── modules/ # 可复用模块(私有 Module Registry)
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── README.md
│ ├── eks-cluster/
│ ├── rds-postgres/
│ └── app-service/ # 封装应用部署(ALB + ECS + ASG)
│
├── environments/ # 各环境的配置入口
│ ├── dev/
│ │ ├── main.tf # 调用公共模块,传入 dev 参数
│ │ ├── variables.tf
│ │ ├── terraform.tfvars # dev 环境的具体参数值
│ │ └── backend.tf # dev 环境的 S3 backend 配置
│ ├── staging/
│ │ └── ... # 与 dev 结构相同
│ └── prod/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
│
├── global/ # 跨环境共享资源
│ ├── iam/ # IAM 角色/策略
│ ├── route53/ # DNS 区域
│ └── acm/ # TLS 证书
│
├── .github/
│ └── workflows/
│ ├── terraform-ci.yml # PR 检查(fmt/validate/plan/test)
│ ├── drift-detection.yml # 定期漂移检测
│ └── infracost.yml # 费用估算
│
├── .tflint.hcl # TFLint 配置
├── atlantis.yaml # Atlantis GitOps 配置
├── .gitignore # 排除 .terraform/, *.tfstate
└── Makefile # 封装常用命令
Makefile 封装常用命令
# Makefile — 封装 Terraform 常用操作,统一团队命令
ENV ?= dev
TF_DIR = environments/$(ENV)
.PHONY: init plan apply destroy fmt validate test
# 初始化(自动使用对应环境的 backend 配置)
init:
cd $(TF_DIR) && terraform init \
-backend-config=backend.tf
# 计划(默认 dev 环境,可用 make plan ENV=prod)
plan: init
cd $(TF_DIR) && terraform plan \
-var-file=terraform.tfvars \
-out=tfplan
# 应用(需要手动确认,或加 AUTO=true 跳过)
apply: plan
cd $(TF_DIR) && terraform apply tfplan
# 格式化所有 .tf 文件
fmt:
terraform fmt -recursive
# 静态验证
validate: init
terraform validate ./... && \
tflint --recursive && \
checkov -d .
# 运行内置测试
test: init
terraform test -verbose
.gitignore 配置
# .gitignore — Terraform 项目必须忽略的文件
# Terraform 缓存目录(包含 provider 二进制文件,很大)
.terraform/
# State 文件(包含敏感数据,不能入 Git)
*.tfstate
*.tfstate.backup
.terraform.tfstate.lock.info
# Plan 文件(可能包含敏感数据)
*.tfplan
# 本地 override 文件(用于个人测试,不共享)
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# 包含敏感数据的 tfvars(生产密码等)
*.auto.tfvars
# 如果 terraform.tfvars 包含密码,也要忽略
# terraform.tfvars
# Terragrunt 缓存
.terragrunt-cache/
# Crash 日志
crash.log
常见反模式与最佳实践对比
反模式:手动执行 apply
团队成员在本地随意执行 terraform apply,没有 review 流程,操作无法追溯。正确做法:通过 Atlantis 或 GitHub Actions CI/CD,所有 apply 都经过 PR review,有完整审计日志。
反模式:一个巨型 main.tf
所有资源堆在一个文件中,难以阅读和维护。正确做法:按资源类型拆分文件(network.tf、compute.tf、database.tf、iam.tf),并将可复用部分提取为模块。
反模式:直接使用 latest provider 版本
version = "~> latest" 会导致不同时间运行 init 下载不同版本的 provider,破坏可重现性。正确做法:在 required_providers 中固定小版本(如 ~> 5.50),并定期用 terraform providers upgrade 升级。反模式:没有标签策略
资源没有统一的标签,无法按项目/环境/团队进行成本分配和资源盘点。正确做法:定义标签规范,通过 default_tags(AWS Provider 功能)自动给所有资源打标签。
反模式:State 文件入 Git
State 文件包含明文密码、私钥等敏感数据,入 Git 等于永久泄露(即使后来删除,历史记录仍保存)。正确做法:始终使用远程 Backend(S3 + KMS 加密),.gitignore 中排除所有 .tfstate 文件。
default_tags:自动给所有资源打标签
# AWS Provider 的 default_tags 功能
# 这些标签会自动应用到所有支持 tags 的 AWS 资源
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
Project = var.project_name # 项目名称(成本中心)
Environment = var.environment # dev/staging/prod
ManagedBy = "terraform" # 标明由 Terraform 管理
Owner = var.team_name # 负责团队
Repository = "github.com/my-org/infra" # 代码来源
}
}
}
# 资源自动继承 default_tags,无需重复声明
# 单独的 tags 会与 default_tags 合并
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.medium"
tags = {
Name = "web-server" # 仅需加资源特有的标签
# Project/Environment/ManagedBy/Owner 会自动继承
}
}
本章小结
本章核心要点
- 最小权限 IAM + OIDC 认证:不使用 AdministratorAccess,通过 aws:RequestedRegion 限制操作区域;在 GitHub Actions 中用 OIDC 获取临时凭证,零长期凭证泄露风险。
- Secrets 不入 .tf 和 State:通过 AWS Secrets Manager、Parameter Store 或 HashiCorp Vault 动态读取密码;S3 Backend 开启 KMS 加密;严格限制 State 文件访问权限。
- lifecycle 块保护生产资源:
prevent_destroy = true防止意外删除;create_before_destroy = true避免零停机部署失败;ignore_changes屏蔽手工操作的属性。 - Atlantis GitOps:所有 apply 通过 PR 流程,自动触发 plan 评论,评论 "atlantis apply" 触发执行,保证操作可审计、可追溯。
- Infracost:在 PR 阶段显示费用变化,避免"意外"的大额费用增加;支持与 GitHub/GitLab/Atlantis 集成。
- 目录隔离 + 标准结构:modules/ 存放可复用模块,environments/ 按环境隔离,global/ 管理跨环境共享资源;Makefile 统一团队操作命令。
- default_tags:AWS Provider 的 default_tags 自动给所有资源打标签,实现统一的成本分配和资源盘点,无需在每个资源上重复声明。
恭喜完成 Terraform IaC 教程!
你已掌握从 HCL 基础、Provider 原理、State 管理、模块化、远程协作到生产级安全工作流的完整知识体系。建议的下一步实践路径:
- 在个人 AWS 账号上用 Terraform 搭建一套三层架构(VPC + ECS/EC2 + RDS),使用 S3 Backend
- 为你的模块编写 terraform test,体验内置测试框架
- 尝试用 Atlantis 搭建 GitOps 工作流,体验 PR-based Infra 变更流程
- 接触 OpenTofu(Terraform 的开源社区 fork)了解 IaC 生态的最新动态