使用 Workload Identity 将 GitHub Actions 无密钥部署到 GCP
不要再将服务账号 JSON 密钥粘贴到 GitHub Secrets 中。借助工作负载身份联合,Actions 可以使用短期 OIDC 令牌向 GCP 进行身份验证。
让 GitHub Actions 部署到 Google Cloud 的通常方法是创建一个服务账号,下载其 JSON 密钥,然后将该密钥粘贴到 GitHub Secret 中。这种方法一次就能成功,这正是它得以普及的原因。但它也会将一个长期有效的凭据交给一个你无法完全控制的系统,该凭据没有过期时间,也没有简单的审计追踪。Workload Identity Federation 则完全无需使用密钥。GitHub Actions 在每次运行时都会签发一个带签名的 OIDC 令牌,你可以让 GCP 信任该令牌,但仅限于特定的仓库和分支。这样就不会生成任何密钥,因此密钥也就不会泄露、无需轮换,或被遗忘在密钥存储中。本文将展示完整的设置过程、决定安全成败的关键属性条件,以及在何种情况下使用普通密钥仍是更快捷的选择。
将服务账号密钥存储在 Secret 中的问题
服务账号密钥是一种持有者凭据。任何持有该 JSON 文件的人都可以扮演该服务账号的角色,直到有人删除该密钥为止,并且默认情况下它永不过期。仅此一个事实就带来了大部分的痛苦。
如果密钥通过受感染的依赖项、范围不当的日志、具有 Secret 访问权限的分支或笔记本电脑备份而泄露,那么在密钥的整个生命周期内,攻击者都将拥有您的部署身份。您通常不会知道泄露已经发生,因为其调用看起来与您的 CI 完全相同。轮换是一项没人愿意做的手动杂活:生成新密钥、更新 Secret、确认没有出现问题、删除旧密钥。团队会跳过这个步骤,因此密钥会不断累积。审计也很薄弱。密钥不会记录其使用来源,因此您无法轻易证明只有您的 CI 使用过它进行调用。
这其中没有任何一项是密钥格式的缺陷。静态凭据本身就带有静态凭据的风险,而 CI 是一个不适合存放静态凭据的地方,因为其爆炸半径就是您的生产部署路径。
Workload Identity Federation 的工作原理
Workload Identity Federation 是外部身份提供商与 GCP 之间的联合信任。GCP 不会铸造一个您带到 GitHub 的密钥,而是由 GitHub 铸造一个 GCP 已经信任的令牌。
每次 GitHub Actions 运行都可以从 GitHub 的身份提供商请求一个 OIDC 令牌。该令牌是一个由 GitHub 签名的短期 JWT,其声明描述了此次运行:哪个代码库、哪个分支或标签、哪个工作流、哪个环境。GCP 托管一个 Workload Identity Pool,其提供商将 GitHub 的颁发者指定为受信任的。当您的工作流出示 GitHub 令牌时,GCP 会验证签名,根据您设置的条件检查声明,如果通过,则将该令牌交换为一个模拟您所选服务账号的短期 Google 访问令牌。
这个链条值得清楚地说明。GitHub 签署一个令牌来证明运行的身份。GCP 信任 GitHub 的签名和您的条件。服务账号授予实际的部署权限。没有任何密钥跨越边界,并且返回的 Google 令牌会在几分钟内过期。
使用 gcloud 设置池和提供商
您需要一次性创建三样东西:一个池、池内的一个 OIDC 提供商,以及服务账号上的一个权限绑定。请先设置您的项目变量。
PROJECT_ID="my-project"
PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')"
REPO="my-org/my-repo"
DEPLOY_SA="deploy@${PROJECT_ID}.iam.gserviceaccount.com"
创建池,然后创建信任 GitHub 颁发者的提供商。属性映射会将 GitHub 令牌中的声明复制到您以后可以引用的属性中。
gcloud iam workload-identity-pools create github-pool \
--project="$PROJECT_ID" \
--location="global" \
--display-name="GitHub Actions"
gcloud iam workload-identity-pools providers create-oidc github-provider \
--project="$PROJECT_ID" \
--location="global" \
--workload-identity-pool="github-pool" \
--display-name="GitHub OIDC" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
--attribute-condition="assertion.repository == '${REPO}' && assertion.ref == 'refs/heads/main'"
attribute-condition 是安全边界,下一节将专门介绍它。目前请注意,它将信任固定到单个代码库的 main 分支。来自任何其他代码库或分支的令牌在到达您的服务账号之前就会交换失败。
将信任锁定到单个仓库和分支
整个设置中最重要的一行是属性条件。如果设置得太宽松,你构建的就不是无密钥部署,而是一个世界上任何仓库都可以冒充的服务账号。
这种失败模式很微妙,因为宽松的版本对你来说仍然有效。如果你映射了 attribute.repository 但从未要求一个特定的值,那么任何 GitHub 仓库(包括陌生人的仓库)都可以出示一个有效的、由 GitHub 签署的令牌并通过验证。签名是真实的。你跳过的是检查这是谁的签名。GitHub 乐于为平台上的每个仓库颁发正确签名的令牌,因此仅凭身份提供商的签名并不能证明所有权。你的条件才是将信任与你绑定的关键。
始终要求精确的仓库。当只有一个分支应该部署时,添加该分支。
# Good: exactly your repo, main branch only.
--attribute-condition="assertion.repository == 'my-org/my-repo' && assertion.ref == 'refs/heads/main'"
# Dangerous: any repo whose name happens to match a prefix.
--attribute-condition="assertion.repository.startsWith('my-org/')"
# Broken: no ownership check at all, every GitHub repo passes.
--attribute-condition="assertion.repository != ''"
服务账号上的绑定应匹配相同的范围。将 workloadIdentityUser 角色授予身份池,但将成员限制为精确的仓库属性,这样模拟权限就不会是身份池范围的。
gcloud iam service-accounts add-iam-policy-binding "$DEPLOY_SA" \
--project="$PROJECT_ID" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/${REPO}"
现在有两层防护在守卫同一扇门。提供商条件在交换时拒绝外部令牌,而成员绑定则限制了哪些身份可以在你的身份池内部模拟此服务账号。也要保持服务账号本身的权限最小化。如果它只需要部署到 Cloud Run,就只授予它所使用的 Cloud Run 和 Artifact Registry 角色,不要授予更广泛的权限。服务账号的最小权限原则可以限制一旦 CI 逻辑被诱骗运行意外内容时可能造成的损害。
工作流侧
在 GitHub 侧,你需要一个权限和一个身份验证步骤。权限 id-token: write 允许作业请求 OIDC 令牌。如果没有它,身份验证步骤会失败并显示一个令人困惑的错误,这是人们最常忘记的事情。
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
service_account: ${{ vars.GCP_DEPLOY_SA }}
- uses: google-github-actions/setup-gcloud@v2
- run: |
gcloud run deploy my-service \
--image "$IMAGE" \
--region asia-northeast3
请注意,这两个输入来自 vars,而不是 secrets。这正是关键所在。这两个值都不是敏感信息。提供商名称和服务帐户电子邮件是标识符,而不是凭据,因此它们作为纯文本的仓库变量存在,任何审查工作流的人都可以读取。
通过 CLI 设置一次:
gh variable set GCP_WIF_PROVIDER \
--body "projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/providers/github-provider"
gh variable set GCP_DEPLOY_SA --body "$DEPLOY_SA"
auth 操作会请求 GitHub OIDC 令牌,将其发送给提供商,接收一个短期的 Google 访问令牌,并写入 setup-gcloud 和 SDK 会自动获取的凭据。auth 之后的每一步都经过身份验证,而仓库中没有任何地方存有密钥。
安全权衡的并列比较
这样做的好处不仅仅是整洁。它改变了攻击者在泄露事件中能获取到的东西,也改变了你需要维护的内容。
| 属性 | 服务账号密钥 | 工作负载身份联合 |
|---|---|---|
| 凭据生命周期 | 直到手动删除,无过期时间 | 分钟级别,每次运行生成 |
| 泄露的密钥会暴露什么 | 完整的 SA 访问权限,无限期 | 什么都不会,因为没有存储的凭据 |
| 信任范围 | 任何持有该文件的人 | 你指定的一个仓库和分支 |
| 轮换 | 手动,容易忘记 | 无需轮换,令牌是短暂的 |
| 存储在 GitHub 中的密钥 | JSON 密钥 | 无,只有公共标识符 |
| 审计 | 较弱,密钥不记录来源 | 令牌声明中记录了仓库、引用和工作流 |
短生命周期的令牌是其核心。一个几分钟后就过期的 GitHub 令牌和一个紧接着也过期的 Google 令牌,在运行结束后几乎毫无价值。没有可供窃取的持久性凭证。仓库和分支的作用域限定意味着,你组织中其他地方的泄露,或是恶意的 fork,都无法借用这个身份,因为它们的令牌携带了不同的 repository 声明,无法通过条件检查。而且,由于你没有存储任何密钥,也就没有什么需要轮换的,这会悄悄地从你的运维工作中移除一整个重复性的任务。
坦白说,成本在于设置的复杂性。使用密钥只需要两个命令:创建、下载。而身份联合则需要一个池、一个带有措辞严谨的条件的提供者、一个成员绑定以及两个工作流输入。特别是条件(condition)部分,很容易在不经意间设置得过于宽松,而一个宽松的条件表面上看起来也能正常工作。要预留出时间来精确地编写它,并测试来自不同分支的令牌确实会被拒绝。然而,一旦首次设置完成,后续的维护成本就接近于零,因为没有需要照看的密钥了。
何时服务账号密钥仍是简便之选
对于部署到生产环境的 CI 而言,身份联合是正确的默认选项,但它并非没有成本,并且有少数情况确实更适合使用密钥。
如果调用方不是支持 OIDC 的身份提供商,身份联合便没有可信任的对象。某个随机机器上的 cron 作业、开发者笔记本电脑上的脚本或没有令牌颁发者的旧系统,都无法提供交换所需的签名断言。将这些系统桥接到工作负载身份池中通常得不偿失,而一个范围受限、轮换周期短的密钥可能是一个务实的选择。
一个没有真实数据、生命周期只有几天的用完即弃的沙盒项目是另一种情况。当整个项目下周就会被删除时,密钥泄露的爆炸半径很小,并且配置的投入收效甚微。针对模拟器进行本地开发完全不需要云凭据,因此两种方法都不适用。
界限大致如此。如果工作负载运行在能够颁发 OIDC 令牌的地方(例如 GitHub Actions、GitLab CI 和大多数现代平台),那么就应该优先选择身份联合并删除密钥。如果它运行在没有可信令牌颁发者的地方,或者项目是一次性的,那么一个范围严格受限、轮换周期短的密钥就是一个合理的选择。然而,对于使用 GitHub Actions 部署到 GCP 的常见情况,使用密钥能让你在当下节省几分钟时间,但只要它存在,就会成为一项持续存在的风险。身份联合需要一次性投入一个下午的更长时间,但之后便不再会给你带来麻烦。