云端部署

AWS 预算提醒与 GitHub Actions OIDC 最小权限部署

给 AWS 账号加两道保险:一是月度预算提醒(实际花费到 80%、预测超过 100% 时发邮件);二是让 GitHub Actions 通过 OIDC 换取临时凭证部署网站,权限只够同步一个存储桶、刷新一个 CloudFront 分发,仓库里不存任何长期密钥。

BINBHO · 图文教程

给 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 再加上测试,以后新开账号或新增仓库都可以照着复用。