给 AWS 账号加两道保险:一是月度预算提醒(实际花费到 80%、预测超过 100% 时发邮件);二是让 GitHub Actions 通过 OIDC 换取临时凭证部署网站,权限只够同步一个存储桶、刷新一个 CloudFront 分发,仓库里不存任何长期密钥。
先看清成品
Terraform 配置已在本机
fmt、init、validate通过,terraform test(mock provider)2 个测试通过:部署策略不含*、信任策略限定仓库和分支、预算有两个提醒;把资源改成*后测试确实失败(见调试示例)。workflow 通过 actionlint。没有在真实账号 apply,也没有在 GitHub 上实际运行部署。
注意:AWS 按用量计费,价格以 AWS 官方价格页和账单为准,本文不列具体金额;做完实验记得删除资源。不要共享 root 账号或访问密钥,不要把密钥提交到仓库。中国大陆的 AWS 中国区(amazonaws.cn)是独立运营的分区,账号不通用,部分服务和 ARN 前缀(arn:aws-cn)不同,本文按全球区域编写。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 月度预算 | 两条邮件提醒 |
| OIDC 身份提供商 | token.actions.githubusercontent.com |
| 部署角色 | 只信任指定仓库的 main 分支 |
| 最小权限策略 | 3 条语句,全部指定资源 |
| 部署 workflow | 配合本工具 A 篇的静态站 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| AWS 账号 | 能管理 IAM 和 Budgets | 预算需要在账单设置中允许 IAM 访问账单信息(如果用 IAM 身份操作) |
| Terraform | 1.7 以上 | 本文用 1.16.6 |
| 已有静态站 | 本工具 A 篇的桶名和分发 ARN | 也可以换成你自己的 |
| GitHub 仓库 | 有设置 Variables 的权限 |
文件结构
20-aws-C-src/
├── versions.tf
├── variables.tf
├── budget.tf
├── github_oidc.tf
├── deploy-site.yml # 复制到网站仓库
└── tests/guard.tftest.hcl
第 1 步:变量
variables.tf:
variable "region" {
type = string
default = "ap-northeast-1"
}
variable "monthly_limit_usd" {
type = string
description = "每月预算上限(美元),例如 \"20\""
}
variable "alert_emails" {
type = list(string)
}
variable "github_repo" {
type = string
description = "允许部署的仓库,格式 OWNER/REPO"
}
variable "site_bucket" {
type = string
}
variable "distribution_arn" {
type = string
}
versions.tf 与 A 篇相同。
第 2 步:预算提醒
budget.tf:
resource "aws_budgets_budget" "monthly" {
name = "monthly-cost"
budget_type = "COST"
limit_amount = var.monthly_limit_usd
limit_unit = "USD"
time_unit = "MONTHLY"
# 实际花费到 80% 提醒
notification {
comparison_operator = "GREATER_THAN"
threshold = 80
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_email_addresses = var.alert_emails
}
# 预测本月会超过 100% 提醒
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "FORECASTED"
subscriber_email_addresses = var.alert_emails
}
}
预算只提醒,不会自动停止资源。收到预测超支邮件后要自己去 Cost Explorer 查是哪个服务。
第 3 步:GitHub OIDC 与最小权限角色
github_oidc.tf:
# GitHub Actions 用 OIDC 换取临时凭证,不在仓库里保存长期密钥
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
}
data "aws_iam_policy_document" "trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
# 只允许指定仓库的 main 分支
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.github_repo}:ref:refs/heads/main"]
}
}
}
resource "aws_iam_role" "deploy" {
name = "github-site-deploy"
assume_role_policy = data.aws_iam_policy_document.trust.json
max_session_duration = 3600
}
# 最小权限:只能同步这一个桶、刷新这一个分发
data "aws_iam_policy_document" "deploy" {
statement {
sid = "ListBucket"
actions = ["s3:ListBucket"]
resources = ["arn:aws:s3:::${var.site_bucket}"]
}
statement {
sid = "WriteObjects"
actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
resources = ["arn:aws:s3:::${var.site_bucket}/*"]
}
statement {
sid = "Invalidate"
actions = ["cloudfront:CreateInvalidation"]
resources = [var.distribution_arn]
}
}
resource "aws_iam_role_policy" "deploy" {
name = "site-deploy"
role = aws_iam_role.deploy.id
policy = data.aws_iam_policy_document.deploy.json
}
output "deploy_role_arn" {
value = aws_iam_role.deploy.arn
}
要点:
sub条件精确到repo:OWNER/REPO:ref:refs/heads/main,其他仓库、其他分支、PR 都换不到凭证。- 不要把
sub写成repo:OWNER/*这种宽泛通配。 - 一个账号里 GitHub 的 OIDC 提供商只能建一个;已经有的话用
data "aws_iam_openid_connect_provider"引用,不要重复创建。
第 4 步:测试安全约束
tests/guard.tftest.hcl:
mock_provider "aws" {
mock_data "aws_iam_policy_document" {
defaults = { json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" }
}
mock_resource "aws_iam_openid_connect_provider" {
defaults = { arn = "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" }
}
}
variables {
monthly_limit_usd = "20"
alert_emails = ["ops@example.com"]
github_repo = "binbho/site"
site_bucket = "binbho-site-test"
distribution_arn = "arn:aws:cloudfront::123456789012:distribution/E123"
}
run "least_privilege" {
command = plan
assert {
condition = alltrue([for s in data.aws_iam_policy_document.deploy.statement : !contains(s.actions, "*") && !contains(s.resources, "*")])
error_message = "部署策略不能用通配符 * 作为操作或资源"
}
assert {
condition = contains(flatten([for c in data.aws_iam_policy_document.trust.statement[0].condition : c.values]), "repo:binbho/site:ref:refs/heads/main")
error_message = "信任策略必须限定仓库和分支"
}
}
run "budget_alerts" {
command = plan
assert {
condition = length(aws_budgets_budget.monthly.notification) == 2
error_message = "应有实际 80% 和预测 100% 两个提醒"
}
}
terraform init
terraform test
检查结果:Success! 2 passed, 0 failed.
第 5 步:创建资源(本文未执行)
terraform.tfvars(不要提交到公开仓库):
monthly_limit_usd = "20"
alert_emails = ["你的邮箱"]
github_repo = "你的用户名/你的网站仓库"
site_bucket = "A 篇输出的 bucket"
distribution_arn = "arn:aws:cloudfront::账号ID:distribution/分发ID"
terraform plan
terraform apply
terraform output deploy_role_arn
预算金额 "20" 只是示例,按你自己的承受范围填。
第 6 步:网站仓库的部署 workflow
在网站仓库 Settings → Secrets and variables → Actions → Variables 添加 AWS_DEPLOY_ROLE_ARN、SITE_BUCKET、DISTRIBUTION_ID(这些不是密钥,用 Variables 即可)。
.github/workflows/deploy-site.yml:
# 放到网站仓库的 .github/workflows/deploy-site.yml
name: Deploy site to AWS
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ap-northeast-1
- run: aws s3 sync site/ "s3://${{ vars.SITE_BUCKET }}/" --delete
- run: aws cloudfront create-invalidation --distribution-id "${{ vars.DISTRIBUTION_ID }}" --paths "/*"
id-token: write 让 workflow 能申请 OIDC 令牌;configure-aws-credentials 用它换取角色的临时凭证。
预期结果:推送到 main 后部署成功;在其他分支手动改触发条件运行时,AssumeRoleWithWebIdentity 被拒绝。
调试示例
把 Invalidate 语句的资源改成 ["*"] 后运行 terraform test,本机实际输出:
run "least_privilege"... fail
Error: Test assertion failed
on tests/guard.tftest.hcl line 21, in run "least_privilege":
...
Failure! 1 passed, 1 failed.
改回 var.distribution_arn 后通过。把这个测试放进 CI,权限被放宽时 PR 会直接失败。
出错了怎么办?
| 现象 | 原因 | 解决 |
|---|---|---|
Not authorized to perform sts:AssumeRoleWithWebIdentity |
sub 与实际仓库/分支不符,或缺 id-token: write |
对照 workflow 运行的分支检查 |
EntityAlreadyExists OIDC provider |
账号里已有 GitHub 提供商 | 改用 data 源引用 |
s3 sync 报 AccessDenied |
缺 ListBucket 或桶名写错 | 检查 site_bucket |
| 没收到预算邮件 | 邮箱填错或还没到阈值 | 在 Budgets 控制台查看提醒设置 |
| IAM 用户看不到账单 | 账号未开启 IAM 访问账单 | 用 root 在账户设置里开启一次,之后不再用 root |
模板规则
- CI 一律用 OIDC 临时凭证,不建访问密钥。
- 策略里不出现
*,并用测试锁住。 - 每个账号第一天就建预算提醒。
项目资源
| 用途 | 地址 |
|---|---|
| AWS Budgets 管理成本 | https://docs.aws.amazon.com/cost-management/latest/userguide/budgets-managing-costs.html |
| GitHub Actions 在 AWS 中使用 OIDC | https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws |
| aws-actions/configure-aws-credentials | https://github.com/aws-actions/configure-aws-credentials |
| IAM 安全最佳实践 | https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html |
| Terraform 测试中的 mock | https://developer.hashicorp.com/terraform/language/tests/mocking |
| AWS 服务条款 | https://aws.amazon.com/service-terms/ |
预算提醒管钱,OIDC 和最小权限管安全,这两件事越早做越省心。写成 Terraform 再加上测试,以后新开账号或新增仓库都可以照着复用。