How to Build Ephemeral PR Preview Environments on GKE with GitHub Actions, Ingress, and Automatic TLS

TL;DR · AI 摘要
本文介绍如何使用GitHub Actions、Ingress和自动TLS在GKE上构建临时PR预览环境,实现每个PR独立部署和自动清理。
核心要点
- 每个PR触发GitHub Actions构建独立Kubernetes命名空间(如pr-42)
- 通过Ingress控制器实现pr-42.preview.yourdomain.com域名自动路由
- 使用Workload Identity Federation替代服务账户密钥提升安全性
结构提纲
按章节快速跳转。
- §引言
揭示传统staging环境共享导致的版本混乱问题及临时环境解决方案
- ·核心机制
GitHub Actions触发构建流程,推送镜像到Artifact Registry
- ›基础设施
- ·安全设计
采用Workload Identity Federation替代服务账户密钥
- ›自动清理
定时任务确保未正确删除的环境被清理
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- 临时PR预览环境构建
- GitHub Actions
- 触发构建流程
- GKE
- 命名空间隔离
- 资源配额控制
- Ingress
- 自动TLS证书
- 域名路由
- Terraform
- 安全设计
- 自动清理
金句 / Highlights
值得收藏与分享的关键句。
每个PR拥有独立环境,避免版本混乱和配置漂移
单个Ingress控制器通过主机名路由所有环境,成本降低90%
Workload Identity Federation使CI/CD无需存储服务账户密钥
如何使用 GitHub Actions、Ingress 和自动 TLS 在 GKE 上构建临时的 PR 预览环境
2026年10月15日
/
Amina Lawal
每个交付软件的团队最终都会搭建预发布环境。而每个搭建预发布环境的团队最终都会遇到同样的问题。
预发布环境是共享的。一个环境对应多个 Pull Request。有人部署未完成的功能进行测试,其他人又在该基础上部署新内容,第三个人运行数据库迁移。到星期四下午,没人能确定到底哪个分支的哪个版本正在运行,"在预发布环境上能工作"这句话已经完全失去了信息价值。
常见的应对方案令人熟悉:在 Slack 频道里像抢会议室一样争夺预发布环境使用权,制定轮换部署时间表,或者搭建第二个、第三个预发布环境,每个环境都比上一个更偏离生产环境配置。
有一种更优的模式,Vercel、Netlify 和 Shopify 等公司的平台团队已经将其标准化:为每个 Pull Request 提供独立的环境。
创建 PR 时,应用的新鲜独立副本会自动部署。推送新提交时,环境会自动更新。PR 合并或关闭后,整个环境会自动删除。无需排队、无需争夺、无需清理、无需配置漂移。
这就是本教程要构建的内容。从审阅者视角看,效果如下:Pull Request 上会出现一个机器人评论,附带可直接访问的实时网址。
你将构建的内容
完成本指南后,你将拥有以下系统:
- 创建 Pull Request 会触发 GitHub Actions 工作流,为该 PR 构建特定标签的容器镜像
- 镜像推送到 Google Artifact Registry
- 在 GKE 集群中创建专用 Kubernetes 命名空间(pr-42、pr-43 等),在任何部署前应用资源配额
- 应用部署到该命名空间,与所有其他 PR 环境完全隔离
- 共享的 Ingress 控制器将 pr-42.preview.yourdomain.com 路由到该命名空间,并自动颁发有效 TLS 证书
- 机器人在 Pull Request 上评论实时网址,每次推送都会更新相同评论
- 推送新提交时,环境会用更新后的代码重新部署
- 关闭或合并 PR 会删除命名空间及其所有内容
- 定时任务会清理所有遗漏的环境
你还将使用 Terraform 构建底层基础设施,包含三个关键设计决策,每个决策都有独立章节:
- 不使用服务账号密钥:GitHub Actions 通过 Workload Identity Federation 认证 Google Cloud,使用短期 OIDC 令牌验证仓库声明。目前 CI/CD 管道中最常见的安全问题是存储在 GitHub Secrets 中的服务账号 JSON 密钥,而你根本不需要这个。
- 使用单一负载均衡器而非每个 PR 一个:单个共享的 Ingress 控制器通过主机名路由所有环境,一个通配符 DNS 记录覆盖所有可能存在的主机名。原始方案为每个环境创建云负载均衡器,这会迅速产生高昂费用。
- 假设失败的清理机制:当 PR 关闭时触发清理。但定时清理任务还会删除那些 PR 已消失或超出最大存活时间的命名空间,因为即使 95% 有效的自动清理仍会有遗漏,这些遗漏最终会体现在账单上。
这是我们目标的架构:
该图示展示了同一系统中两条独立的路径。
实线蓝色路径代表自动化流程。一个拉取请求触发 GitHub Actions,通过 Workload Identity Federation 认证到 Google Cloud,将带有 PR 标签的镜像推送到 Artifact Registry,并部署到 GKE 集群内的专用命名空间中。
每个命名空间(pr-42、pr-43 等)都拥有自己的 Deployment、Service 和 Pod,与其他命名空间完全隔离。
其旁还有两个自动化组件:一个用于在拉取请求中发布和更新预览 URL 的机器人,以及一个定时清理器用于查找并删除孤儿命名空间。
虚线绿色路径代表公共流量。浏览器对预览 URL 的请求通过单个通配符 DNS 记录解析到一个 Google Cloud 负载均衡器,该负载均衡器将其转发给共享的 NGINX Ingress 控制器。该控制器读取请求的 Host 头并将其路由到正确的命名空间。cert-manager 与其并行运行,从 Let's Encrypt 请求证书,使每个环境都能获得有效的 TLS 证书。
关键细节在于未被复制的部分。一个负载均衡器、一个 DNS 记录和一个 Ingress 控制器即可服务所有环境,无论同时有多少个拉取请求处于打开状态。
目录
- 你将构建的内容
- 先决条件
- 第 1 步:设置项目结构
- 第 2 步:编写应用程序
- 第 3 步:编写 Dockerfile
- 第 4 步:使用 Terraform 配置基础设施
- 第 5 步:使用 Workload Identity Federation 设置无密钥认证
- 第 6 步:应用 Terraform 配置
- 第 7 步:手动验证生命周期
- 第 8 步:使用 Kustomize 编写 Kubernetes 清单文件
- 第 9 步:构建启动工作流
- 第 10 步:构建清理工作流
- 第 11 步:打开拉取请求并观察其运行
- 第 12 步:保留静态 IP 地址
- 第 13 步:安装 Ingress 控制器
- 第 14 步:将通配符 DNS 指向负载均衡器
- 第 15 步:安装 cert-manager 并创建 ClusterIssuer
- 第 16 步:向基础清单文件添加 Ingress
- 第 17 步:按拉取请求自定义主机名
- 第 18 步:使用 ResourceQuota 和 LimitRange 添加防护措施
- 第 19 步:更新部署工作流
- 第 20 步:在拉取请求中添加 URL 注释
- 第 21 步:构建清理器
- 第 22 步:测试整个系统
- 成本估算
- 资源清理
- 总结
- 项目文件结构
先决条件
开始之前,请确保已准备以下内容:
- 启用计费功能的 Google Cloud 项目。如果没有,请在 console.cloud.google.com 创建。运行本教程的成本约为 $5–10(具体取决于集群运行时长)。请勿跳过最后的清理部分。
- 安装并认证了 gcloud CLI。运行 gcloud auth login 进行登录,并通过 gcloud config set project YOUR_PROJECT_ID 将其指向你的项目。
- Terraform 1.5 或更高版本。使用 terraform version 命令检查版本。
- 安装了 kubectl。这是与 Kubernetes 集群交互的 CLI 工具。
- 本地安装了 Docker,用于在提交到 CI 之前测试构建。
- 你拥有的 GitHub 仓库。需要能够推送分支并创建拉取请求,因为这是触发我们构建所有内容的机制。
- 对 Docker(镜像、层、Dockerfile)、Kubernetes(Pod、Deployment、Service、Namespace)和 GitHub Actions(工作流、作业、步骤)的基本了解。你不需要是专家,但应了解这些概念。
- 安装 Ingress 控制器和 cert-manager 需要 Helm 3。请使用
helm version命令确认版本。
- 您需要拥有一个可以添加 DNS 记录的域名,注册位置无关紧要。您需要为预览环境分配一个子域名,例如 preview.yourdomain.com,所有环境都将部署在该子域名下。
关于域名要求的说明:这是项目中无法伪造的部分。Let's Encrypt 在颁发证书前需要验证您对域名的控制权,它会通过公网发起真实的 HTTP 请求进行验证。.local 域名、/etc/hosts 中的条目或非自有域名均无法通过验证。
如果没有备用域名,.dev 或 .xyz 域名每年仅需几英镑,对于此类项目非常值得拥有。
另一个需要提前注意的事项:您还需要安装 gke-gcloud-auth-plugin。gcloud 的最新版本在首次连接 GKE 集群时会提示您安装该插件。您可以提前通过以下命令安装:
gcloud components install gke-gcloud-auth-plugin第 1 步:设置项目结构
首先创建目录结构,以便您清晰了解各组件的位置:
mkdir -p pr-preview-envs/{app,terraform,k8s/base,.github/workflows}
cd pr-preview-envs
git init各目录的作用如下:
- app/:Go 应用程序及其 Dockerfile
- terraform/:基础设施即代码,包含 GKE 集群、Artifact Registry 和 Workload Identity Federation 的配置
- k8s/base/:所有预览环境的基础 Kubernetes 清单文件
- .github/workflows/:创建和销毁环境的两个 GitHub Actions 工作流
现在将项目连接到 GitHub 仓库,因为第 5 步的 Workload Identity Federation 配置需要知道仓库的完整名称(owner/repo):
git remote add origin https://github.com/YOUR_USERNAME/pr-preview-envs.git第 2 步:编写应用程序
我们需要一个可部署的应用。我们将使用 Go,原因有三:
首先,Go 可编译为单个静态链接的二进制文件。无需安装运行时或解释器,这意味着容器镜像体积非常小,而小体积镜像能显著提升镜像的推送和拉取速度,这对每个开放 PR 的每次提交构建都至关重要。
其次,Go 的 -ldflags -X 机制允许我们在构建时向二进制文件注入值。我们将利用此功能将 PR 编号和 Git 提交 SHA 直接写入应用程序,使运行中的应用能够明确告知我们它对应的构建信息。
最后,Go 的构建速度很快,而 CI 的构建时间成本不容忽视。
创建 app/main.go 文件:
package main
import (
"fmt"
"net/http"
"os"
)
// 这些值在编译时为空,将在 Docker 构建过程中通过 -ldflags -X 由链接器注入。详见 Dockerfile。
var (
prNumber = "local"
gitSha = "dev"
)
func handler(w http.ResponseWriter, r *http.Request) {
// Kubernetes 通过 Downward API 注入这些值 —— 详见 deployment.yaml
namespace := os.Getenv("POD_NAMESPACE")
podName := os.Getenv("POD_NAME")
if namespace == "" {
namespace = "not-in-kubernetes"
}
if podName == "" {
podName, _ = os.Hostname()
}
fmt.Fprintf(w, "Preview environment is live\n\n")
fmt.Fprintf(w, "PR number : %s\n", prNumber)
fmt.Fprintf(w, "Git SHA : %s\n", gitSha)
fmt.Fprintf(w, "Namespace : %s\n", namespace)
fmt.Fprintf(w, "Pod name : %s\n", podName)
}func healthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
}
func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/healthz", healthz)
fmt.Println("Server starting on port 8080...")
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
os.Exit(1)
}
}cd app
go mod init preview-app让我们逐步了解这段代码的作用以及每个部分存在的原因。
var prNumber = "local" 和 var gitSha = "dev" 是具有默认值的包级字符串变量。这些默认值是你在不经过 Docker 构建直接运行二进制文件时看到的内容。
在 CI 构建过程中,Go 链接器会用实际的 PR 编号和提交 SHA 覆盖这些变量。这是整个项目中最实用的调试功能:当你访问预览环境时,应用程序本身会告诉你它是由哪个提交构建的。无需猜测。
os.Getenv("POD_NAMESPACE") 和 os.Getenv("POD_NAME") 读取 Kubernetes 通过称为 Downward API 的机制注入到容器中的环境变量。我们将在第 8 步的部署清单中配置该功能。
Downward API 允许 Pod 无需任何 Kubernetes API 凭据即可读取自身的元数据(包括其名称、命名空间和标签)。这是应用程序了解其运行位置的最简洁方式。
根路径 / 注册了 handler 函数。它将 PR 编号、提交 SHA、命名空间和 Pod 名称写入响应。这四个信息共同回答了“我此刻正在查看的是什么?”这个问题。
/healthz 路径注册了 healthz 函数,返回 HTTP 200 和文本 ok。Kubernetes 会使用这个端点进行存活和就绪探针检测,我们将在部署清单中进行配置。
http.ListenAndServe(":8080", nil) 在 8080 端口启动服务器。如果启动失败,它会打印错误并以非零代码退出,这样 Kubernetes 就知道容器出现了问题并可以重新启动它。
在继续之前先本地测试:
go run main.go在第二个终端中:
curl http://localhost:8080由于你是在 Docker 构建之外运行,应该看到 PR number: local 和 Git SHA: dev。这是预期的本地行为。
第 3 步:编写 Dockerfile
创建 app/Dockerfile:
# -------------------------------------------------------
# Stage 1: Build
# -------------------------------------------------------
FROM golang:1.27-alpine AS builder
# 确保版本与 go.mod 文件中的版本一致
# 这些变量由 CI 管道在构建时注入
ARG PR_NUMBER=local
ARG GIT_SHA=dev
WORKDIR /app
# 首先复制 go.mod 以便 Docker 缓存依赖层
COPY go.mod .
RUN go mod download
COPY main.go .
# -w -s 去除调试符号(二进制文件体积减少约 30%)
# -X 在链接时覆盖包级字符串变量
RUN CGO_ENABLED=0 go build \
-ldflags="-w -s -X main.prNumber=${PR_NUMBER} -X main.gitSha=${GIT_SHA}" \
-o server main.go
# -------------------------------------------------------
# Stage 2: Runtime
# -------------------------------------------------------
FROM alpine:latest
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]这里发生了一些需要正确理解的关键事项。
1. -ldflags -X 注入
-X main.prNumber=${PR_NUMBER}-X 链接器标志用于在链接时设置包级字符串变量的值。语法是 -X importpath.name=value 。由于我们的变量位于 package main 中,因此导入路径只需写 main 即可。
这仅适用于源代码中已声明的字符串变量。你无法通过这种方式创建新变量,也无法注入整数或布尔值。这就是为什么 main.go 中的 prNumber 是字符串类型,即使它存储的是数字。
为什么选择这种方式而不是通过部署清单传递环境变量?因为构建元数据属于镜像本身,而不是部署。如果你通过环境变量传递,有人可以使用环境变量声称镜像 abc123 的提交是 def456,而应用会欣然接受这种谎言。将元数据烘焙到二进制文件中意味着镜像只能报告其真实信息。
CGO_ENABLED=0 标志
默认情况下,Go 可以链接到 C 库。当它这样做时,生成的二进制文件在运行时依赖于系统中存在 C 库。Alpine 使用 musl 而不是更常见的 glibc,这种不匹配是“在构建阶段正常运行,在运行阶段崩溃”错误的经典来源。
设置 CGO_ENABLED=0 会强制生成一个纯 Go 实现的完全静态二进制文件,没有任何外部库依赖。它可以在任何 Linux 系统上运行,包括一个完全空白的 scratch 镜像。
先 COPY go.mod 再 COPY main.go
Docker 按层构建镜像并缓存每一层。通过首先复制 go.mod 并在复制源代码之前运行 go mod download,我们创建了一个缓存的依赖层。
在后续构建中(请记住,此流水线会为每个开放 PR 的每个提交构建),只要 go.mod 没有变化,Docker 会完全跳过依赖下载步骤。在一个有数十个依赖的真实项目中,这会将构建时间从 20 秒减少到 3 分钟,乘以团队每次推送的次数。
非 root 用户
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser容器默认以 root 身份运行。如果攻击者在你的应用中发现漏洞,容器内的 root 权限会给他们更多升级权限的空间。创建一个非特权用户并切换到该用户可以限制影响范围。这只需你添加两行代码,没有理由不这么做。
本地构建和测试:
docker build --build-arg PR_NUMBER=99 --build-arg GIT_SHA=abc1234 -t preview-app:test .
docker run -p 8080:8080 preview-app:test然后在另一个终端执行 curl http://localhost:8080。你现在应该看到 PR number : 99 和 Git SHA : abc1234。注入已成功。
第 4 步:使用 Terraform 配置基础设施
现在我们将构建云基础设施。我们选择 Terraform 而非一系列 gcloud 命令的原因在出现问题时会变得显而易见:使用 Terraform 时,terraform destroy 会按正确顺序清理你创建的所有内容,不会留下静默计费的孤儿资源。
创建 terraform/variables.tf:
variable "project_id" {
description = "GCP 项目 ID"
type = string
}
variable "region" {
description = "GCP 区域"
type = string
default = "europe-west2"
}
variable "zone" {
description = "GCP 区域。区域集群比区域集群更便宜。"
type = string
default = "europe-west2-a"
}variable "cluster_name" { description = "GKE 集群的名称" type = string default = "pr-preview-cluster" }
variable "github_repo" { description = "GitHub 仓库地址,格式为 owner/repo,例如 amina/pr-preview-envs" type = string }
variable "machine_type" { description = "节点机器类型" type = string default = "e2-standard-2" }
variable "node_count" { description = "节点池中的节点数量" type = number default = 2 }
现在创建 terraform/main.tf 文件:
terraform { required_version = ">= 1.5" required_providers { google = { source = "hashicorp/google" version = "~> 5.0" } } }
provider "google" { project = var.project_id region = var.region }
查找项目信息以便引用其数字项目编号,
这是 Workload Identity Federation 的要求(它不接受可读性项目 ID)。
data "google_project" "current" {}
-------------------------------------------------------
启用所需 API
-------------------------------------------------------
resource "google_project_service" "apis" { for_each = toset([ "container.googleapis.com", "artifactregistry.googleapis.com", "iamcredentials.googleapis.com", "sts.googleapis.com", ])
service = each.value disable_on_destroy = false }
-------------------------------------------------------
Artifact Registry —— 用于存储预览镜像
-------------------------------------------------------
resource "google_artifact_registry_repository" "preview" { location = var.region repository_id = "pr-preview" description = "PR 预览环境的容器镜像" format = "DOCKER"
depends_on = [google_project_service.apis] }
-------------------------------------------------------
GKE 集群
-------------------------------------------------------
resource "google_container_cluster" "primary" { name = var.cluster_name location = var.zone
创建集群时使用默认节点池,随后将其移除并通过单独的节点池资源管理节点。这是标准模式 ——
它允许你在不重新创建集群控制平面的情况下调整或升级节点池。
remove_default_node_pool = true initial_node_count = 1
networking_mode = "VPC_NATIVE" ip_allocation_policy {}
在将其用于实际用途前请将此选项设为 true。
deletion_protection = false
depends_on = [google_project_service.apis] }
resource "google_container_node_pool" "primary_nodes" { name = "${var.cluster_name}-pool" location = var.zone cluster = google_container_cluster.primary.name node_count = var.node_count
node_config { machine_type = var.machine_type disk_size_gb = 50
oauth_scopes = [ "https://www.googleapis.com/auth/cloud-platform", ]
labels = { purpose = "pr-preview-envs" } }
management { auto_repair = true auto_upgrade = true } }
这里有几个需要注意的要点。
data "google_project" "current" 并没有创建任何资源。这是一个数据源,用于从提供商处读取现有信息。我们需要它是因为 Workload Identity Federation 通过项目的数字项目编号来识别项目,而不是你在控制台中输入的项目 ID。Terraform 会为你自动获取这个信息,而无需你手动查找并粘贴。
在 google_project_service 上使用 for_each:与其编写四个几乎相同的资源块来启用四个 API,for_each 会遍历一个集合,为每个项目创建一个资源。disable_on_destroy = false 表示 terraform destroy 不会关闭这些 API,这是你期望的行为,因为项目中的其他组件可能仍在使用它们。
remove_default_node_pool = true:GKE 在创建集群时会强制生成默认节点池。设置此参数会立即删除该节点池,我们则将自定义节点池定义为独立资源。这样做的优势在于,节点池的变更(如调整规模、更改机器类型、升级)不会强制触发集群资源本身的变更。
node_count = 2 with e2-standard-2:两个节点,每个配备 2 vCPU 和 8GB 内存。预览环境规模较小,但你可能会同时运行多个预览环境,加上 GKE 自身的系统 Pod。两个节点可提供充足的资源余量。如果你计划同时保持超过约八个预览环境运行,建议增加此数值。
deletion_protection = false:GKE 添加此参数是为了防止意外删除生产环境集群。我们在此教程中禁用它,因为这是你最终会删除的教程集群。若将此配置用于实际生产环境,请将其设置为 true。
第 5 步:通过工作负载身份联合设置无密钥认证
这是最关键的部分,让我们仔细分析。
服务账户密钥的问题
GitHub Actions 需要权限将镜像推送到 Artifact Registry 并部署到你的 GKE 集群。大多数教程的处理方式如下:
- 在 GCP 中创建服务账户
- 为其生成 JSON 密钥
- 将该 JSON 粘贴到名为 GCP_SA_KEY 的 GitHub 密钥中
- 在工作流中加载该密钥
虽然这种方式可行,但存在安全隐患,原因如下:
首先,密钥永远不会过期。服务账户的 JSON 密钥是一种长期凭证,除非有人明确撤销,否则一直有效。实际上这意味着它们几乎可以无限期使用,因为没人会记得定期轮换。
其次,密钥是 bearer token。任何获取该 JSON 文件的人都等同于获得了你的服务账户。没有二次验证、IP 限制或过期机制。只需复制文件,即可从世界任何地方使用它。
第三,泄露影响范围巨大。GitHub 密钥会暴露给工作流。第三方动作被入侵、构建依赖中存在恶意组件,或 pull request 中修改了工作流,都可能导致该密钥泄露。一旦泄露,攻击者将获得对云项目的持久访问权限。
这并非理论风险。泄露的服务账户密钥是云安全事件中最常见的根本原因之一。
工作负载身份联合如何解决问题
工作负载身份联合完全消除了长期密钥。其工作流程如下:
- GitHub Actions 为运行中的工作流生成短期 OIDC 令牌。该令牌由 GitHub 签名,包含经过验证的声明:运行的仓库、分支、工作流及操作者信息。
- 工作流将该令牌提交给 Google Cloud 的安全令牌服务。
- Google 会使用 GitHub 的公钥验证令牌签名,并检查声明是否符合你配置的规则(特别是令牌是否来自你的仓库)。
- 若所有信息匹配,Google 会颁发一个短期访问令牌(通常有效约 1 小时)。
- 工作流使用该令牌,且会在不久后过期。
没有任何秘密信息被存储在任何地方。没有可泄露、轮换或撤销的内容。如果有人 fork 你的仓库并运行相同的 workflow,他们生成的 OIDC 令牌会在声明中携带其仓库名称,Google 的验证会失败,他们将无法获得任何权限。
将以下内容添加到 terraform/main.tf:
-------------------------------------------------------
GitHub Actions 将 impersonate 的服务账户
-------------------------------------------------------
resource "google_service_account" "github_actions" { account_id = "github-actions-preview" display_name = "GitHub Actions — PR Preview Environments" }
推送镜像到 Artifact Registry 的权限
resource "google_project_iam_member" "artifact_writer" { project = var.project_id role = "roles/artifactregistry.writer" member = "serviceAccount:${google_service_account.github_actions.email}" }
在 GKE 中创建命名空间和部署工作负载的权限
container.developer 授予对 Kubernetes API 对象的完全访问权限,
但不包含修改集群本身的权限
resource "google_project_iam_member" "gke_developer" { project = var.project_id role = "roles/container.developer" member = "serviceAccount:${google_service_account.github_actions.email}" }
-------------------------------------------------------
工作负载身份联合
-------------------------------------------------------
resource "google_iam_workload_identity_pool" "github" { workload_identity_pool_id = "github-pool" display_name = "GitHub Actions Pool" description = "GitHub Actions OIDC 的身份池"
depends_on = [google_project_service.apis] }
resource "google_iam_workload_identity_pool_provider" "github" { workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id workload_identity_pool_provider_id = "github-provider" display_name = "GitHub OIDC Provider"
将 GitHub OIDC 令牌的声明映射到 Google 属性
attribute_mapping = { "google.subject" = "assertion.sub" "attribute.actor" = "assertion.actor" "attribute.repository" = "assertion.repository" }
关键点:如果没有这个条件,互联网上
任何 GitHub 仓库都能认证到你的身份池
attribute_condition = "assertion.repository == '${var.github_repo}'"
oidc { issuer_uri = "https://token.actions.githubusercontent.com" } }
允许特定仓库的令牌 impersonate 服务账户
resource "google_service_account_iam_member" "github_impersonation" { service_account_id = google_service_account.github_actions.name role = "roles/iam.workloadIdentityUser" member = "principalSet://iam.googleapis.com/projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github.workload_identity_pool_id}/attribute.repository/${var.github_repo}" }
让我们分解容易让人困惑的部分:
attribute_mapping:GitHub 的 OIDC 令牌包含一组声明。这个代码块告诉 Google 需要读取哪些声明,并在内部使用什么名称。assertion.sub 是令牌的主体(类似 repo:owner/name:ref:refs/heads/main )。assertion.repository 是完整的仓库名称。google.subject 是必填项,其他属性是可选的,你可以基于这些属性编写规则。
attribute_condition : 请仔细阅读本行内容两次,因为理解错误会导致严重的安全漏洞:
attribute_condition = "assertion.repository == '${var.github_repo}'"
如果没有这个条件,您的身份池将信任 GitHub OIDC 发行者,这意味着它会信任 GitHub 上的每个仓库。任何知道您的身份池资源路径的人(该路径并非机密且会出现在工作流文件中)都可以进行身份验证并伪装成您的服务账户。
此条件将信任范围限制为恰好一个仓库。Google 现在在映射 assertion.repository 时要求使用属性条件,正是由于许多人在此处犯了错误。
principalSet://... : 这是被授予 workloadIdentityUser 角色的身份。请从右到左阅读:github-pool 中任何属性.repository 等于您仓库的主体都可以伪装成此服务账户。请注意它使用的是 data.google_project.current.number,这是数字项目编号而非项目 ID。在此处使用项目 ID 会产生一个无法明确说明问题的错误,这也是 WIF 最常见的错误之一。
为什么使用 roles/container.developer 而不是 roles/container.admin?container.developer 可以对集群内的 Kubernetes API 对象进行完全访问:创建命名空间、应用部署、删除资源。container.admin 还允许修改集群本身:调整节点池大小、升级、彻底删除集群。
我们的工作流从不需要接触集群自身的配置,因此不应该拥有相关权限。这在实践中体现了最小权限原则:仅授予工作所需的权限,不提供任何额外权限。
现在创建 terraform/outputs.tf:
output "cluster_name" { value = google_container_cluster.primary.name }
output "cluster_zone" { value = var.zone }
output "artifact_registry_url" { description = "推送镜像的基础 URL" value = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.preview.repository_id}" }
output "workload_identity_provider" { description = "将此内容粘贴到 GitHub Actions 工作流中" value = "projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github.workload_identity_pool_id}/providers/${google_iam_workload_identity_pool_provider.github.workload_identity_pool_provider_id}" }
output "service_account_email" { description = "将此内容粘贴到 GitHub Actions 工作流中" value = google_service_account.github_actions.email }
output "get_credentials_command" { value = "gcloud container clusters get-credentials ${google_container_cluster.primary.name} --zone ${var.zone} --project ${var.project_id}" }
这些输出并非装饰性内容。workload_identity_provider 和 service_account_email 是您需要粘贴到 GitHub Actions 工作流中的两个值,手动构造这两个值都十分繁琐。
## 第 6 步:应用 Terraform 配置
在 terraform/ 目录内执行:
terraform init
这将下载 Google 提供商并设置后端。您应该看到 "Terraform has been successfully initialized!"(Terraform 已成功初始化!)
现在预览将要创建的内容:
terraform plan \ -var="project_id=YOUR_PROJECT_ID" \ -var="github_repo=YOUR_USERNAME/pr-preview-envs"
阅读计划输出。Terraform 会在创建任何内容之前明确告诉你它打算创建什么。你应该看到大约十几个需要添加的资源,没有需要更改或销毁的资源。
如果确认无误:
terraform apply \ -var="project_id=YOUR_PROJECT_ID" \ -var="github_repo=YOUR_USERNAME/pr-preview-envs"
当提示时输入 yes。集群创建大约需要五分钟。
提示:反复输入这些 -var 标志会很快变得厌烦。创建一个名为 terraform.tfvars 的文件,内容为 project_id = "your-project-id" 和 github_repo = "you/pr-preview-envs",Terraform 会自动读取该文件。请将该文件添加到 .gitignore 中。
完成后,Terraform 会输出你的配置信息。请保存这些信息,因为第 9 步需要用到:
artifact_registry_url = "europe-west2-docker.pkg.dev/your-project/pr-preview" service_account_email = "[email protected]" workload_identity_provider = "projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider"
任何时候都可以通过 terraform output 命令重新查看这些信息。
使用 get_credentials_command 输出中的命令将 kubectl 连接到新集群:
gcloud container clusters get-credentials pr-preview-cluster \ --zone europe-west2-a \ --project YOUR_PROJECT_ID
验证:
kubectl get nodes
你应该看到两个处于 Ready 状态的节点。
## 第 7 步:手动验证生命周期
在自动化任何操作之前,先手动执行一次。
这不是无意义的重复劳动。自动化本质上只是运行你本可以手动输入的命令的脚本。如果你不知道这些命令是什么,当自动化失败时你将无法调试它...而它通常会在演示前的晚上 11 点失败。现在花 10 分钟在这里,可以节省之后数小时的麻烦。
创建一个命名空间:
kubectl create namespace pr-test
在其中部署一个占位符:
kubectl create deployment hello \ --image=nginx:1.27-alpine \ --namespace=pr-test
检查状态:
kubectl get pods --namespace=pr-test
现在是关键部分 – 验证隔离性:
kubectl get pods --all-namespaces | grep hello
该 Pod 仅存在于 pr-test 命名空间中。default 命名空间或其他任何命名空间都无法感知到它。这就是整个项目所依赖的特性:命名空间是边界,一个 PR 的命名空间内的所有内容对其他所有命名空间都是不可见的。
现在将其删除:
kubectl delete namespace pr-test
观察发生的情况:
kubectl get namespace pr-test
几秒钟后你会看到 Terminating 状态,然后命名空间会完全消失。删除命名空间会删除其中的所有资源:部署、Pod、服务、配置映射、密钥等一切内容。只需一条命令即可完成清理。
正是这个特性使每个 PR 对应一个命名空间的方案特别适合预览环境。清理不是需要小心执行的删除序列,而是一条不会留下任何残留的命令。
## 第 8 步:使用 Kustomize 编写 Kubernetes 清单
每个预览环境部署相同的应用程序。pr-42 和 pr-43 之间的唯一差异是命名空间和镜像标签。
这正是 Kustomize 要解决的问题。你只需编写一次基础清单,然后通过叠加层(overlay)在基础之上应用小的声明式更改,而无需模板、字符串替换或 sed 脚本破坏你的 YAML。
Kustomize 已集成到 kubectl 中(这就是 -k 标志的用途),因此无需额外安装任何内容。
创建 k8s/base/deployment.yaml:
apiVersion: apps/v1 kind: Deployment metadata: name: preview-app labels: app: preview-app spec: replicas: 1 selector: matchLabels: app: preview-app template: metadata: labels: app: preview-app spec: containers:
- name: preview-app
这是一个占位符。覆盖层会用实际的镜像路径和特定于PR的标签替换它
image: preview-app ports:
- containerPort: 8080
env:
Downward API:Pod获取自己的身份信息
- name: POD_NAMESPACE
valueFrom: fieldRef: fieldPath: metadata.namespace
- name: POD_NAME
valueFrom: fieldRef: fieldPath: metadata.name livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 5 resources: requests: cpu: "50m" memory: "64Mi" limits: cpu: "200m" memory: "128Mi"
创建 k8s/base/service.yaml:
apiVersion: v1 kind: Service metadata: name: preview-app spec: selector: app: preview-app ports:
- protocol: TCP
port: 80 targetPort: 8080 type: ClusterIP
创建 k8s/base/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization
resources:
- deployment.yaml
- service.yaml
这里有一些有意为之的设计选择。
replicas: 1:生产环境会运行多个副本以确保可用性。预览环境不需要高可用性。每个环境使用一个副本,乘以所有打开的PR数量,可以保持节点数量和账单在合理范围内。
image: preview-app:这是一个没有仓库地址和标签的空白占位符。它不会直接拉取镜像。Kustomize的镜像转换器会匹配这个名字并整体替换,因此基础清单不需要知道你的仓库URL。
Downward API 环境变量块:这就是POD_NAMESPACE和POD_NAME的来源。fieldRef在运行时从Pod自身的spec中读取字段并将其暴露为环境变量。不需要API调用、凭证或服务账户。
type: ClusterIP:仅限内部访问。每个LoadBalancer类型的服务都会在Google Cloud上创建一个真实的负载均衡器,产生真实的每小时费用和公网IP。每个PR都这样做会很昂贵且浪费资源。目前我们会使用kubectl port-forward访问应用,到第13步时会使用一个共享的Ingress控制器通过一个负载均衡器为所有环境提供服务。
资源请求和限制:请求值为调度器预留容量,让调度器知道Pod需要多少资源。限制值则限制Pod可使用的资源上限。如果没有限制,一个失控的预览环境可能会耗尽节点上所有其他环境的资源。在有大量短生命周期环境频繁创建和销毁的情况下,这并非可选配置。
测试基础清单是否能正确渲染:
kubectl kustomize k8s/base
这会打印渲染后的YAML但不会实际应用。这对调试很有帮助。
## 第9步:构建启动工作流
现在将所有内容连接起来。
创建 .github/workflows/preview-deploy.yml:
name: Deploy PR Preview
on: pull_request: types: [opened, synchronize, reopened]
# 如果有人快速推送了三个提交,取消较旧的运行。
# 不启用此功能时,三个部署会同时竞争同一个命名空间
# 最后完成的部署会获胜——但这可能不是最新的提交。
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
id-token: write # 用于工作负载身份联邦,必需
pull-requests: write
env:
PROJECT_ID: YOUR_PROJECT_ID
REGION: europe-west2
CLUSTER_NAME: pr-preview-cluster
CLUSTER_ZONE: europe-west2-a
REPOSITORY: pr-preview
IMAGE_NAME: preview-app
jobs:
deploy:
name: 构建并部署预览环境
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 设置环境变量
run: |
PR_NUMBER=${{ github.event.pull_request.number }}
SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)
echo "PR_NUMBER=${PR_NUMBER}" >> $GITHUB_ENV
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
echo "NAMESPACE=pr-${PR_NUMBER}" >> $GITHUB_ENV
echo "IMAGE_TAG=pr-${PR_NUMBER}-${SHORT_SHA}" >> $GITHUB_ENV
echo "IMAGE_BASE=${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_ENV
- name: 认证到Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- name: 设置gcloud
uses: google-github-actions/setup-gcloud@v2
- name: 为Artifact Registry配置Docker
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet
- name: 构建并推送镜像
run: |
docker build \
--build-arg PR_NUMBER="${PR_NUMBER}" \
--build-arg GIT_SHA="${SHORT_SHA}" \
-t "${IMAGE_BASE}:${IMAGE_TAG}" \
./app
docker push "${IMAGE_BASE}:${IMAGE_TAG}"
- name: 获取GKE凭证
uses: google-github-actions/get-gke-credentials@v2
with:
cluster_name: ${{ env.CLUSTER_NAME }}
location: ${{ env.CLUSTER_ZONE }}
- name: 创建命名空间
run: |
# --dry-run=client | apply 使此操作具有幂等性
# 在现有命名空间上运行此命令不会报错,而是无操作
kubectl create namespace "${NAMESPACE}" \
--dry-run=client -o yaml | kubectl apply -f -
- name: 生成Kustomize覆盖文件
run: |
mkdir -p k8s/overlays/"${NAMESPACE}"
cat > k8s/overlays/"${NAMESPACE}"/kustomization.yaml <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ${NAMESPACE}
resources:
- ../../base
images:
- name: preview-app
newName: ${IMAGE_BASE}
newTag: ${IMAGE_TAG}
EOF
echo "--- 渲染后的清单 ---"
kubectl kustomize k8s/overlays/"${NAMESPACE}"
- name: 部署
run: kubectl apply -k k8s/overlays/"${NAMESPACE}"
- name: 等待部署完成
run: |
kubectl rollout status deployment/preview-app \
--namespace="${NAMESPACE}" \
--timeout=180s
- name: 显示部署详情
run: |
echo "命名空间: ${NAMESPACE}"
kubectl get pods,svc --namespace="${NAMESPACE}"添加两个 Secret
前往 GitHub 上的仓库,进入 Settings(设置),选择 Secrets and variables(密钥和变量),然后选择 Actions(操作),点击 New repository secret(新建仓库密钥)。添加以下内容:
Secret 名称
值
WIF_PROVIDER来自 Terraform 的输出
workload_identity_providerWIF_SERVICE_ACCOUNTservice_account_email这两个都不是凭证。提供者路径和服务账户邮箱是标识符,而非密钥。它们本身无法授予任何权限,因为实际控制访问的是 OIDC 令牌和属性条件。将它们存储为密钥只是为了整洁性。请根据你的环境配置,更新 workflow 的 env: 块中的 PROJECT_ID 和区域值。
理解工作流
on: pull_request: types: [opened, synchronize, reopened]:这三个是不同的触发器。
- opened 在 PR 首次创建时触发。
- synchronize 在分支上的每次后续推送时触发,这会保持环境的最新状态。
- reopened 用于处理被关闭后重新打开的 PR。如果没有这个触发器,该 PR 将拥有一个已被销毁的环境,且无法重新创建。
concurrency:这个配置能节省实际调试时间。如果连续快速推送三次提交,会同时触发三个工作流运行,目标都是同一个命名空间。它们会相互竞争,最后一个完成的会胜出,而这未必是最新代码。一个快速的早期构建可能超过一个较慢的后续构建,导致你的预览环境在 PR 显示绿色通过时却在运行过时的代码。通过 PR 编号分组并设置 cancel-in-progress: true,可以确保只有最新的运行会保留下来。
permissions: id-token: write:没有这一行,其他任何操作都无法正常工作。正是这一行允许工作流从 GitHub 请求 OIDC 令牌。如果遗漏这一行,google-github-actions/auth 会因无法获取 ID 令牌而报错。这是 WIF 配置中最常见的错误。
$GITHUB_ENV:将 KEY=value 写入此文件,可以使变量在后续所有步骤中可用。但不是当前步骤,因为在一个步骤中写入的变量无法在同一个步骤中后续读取。这就是为什么设置步骤需要单独放在工作流的最开始。
github.event.pull_request.head.sha:注意使用这个而不是 github.sha。在 pull_request 事件中,GitHub 会检出一个合并提交。这是一个临时提交,将你的分支与基准分支合并。github.sha 指向这个合并提交,它在你的仓库历史中并不存在,查找时会让人困惑。head.sha 是 PR 分支上的实际最后一次提交,这也是开发者期望看到的内容。
--dry-run=client -o yaml | kubectl apply -f - 是一个值得借鉴的小技巧。如果命名空间已存在,kubectl create namespace 会失败,这会导致第一次推送后所有后续推送都失败。此流程会生成命名空间的 YAML 文件而无需连接集群,然后通过 apply 命令进行处理:如果命名空间不存在则创建,如果已存在则不做任何操作。这种设计天生具有幂等性。
Heredoc 覆盖层:而不是为每个 PR 提交一个覆盖层(这不可能,因为事先不知道 PR 编号),工作流会在运行时动态生成一个覆盖层,并在 runner 销毁时丢弃。这是为临时环境设计的临时配置。
kubectl rollout status 是使工作流程诚实的关键。如果没有它,当 kubectl apply 被 API 服务器接受时,作业就会立即显示成功,这甚至发生在任何 Pod 启动之前。一个不断崩溃的镜像仍然会显示绿色对勾。rollout status 会阻塞直到 Pod 实际准备就绪,或在 180 秒后失败。绿色表示正常运行。
关于 Forked Pull Request 的说明
在将此内容部署到任何公共环境之前,有件事需要了解:来自 fork 的 Pull Request 默认不会获得 id-token: write 权限。GitHub 会阻止这种权限,以防止陌生人通过打开 PR 盗取你的云凭证。
对于私有仓库或个人项目,这不会影响你。但对于接受外部贡献的公共仓库,你需要采用基于 workflow_run 的模式:在不受信任的构建完成后,从基础仓库运行特权工作流。这超出了本手册的范围,但如果你正在开源项目中部署此方案,请不要跳过这一步。
步骤 10:构建拆除工作流程
启动环境只是价值的一半。一个永远不会消失的环境相当于云账单上的缓慢泄漏。
创建 .github/workflows/preview-teardown.yml:
name: Tear Down PR Preview
on:
pull_request:
types: [closed]
permissions:
contents: read
id-token: write
env:
PROJECT_ID: YOUR_PROJECT_ID
CLUSTER_NAME: pr-preview-cluster
CLUSTER_ZONE: europe-west2-a
jobs:
teardown:
name: Delete preview namespace
runs-on: ubuntu-latest
steps:
- name: Set namespace
run: echo "NAMESPACE=pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- name: Get GKE credentials
uses: google-github-actions/get-gke-credentials@v2
with:
cluster_name: ${{ env.CLUSTER_NAME }}
location: ${{ env.CLUSTER_ZONE }}
- name: Delete namespace
run: |
echo "Deleting namespace ${NAMESPACE}"
kubectl delete namespace "${NAMESPACE}" \
--ignore-not-found=true \
--wait=false
- name: Confirm
run: |
echo "Preview environment for PR #${{ github.event.pull_request.number }} torn down."这里有几个细节比表面看起来更重要。
首先,types: [closed] 同时涵盖合并操作。GitHub 没有单独的 merged 事件类型。合并 Pull Request 会触发 closed 事件并带有 merged: true 的 payload 字段。因此这个单一触发器同时处理 "成功合并" 和 "被放弃并关闭" 两种情况。两者都应该拆除环境,因此无需区分它们。
其次,--ignore-not-found=true:如果命名空间不存在(可能部署失败,或有人手动删除了它),kubectl delete 通常会以错误退出并标记工作流为红色。这是一个误报:期望的最终状态是 "命名空间不存在",而它确实不存在。此标志使步骤在无事可做时成功。
最后,--wait=false:命名空间删除是异步操作。Kubernetes 会将其标记为 Terminating,然后逐步删除内部所有资源。等待此过程完成可能会让 CI runner 等待一分钟或更久,没有任何好处。直接发起删除操作,让集群自行完成即可。
步骤 11:打开 Pull Request 并观察其运行
提交所有更改并推送:
git add .
git commit -m "Add PR preview environment infrastructure"
git push -u origin main现在创建一个分支并进行可见的更改:
git checkout -b test-preview编辑 app/main.go 文件。修改处理器输出的第一行:
fmt.Fprintf(w, "Preview environment is live — testing the pipeline\n\n")提交并推送:
git add .
git commit -m "Test preview environment"
git push -u origin test-preview在 GitHub 上打开一个拉取请求,然后前往 Actions 标签页。你应该会看到 "Deploy PR Preview" 正在运行。
按顺序观察各个步骤:在没有密钥的情况下认证成功,镜像构建并推送,命名空间被创建,Kustomize 渲染覆盖层(你会看到完整解析后的清单被打印),部署应用,rollout status 等待直到 Pod 就绪。
当状态变为绿色后,从终端验证:
kubectl get namespaces | grep pr-你应该会看到你的 pr-1(或你的 PR 获得的其他编号)。
kubectl get pods --namespace=pr-1现在访问应用程序:
kubectl port-forward --namespace=pr-1 service/preview-app 8080:80你应该会看到:
Preview environment is live — testing the pipeline
PR number : 1
Git SHA : a1b2c3d
Namespace : pr-1
Pod name : preview-app-7d9f8b6c4-xk2mp应用程序正在报告在构建时嵌入到其二进制文件中的 PR 编号和提交 SHA,以及通过 Downward API 发现的命名空间。所有这些值都是由管道的不同部分解析的,它们都一致。这就是整个链路正常工作的证明。
现在向同一分支推送另一个提交,并观察环境原地更新。
然后关闭拉取请求并观察清理工作流触发。
kubectl get namespace pr-1终止后,就消失了。
你已拥有的内容与仍需完善之处
暂停一下,进行盘点,因为基础设施的艰苦工作已经完成。现在一个拉取请求会生成一个真实且隔离的环境,无需任何手动操作,关闭它会删除所有痕迹。这就是整个生命周期的自动化过程。
但再仔细看一下你如何到达这里:
这需要安装 kubectl,拥有集群的凭证,使用它们的权限,以及知道哪个命名空间对应哪个 PR。这对于构建管道的人而言是可行的,但对于检查间距更改的设计师、审查文案编辑的产品经理,或在签署前点击流程的 QA 工程师来说毫无用处。
预览环境的全部意义在于任何人都可以查看更改。一个只有工程师才能访问的环境实际上并不是真正的预览环境,它只是一个调试工具。
本教程的其余部分将弥补这一差距,并确保结果可以安全地持续运行。首先,我们将为每个环境设置一个公共的 HTTPS URL。然后我们将构建防护措施,并通过清理工作防止其成为云账单上一个安静的条目。
第12步:保留一个静态IP地址
在安装任何内容之前,你需要保留一个外部IP地址。
这很重要,因为当你安装Ingress控制器时,它会创建一个类型为LoadBalancer的Kubernetes服务,Google Cloud会为其分配一个带有公共IP的真实负载均衡器。默认情况下,这个IP是临时的。如果服务被删除并重新创建,你会得到一个不同的IP。
你的通配符 DNS 记录指向该 IP。如果 IP 发生变化,所有预览环境将同时失效,直到有人告诉你链接已失效才会发现。先保留 IP 可确保它归属你的项目而非负载均衡器,并且无论对集群做什么操作它都能保持不变。
# -------------------------------------------------------
# Ingress 控制器的静态外部 IP
# -------------------------------------------------------
resource "google_compute_address" "ingress" {
name = "pr-preview-ingress-ip"
region = var.region
address_type = "EXTERNAL"
depends_on = [google_project_service.apis]
}并将输出添加到 terraform/outputs.tf:
output "ingress_ip" {
description = "将你的通配符 DNS 记录指向此地址"
value = google_compute_address.ingress.address
}如果你的 API 列表中还没有 compute.googleapis.com,也需要将其添加进去。更新你的 google_project_service 资源中的 for_each 集合:
for_each = toset([
"container.googleapis.com",
"artifactregistry.googleapis.com",
"iamcredentials.googleapis.com",
"sts.googleapis.com",
"compute.googleapis.com",
])应用配置:
cd terraform
terraform apply \
-var="project_id=YOUR_PROJECT_ID" \
-var="github_repo=YOUR_USERNAME/pr-preview-envs"记录返回的 IP:
terraform output ingress_ip记下来。接下来的两个步骤中你会用到两次这个 IP。
重要:这必须是与集群相同区域的区域地址,这就是 region = var.region 的作用。Google Cloud 还提供全局地址,用于全局 HTTP(S) 负载均衡器。
ingress-nginx 创建的类型为 LoadBalancer 的 Service 是区域负载均衡器,无法绑定全局地址。混淆这两者会导致 Service 永远处于 Pending 状态,并出现无法明确解释原因的错误。
第 13 步:安装 Ingress 控制器
Ingress 是一个描述 HTTP 路由规则的 Kubernetes 资源:"此主机名的请求转发到该 Service"。但 Ingress 资源只是一个描述。必须有实际读取这些规则并路由流量的组件,这个组件就是 Ingress 控制器。
我们将使用 ingress-nginx,这是一个基于 NGINX 的社区维护控制器。它以 Pod 形式在你的集群中运行,监视 Kubernetes API 的 Ingress 资源,并在每次更改时重新编写自己的 NGINX 配置。
对预览环境而言,这个架构的关键点在于一个控制器可以服务所有命名空间。每个预览环境创建自己的 Ingress 资源。控制器会检测到它,为该主机名添加路由规则,并将流量发送到正确的 Service。控制器不关心存在多少个环境。
这与我们原本可能采取的方式形成鲜明对比。如果每个预览环境都使用 LoadBalancer 类型的 Service,每个 PR 都会创建自己的 Google Cloud 负载均衡器,每个负载均衡器都有每小时费用和自己的 IP 地址,DNS 需要知道这些地址。如果有 10 个打开的 PR,就会有 10 个负载均衡器。而使用共享的 Ingress 控制器,10 个打开的 PR 只需要 1 个。
使用 Helm 安装:
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo updatehelm install ingress-nginx ingress-nginx/ingress-nginx \ --namespace ingress-nginx \ --create-namespace \ --set controller.service.loadBalancerIP="YOUR_STATIC_IP" \ --set controller.service.externalTrafficPolicy=Local
将 YOUR_STATIC_IP 替换为第 12 步中获取的地址。
在继续之前,我们来分解这些参数的含义:
--namespace ingress-nginx --create-namespace:控制器拥有自己的命名空间,与任何预览环境隔离。这是共享的基础设施,不应被预览环境的清理操作影响。
controller.service.loadBalancerIP:指示 GKE 将负载均衡器绑定到你预留的特定地址,而不是分配新的地址。这是使 DNS 记录保持稳定的参数。
controller.service.externalTrafficPolicy=Local:保留客户端的真实源 IP。如果没有这个参数,Kubernetes 可能会将请求路由到中间节点,当请求到达 NGINX 时,源 IP 会被重写为节点的内部地址。你的访问日志会显示集群 IP 而非真实访客 IP。
权衡是流量只会到达实际运行控制器 Pod 的节点,这在当前场景中是可接受的。
观察外部 IP 是否被分配:
kubectl get service ingress-nginx-controller \ --namespace ingress-nginx \ --watch
Google 部署负载均衡器时,会显示 <pending> 状态约 1-2 分钟,随后会显示第 12 步中的静态 IP。当 IP 出现后按 Ctrl+C 停止观察。
验证控制器状态:
kubectl get pods --namespace ingress-nginx
## 第 14 步:将通配符 DNS 指向负载均衡器
这一步大约需要 2 分钟,是使整个模式具备扩展性的关键环节。
前往你域名的 DNS 管理页面,创建一条 A 记录:
字段
类型
A
名称 / 主机
*.preview
静态 IP(来自第 1 步)
TTL
300(或你提供商允许的最低值)
这就是整个项目的 DNS 配置。
之所以只需要一条记录,是因为通配符 DNS 记录可以匹配该位置的任意标签。*.preview.yourdomain.com 会解析 pr-1.preview.yourdomain.com、pr-42.preview.yourdomain.com 和 pr-99999.preview.yourdomain.com 等所有子域名,全部指向同一个 IP,且无需为每个子域名单独创建记录。
这一点值得特别注意,因为这是保持系统简洁的设计决策。显而易见的方案是让 CI 管道在 PR 打开时创建 DNS 记录,在 PR 关闭时删除记录。这意味着管道中需要存储 DNS API 凭据,增加了故障点、泄露风险和孤儿资源的来源。
通配符方案消除了所有这些工作:DNS 只需手动配置一次,之后无需再处理。
路由到正确环境的逻辑不在 DNS 层实现。每个主机名都解析到同一个负载均衡器,Ingress 控制器通过读取每个请求的 Host 头来决定哪个命名空间的 Service 应该处理该请求。
验证记录是否已生效:
dig +short anything-at-all.preview.yourdomain.com
你应该会收到静态 IP 的返回。请注意子域名无需预先存在,这正是通配符的作用。
如果未收到任何响应,请等待几分钟后重试。DNS 传播通常在低 TTL 设置下很快完成,但并非瞬时生效。
## 第 15 步:安装 cert-manager 并创建 ClusterIssuer
现在开始配置 HTTPS。
cert-manager 是一个 Kubernetes 控制器,用于自动化证书的完整生命周期管理。它会监控带有颁发机构注解的 Ingress 资源,向证书颁发机构申请证书,验证你对域名的控制权,将证书存储在 Kubernetes Secret 中,并在证书到期前自动续期。你不会遇到定时任务、手动续期或凌晨 3 点证书过期的事故。
安装方法:
helm repo add jetstack https://charts.jetstack.io helm repo update
helm install cert-manager jetstack/cert-manager \ --namespace cert-manager \ --create-namespace \ --version v1.21.2 \ --set crds.enabled=true
--set crds.enabled=true 会安装 cert-manager 的自定义资源定义(Custom Resource Definitions):Certificate、Issuer、ClusterIssuer、CertificateRequest 等。这些定义通过新增资源类型扩展了 Kubernetes API。缺少这些定义时,cert-manager 将无法监控任何资源。(如果你正在参考使用 installCRDs=true 的旧教程,请注意该参数在 v1.15 版本中已更名为 crds.enabled)
验证安装状态:
kubectl get pods --namespace cert-manager
你应该看到三个 Pod:控制器、webhook 和 cainjector。
### 创建 ClusterIssuer
Issuer 告诉 cert-manager 从哪里获取证书。ClusterIssuer 是具有集群范围作用域的相同概念,这是我们所需要的。命名空间级别的 Issuer 需要在每个预览命名空间中重复创建。
创建 k8s/cluster/letsencrypt-staging.yaml:
apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: letsencrypt-staging spec: acme:
Let's Encrypt 的测试环境。来自该服务器的证书不被浏览器信任,但速率限制更宽松。调试时使用。
server: https://acme-staging-v02.api.letsencrypt.org/directory email: [email protected] privateKeySecretRef: name: letsencrypt-staging-account-key solvers:
- http01:
ingress: ingressClassName: nginx
和 k8s/cluster/letsencrypt-prod.yaml:
apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: letsencrypt-prod spec: acme: server: https://acme-v02.api.letsencrypt.org/directory email: [email protected] privateKeySecretRef: name: letsencrypt-prod-account-key solvers:
- http01:
ingress: ingressClassName: nginx
应用这两个配置:
kubectl apply -f k8s/cluster/letsencrypt-staging.yaml kubectl apply -f k8s/cluster/letsencrypt-prod.yaml
确认状态:
kubectl get clusterissuer
两者都应该显示 READY: True。
### 为什么要创建两个 Issuer?
这不是冗余设计,而是为了避免令人沮丧的下午。
Let's Encrypt 对生产环境实施严格的速率限制。这里关键的限制是每周每个注册域名最多发放 50 个证书。还有另一个限制是每周相同主机名集合的重复证书最多 5 个。
在配置过程中,你很可能会出现配置错误。Ingress 注解可能错误,HTTP-01 挑战可能无法正确路由,或者 DNS 记录可能未正确传播。每次失败尝试都会消耗你的配额,一旦达到限制,你将被封锁一周且无法申诉。
暂存签发者具有宽松得多的限制。其证书由不受信任的根证书签名,因此浏览器会显示警告,但该警告本身恰恰证明了流程有效。如果你看到提示信息中包含 "Let's Encrypt Staging",说明一切运作正常,唯一剩下的步骤就是切换签发者。
在流程完全打通之前,请使用 letsencrypt-staging,之后再切换至 letsencrypt-prod。
### HTTP-01 挑战的工作原理
理解这一点很重要,因为这是出现问题时最容易出错的环节。
当 cert-manager 为 pr-42.preview.yourdomain.com 申请证书时,Let's Encrypt 需要验证你确实控制该主机名。使用 HTTP-01 解决方案时,流程如下:
- Let's Encrypt 向 cert-manager 提供一个随机令牌。
- cert-manager 创建临时 Pod、Service 和 Ingress 规则,在 http://pr-42.preview.yourdomain.com/.well-known/acme-challenge/<token> 路径下提供该令牌。
- Let's Encrypt 从公共互联网向该 URL 发起真实 HTTP 请求。
- 如果令牌匹配,域名控制即被验证,证书将被签发。
- cert-manager 删除临时资源并将证书存储在 Secret 中。
要使这个流程正常工作,必须满足三个条件,这些也是出现问题时需要检查的三个关键点:DNS 必须将主机名解析到你的负载均衡器,80 端口必须可以从互联网访问(不要在负载均衡器层面将所有流量重定向到 HTTPS,挑战需要的是明文 HTTP),Ingress 控制器必须正确路由流量。
### 关于扩展性的说明
HTTP-01 每个主机名只签发一个证书,而每个预览环境都有唯一的主机名。这意味着每个 Pull Request 对应一个证书。
在 Let's Encrypt 每周 50 个证书的限制下,这对个人项目或小型团队来说是足够的。如果团队每周提交超过 50 个 PR,就会触及上限。
生产环境的解决方案是为 *.preview.yourdomain.com 签发一个通配符证书,并在所有命名空间中共享该证书。通配符证书无法通过 HTTP-01 签发,需要使用 DNS-01 挑战,此时 cert-manager 通过创建 TXT 记录来证明控制权,而不是提供文件。
使用 Cloud DNS 时,cert-manager 可以通过 Google 服务账户自动完成此操作。之后可以使用 reflector 等工具将生成的 Secret 复制到每个预览命名空间。
这比本手册需要的组件更多,但当你的 PR 量达到这个规模时,这是正确的解决方案。在触及限制之前了解这个限制的存在也很重要。
## 第 16 步:向基础清单添加 Ingress
现在我们为应用添加路由。
创建 k8s/base/ingress.yaml 文件:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: preview-app annotations:
告诉 cert-manager 为 spec.tls 中列出的主机名
请求证书,使用此 ClusterIssuer。
cert-manager.io/cluster-issuer: letsencrypt-staging
证书存在后将 HTTP 重定向到 HTTPS。
nginx.ingress.kubernetes.io/ssl-redirect: "true" spec: ingressClassName: nginx tls:
- hosts:
- preview-host-placeholder
secretName: preview-app-tls rules:
- host: preview-host-placeholder
http: paths:
- path: /
pathType: Prefix backend: service: name: preview-app port: number: 80
添加到 k8s/base/kustomization.yaml:
你早在第 8 步就创建了这个文件,其中包含两个资源。将 Ingress 添加到该文件中:
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- ingress.yaml
关键点如下:
cert-manager.io/cluster-issuer:这个注解触发了整个证书工作流程。cert-manager 会监控该注解,从 spec.tls 中读取主机名,并完成后续所有操作。注意目前设置为 letsencrypt-staging,等流程验证通过后会切换为正式环境。
ingressClassName: nginx:标识哪个控制器应该处理这个 Ingress。集群可以同时运行多个控制器,此字段可避免它们争夺同一资源。旧教程使用 kubernetes.io/ingress.class 注解,该方式已弃用。
secretName: preview-app-tls:cert-manager 存储签发证书的位置。它会自动在与 Ingress 相同的命名空间中创建,因此每个预览环境都有独立的存储。
preview-host-placeholder:故意设置为无效的主机名。它在 spec.tls.hosts 和 spec.rules.host 中各出现一次,下一步会根据每个 PR 进行替换。使用明显不是真实域名的占位符可以确保补丁失败时立即出现明显错误,而不是发生难以察觉的路由错误。
pathType: Prefix with path: /:匹配主机上的所有路径。预览环境需要提供整个应用,而非子路径。
## 第17步:按 Pull Request 修补主机名
每个环境都需要在 Ingress 的两个位置替换专属的主机名。Kustomize 使用 JSON 6902 补丁实现该功能,这是一种精确操作资源特定路径的机制。
在第9步中,工作流生成了一个设置命名空间和镜像标签的覆盖配置。我们在此基础上扩展补丁块。以下是 PR 42 的完整生成 kustomization.yaml 示例:
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization
namespace: pr-42
resources:
- ../../base
images:
- name: preview-app
newName: europe-west2-docker.pkg.dev/your-project/pr-preview/preview-app newTag: pr-42-a1b2c3d
patches:
- target:
kind: Ingress name: preview-app patch: |-
- op: replace
path: /spec/rules/0/host value: pr-42.preview.yourdomain.com
- op: replace
path: /spec/tls/0/hosts/0 value: pr-42.preview.yourdomain.com
补丁语法的字面意思是:在匹配 kind: Ingress, name: preview-app 的资源中,替换 /spec/rules/0/host 的值,以及替换 /spec/tls/0/hosts/0 的值。
数组中的 0 索引表示位置。/spec/rules/0 是第一个(也是唯一)规则。/spec/tls/0/hosts/0 是第一个 TLS 块中的第一个主机名。如果后续为每个环境添加第二个主机名,需要添加另一个操作并指定索引 1。
为什么使用 JSON 6902 补丁而不是战略合并补丁?因为对对象数组进行战略合并存在歧义。Kubernetes 需要猜测你是想"替换整个列表"还是"合并到现有条目",答案取决于每个字段的合并键。对于已知位置的单个值替换,显式的 op: replace 能精确表达意图,且在路径不存在时会明确失败。
工作流会在运行时生成此文件,因此你永远不会提交每个 PR 的覆盖配置。第19步将详细说明该步骤。
## 第18步:通过 ResourceQuota 和 LimitRange 添加防护措施
在连接工作流程之前,让我们阻止预览环境对集群造成影响。
目前没有任何机制能阻止拉取请求部署一个要求32个CPU、扩展到200个副本或创建十几个LoadBalancer服务的清单。即使没有恶意意图,一个错误的合并、复制的清单或副本计数的拼写错误同样可以造成严重后果。
两个Kubernetes资源可以解决这个问题,且它们协同工作。
创建k8s/cluster/namespace-guardrails.yaml作为模板:
apiVersion: v1 kind: ResourceQuota metadata: name: preview-quota spec: hard: requests.cpu: "1" requests.memory: 1Gi limits.cpu: "2" limits.memory: 2Gi pods: "10" services: "5" services.loadbalancers: "0" persistentvolumeclaims: "2"
apiVersion: v1 kind: LimitRange metadata: name: preview-limits spec: limits:
- type: Container
应用于未指定自身资源的容器
defaultRequest: cpu: 50m memory: 64Mi default: cpu: 200m memory: 128Mi
硬性上限——请求超过此值的容器将被拒绝
max: cpu: "1" memory: 512Mi
### 各自的作用
ResourceQuota对整个命名空间的资源消耗进行总量限制。将每个Pod的请求和限制相加,总和不能超过这些数值。当某个请求会使命名空间超出配额时,API服务器会在准入阶段直接拒绝该请求,而不会进行任何调度。
services.loadbalancers: "0"这一行值得特别强调。它从结构上防止预览环境即使提交了type: LoadBalancer的清单,也无法创建Google Cloud负载均衡器。这是一个直接的财务防护措施:这个系统可能犯下的最昂贵错误现在变得不可实现。
LimitRange针对单个容器而非命名空间总量进行限制。它为未指定资源的容器设置默认值,并规定任何单个容器都不能超过的硬性上限。
### 为何必须一起部署
当命名空间中存在限制requests.cpu的ResourceQuota时,该命名空间中的每个容器都必须声明资源请求。没有这些声明的Pod会被直接拒绝,因为Kubernetes无法验证其是否符合配额,如果它不知道容器的资源需求。
这意味着单独部署ResourceQuota会破坏任何省略资源规格的清单,而大多数实际清单都省略了这些规格。
LimitRange通过提供默认值解决了这个问题。在准入阶段,没有资源规格的容器会自动注入defaultRequest和default值,从而满足配额要求。
单独部署其中一个会导致令人困惑的失败。同时部署两者时,系统对不完整的清单具有容错性,但仍保持严格的限制。
### 数值设置
上述值适合我们这样的小型应用。要调整这些数值,应从节点容量倒推计算。
两个e2-standard-2节点提供4个vCPU和16GB总内存,减去GKE为系统组件保留的约15%,即约3.4个可用vCPU。每个命名空间设置requests.cpu: "1",理论上三个预览环境就能填满集群。
我们的应用仅请求50m CPU,实际上可以容纳远多于三个的环境。但配额是命名空间可请求资源的上限,应将其设置得远低于"一个PR就能消耗所有资源"的水平。
如果预计有十个并发环境,请将每个命名空间的配额设置为可用容量的十分之一左右,或添加节点。
## 步骤 19:更新部署工作流程
以下是完整的 `.github/workflows/preview-deploy.yml`,本节新增内容标记为 # NEW。请用此版本替换步骤 9 的版本。
name: Deploy PR Preview
on: pull_request: types: [opened, synchronize, reopened]
concurrency: group: preview-${{ github.event.pull_request.number }} cancel-in-progress: true
permissions: contents: read id-token: write pull-requests: write
env: PROJECT_ID: YOUR_PROJECT_ID REGION: europe-west2 CLUSTER_NAME: pr-preview-cluster CLUSTER_ZONE: europe-west2-a REPOSITORY: pr-preview IMAGE_NAME: preview-app
NEW: 所有预览环境的基础域名
PREVIEW_DOMAIN: preview.yourdomain.com
NEW: 流程验证后切换为 letsencrypt-prod
CLUSTER_ISSUER: letsencrypt-staging
jobs: deploy: name: 构建和部署预览 runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 设置环境变量
run: | PR_NUMBER=${{ github.event.pull_request.number }} SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)
echo "PR_NUMBER=${PR_NUMBER}" >> $GITHUB_ENV echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV echo "NAMESPACE=pr-${PR_NUMBER}" >> $GITHUB_ENV echo "IMAGE_TAG=pr-${PR_NUMBER}-${SHORT_SHA}" >> $GITHUB_ENV echo "IMAGE_BASE=${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_ENV
NEW: 该环境可访问的主机名
echo "PREVIEW_HOST=pr-${PR_NUMBER}.${{ env.PREVIEW_DOMAIN }}" >> $GITHUB_ENV
- name: 认证到 Google Cloud
uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.WIF_PROVIDER }} service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- name: 设置 gcloud
uses: google-github-actions/setup-gcloud@v2
- name: 为 Artifact Registry 配置 Docker
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet
- name: 构建并推送镜像
run: | docker build \ --build-arg PR_NUMBER="${PR_NUMBER}" \ --build-arg GIT_SHA="${SHORT_SHA}" \ -t "${IMAGE_BASE}:${IMAGE_TAG}" \ ./app
docker push "${IMAGE_BASE}:${IMAGE_TAG}"
- name: 获取 GKE 凭据
uses: google-github-actions/get-gke-credentials@v2 with: cluster_name: ${{ env.CLUSTER_NAME }} location: ${{ env.CLUSTER_ZONE }}
- name: 创建命名空间
run: | kubectl create namespace "${NAMESPACE}" \ --dry-run=client -o yaml | kubectl apply -f -
NEW: 添加标签以便清理程序识别此命名空间属于我们
kubectl label namespace "${NAMESPACE}" \ app.kubernetes.io/managed-by=pr-preview \ preview.pr="${PR_NUMBER}" \ --overwrite
NEW: 在任何部署之前应用配额和限制
- name: 应用命名空间防护规则
run: | kubectl apply -f k8s/cluster/namespace-guardrails.yaml \ --namespace="${NAMESPACE}"
- name: 生成 Kustomize 覆盖
run: |
mkdir -p k8s/overlays/"${NAMESPACE}"
cat > k8s/overlays/"${NAMESPACE}"/kustomization.yaml <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ${NAMESPACE}
resources:
- ../../base
images:
- name: preview-app
newName: ${IMAGE_BASE}
newTag: ${IMAGE_TAG}
patches:
- target:
kind: Ingress
name: preview-app
patch: |-
- op: replace
path: /spec/rules/0/host
value: ${PREVIEW_HOST}
- op: replace
path: /spec/tls/0/hosts/0
value: ${PREVIEW_HOST}
- op: replace
path: /metadata/annotations/cert-manager.io~1cluster-issuer
value: ${CLUSTER_ISSUER}
EOF
echo "--- 渲染后的清单 ---"
kubectl kustomize k8s/overlays/"${NAMESPACE}"
- name: 部署
run: kubectl apply -k k8s/overlays/"${NAMESPACE}"
- name: 等待部署完成
run: |
kubectl rollout status deployment/preview-app \
--namespace="${NAMESPACE}" \
--timeout=180s
# 新增:等待 TLS 证书签发
- name: 等待证书
continue-on-error: true
run: |
echo "等待 ${PREVIEW_HOST} 的 TLS 证书..."
kubectl wait certificate/preview-app-tls \
--namespace="${NAMESPACE}" \
--for=condition=Ready \
--timeout=180s
- name: 显示部署详情
run: |
echo "预览 URL: https://${PREVIEW_HOST}"
kubectl get pods,svc,ingress,certificate --namespace="${NAMESPACE}"关于新增步骤有几个关键点需要理解。
注解路径中的 ~1:这看起来像是拼写错误,但实际上并非如此:
path: /metadata/annotations/cert-manager.io~1cluster-issuerJSON Pointer 路径使用 / 作为分隔符,因此键中的字面量 / 必须进行转义。转义序列是 ~1(~ 本身转义为 ~0)。由于注解键是 cert-manager.io/cluster-issuer,指针路径变为 cert-manager.io~1cluster-issuer。如果此处处理错误,补丁将静默地作用于不存在的路径。
命名空间标签:app.kubernetes.io/managed-by=pr-preview 这个标签使得第21步的清理程序是安全的。匹配特定命名空间模式的清理任务如果使用错误的正则表达式,可能会误删重要内容。而只操作带有自身应用标签的命名空间的任务,其最坏情况影响范围要小得多。
部署前的防护措施:ResourceQuota 必须在应用部署前就存在,而不是之后。如果在部署后才应用配额,首次部署将完全绕过配额限制,直到某资源超出未被强制执行的限制时才会发现问题。
证书等待步骤设置 continue-on-error: true:这是有意为之。证书签发依赖于 Let's Encrypt,这是一个有速率限制且偶尔响应较慢的外部服务。如果签发延迟,环境仍然会被部署且仍能通过 HTTP 访问。
因此,让整个工作流失败是错误的。我们记录问题并继续执行。下一步的 PR 评论将反映证书是否真正准备就绪。
步骤 20:在 Pull Request 上添加 URL 注释
环境已上线。现在是时候告诉人们这个消息了。
将以下步骤添加到部署任务的末尾:
- name: 在 PR 上评论预览 URL
uses: actions/github-script@v7
with:
script: |
const host = process.env.PREVIEW_HOST;
const sha = process.env.SHORT_SHA;
const ns = process.env.NAMESPACE;
// 一个隐藏标记,以便我们之后可以找到自己的评论
const marker = '<!-- pr-preview-environment -->';
const body = [
marker,
'### 预览环境已就绪',
'',
`**https://${host}**`,
'',
`提交 \`${sha}\` · 命名空间 \`${ns}\``,
'',
'<sub>此环境会在每次推送时更新,当 PR 关闭时会被删除。</sub>',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}为什么采用这种方案?
actions/github-script 是 GitHub 官方维护的,它为你提供了一个无需任何配置的认证 Octokit 客户端。虽然存在一些第三方动作可以在更少的代码行数中发布 PR 评论,但任何具有写权限的第三方动作在工作流中都会成为供应链依赖。
考虑到我们在第 5 步中花费的精力去移除长期有效的凭证,添加一个未经审计且可以写入你仓库的动作会显得很奇怪。
隐藏的标记评论是使此评论保持粘性的机制。<!-- pr-preview-environment --> 在 GitHub 的 Markdown 中渲染为空,但它确实存在于评论正文中。每次运行时,我们会列出 PR 的评论,查找包含标记的评论,并在找到时进行更新。
如果没有这个机制,一个包含 15 次提交的 PR 会生成 15 条几乎相同的机器人评论,导致线程变得难以阅读。审阅者开始完全忽略机器人评论,这就会失去其意义。一个始终保持更新的评论是实用工具和噪音之间的关键区别。
process.env.PREVIEW_HOST:在早期步骤中写入 $GITHUB_ENV 的值会在脚本中作为环境变量可用。这比直接将 ${{ }} 表达式插入 JavaScript 更加整洁,因为如果值包含引号或反引号,可能会以令人惊讶的方式出错。
context.issue.number 在 pull_request 事件中有效,因为 GitHub 将拉取请求视为一种特殊类型的议题。这就是我们使用 issues API 在 PR 上评论的原因。
该步骤需要 pull-requests: write 权限,这已经在工作流中声明。
第 21 步:构建 Reaper
关于第一部分中拆解工作流的不舒适真相:它最终会失败运行。
故障模式很普通。有人在 GitHub Actions 出现故障时关闭了一个 PR。某个分支上的工作流文件被修改,导致清理任务失败。有人在不关闭 PR 的情况下删除了分支。仓库被归档时仍有 PR 处于打开状态。GitHub 的事件传递出现短暂中断。
这些情况并不常见。但所有情况都会导致命名空间无限期运行,消耗集群容量(如果发生频率足够高,还会产生真实成本)。即使清理任务 95% 的时间都能正常工作,仍然存在资源泄漏。
解决方案是引入一个清道夫:一个定时任务,定期将当前运行状态与预期运行状态进行比对,并删除差异部分。
创建 .github/workflows/preview-reaper.yml:
name: 清理孤立的预览环境
on:
schedule:
# 每天 UTC 时间 03:00
- cron: '0 3 * * *'
# 允许从 Actions 标签手动触发
workflow_dispatch:
permissions:
contents: read
id-token: write
pull-requests: read
env:
PROJECT_ID: YOUR_PROJECT_ID
CLUSTER_NAME: pr-preview-cluster
CLUSTER_ZONE: europe-west2-a
# 即使 PR 仍处于打开状态,也删除超过此时间的环境
MAX_AGE_DAYS: 7
jobs:
reap:
name: 删除孤立的命名空间
runs-on: ubuntu-latest
steps:
- name: 认证到 Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- name: 获取 GKE 凭据
uses: google-github-actions/get-gke-credentials@v2
with:
cluster_name: ${{ env.CLUSTER_NAME }}
location: ${{ env.CLUSTER_ZONE }}
- name: 清理
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "获取打开的 Pull Request..."
OPEN_PRS=$(gh pr list \
--repo "${{ github.repository }}" \
--state open \
--limit 500 \
--json number \
--jq '.[].number')
echo "打开的 PR: ${OPEN_PRS:-none}"
echo ""
# 仅考虑我们自己创建的命名空间
NAMESPACES=$(kubectl get namespaces \
-l app.kubernetes.io/managed-by=pr-preview \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.labels.preview\.pr}{"\t"}{.metadata.creationTimestamp}{"\n"}{end}')
if [ -z "${NAMESPACES}" ]; then
echo "未找到预览命名空间。无需操作。"
exit 0
fi
NOW=$(date -u +%s)
MAX_AGE_SECONDS=$(( MAX_AGE_DAYS * 86400 ))
DELETED=0
while IFS=$'\t' read -r NS PR_NUM CREATED; do
[ -z "${NS}" ] && continue
CREATED_EPOCH=$(date -u -d "${CREATED}" +%s)
AGE=$(( NOW - CREATED_EPOCH ))
AGE_DAYS=$(( AGE / 86400 ))
REASON=""
# 规则 1: PR 已经关闭
if ! echo "${OPEN_PRS}" | grep -qx "${PR_NUM}"; then
REASON="PR #${PR_NUM} 已关闭"
# 规则 2: 环境已超过 MAX_AGE_DAYS
elif [ "${AGE}" -gt "${MAX_AGE_SECONDS}" ]; then
REASON="环境已存在 ${AGE_DAYS} 天 (最大 ${MAX_AGE_DAYS} 天)"
fiif [ -n "${REASON}" ]; then echo "DELETING ${NS} — ${REASON}" kubectl delete namespace "${NS}" \ --ignore-not-found=true \ --wait=false DELETED=$(( DELETED + 1 )) else echo "KEEPING ${NS} — PR #${PR_NUM} open, ${AGE_DAYS}d old" fi done <<< "${NAMESPACES}"
echo "" echo "Reaped ${DELETED} namespace(s)."
### GITHUB_TOKEN 从何而来?
与在第 6 步中创建的 WIF_PROVIDER 和 WIF_SERVICE_ACCOUNT 不同,GITHUB_TOKEN 并不是你手动设置的。你不会在 Secrets 页面找到它,也不应该将其添加到那里。
GitHub 在每次工作流运行开始时会生成一个新的安装令牌,将其暴露为 secrets.GITHUB_TOKEN,并在任务完成后立即撤销该令牌。该令牌的作用域仅限于运行工作流的仓库,不涉及其他内容。
### 关于 Reaper 的理解
Reaper 从 GitHub Actions 运行,而不是在集群内部运行。如果使用 Kubernetes 的 CronJob,需要将 GitHub 令牌存储为集群 Secret 以查询开放的 PR。这会引入一个长期存在于集群中的凭证,这正是我们在第 5 步中试图避免的情况。
计划任务工作流已经拥有 GITHUB_TOKEN(作用域限定、自动获取、短期有效),并且已通过工作负载身份联合认证到 GCP。无需新增任何凭证。
标签选择器是安全机制。查询条件是 -l app.kubernetes.io/managed-by=pr-preview,而不是类似 pr-* 的名称模式。这是一个有意为之的设计选择:使用名称模式匹配可能会误删某个名为 pr-routing-service 的生产命名空间。Reaper 只能看到部署工作流显式打标签的命名空间,这意味着它可能删除的对象范围仅限于它所创建的对象。
在使用此方案时要格外小心。任何清理自动化都应基于明确的白名单进行操作,而不是可能匹配到未创建对象的模式。
我们还设置了两条删除规则,用于解决两种不同问题。规则 1 用于捕获真正的孤儿命名空间:PR 已关闭但未执行清理的命名空间。规则 2 用于捕获陈旧环境:PR 已经开放三周,但最近两周未被修改,且静默占用集群资源。七天是一个合理的默认值。如果某人需要恢复环境,只需推送一次提交,环境将在几分钟内重新部署。
set -euo pipefail:-e 表示任何命令失败时脚本立即退出,-u 表示未定义变量时报错,-o pipefail 表示管道中任一阶段失败时整个管道失败(而不仅仅是最后一个阶段)。对于一个负责删除资源的脚本,静默失败是最糟糕的结果。如果出现任何问题,应立即停止执行。
grep -qx:-x 参数要求整行匹配。如果没有该参数,PR 4 可能会匹配到 PR 42,导致 Reaper 永远保留孤儿命名空间。这是一个微小但关键的参数。
workflow_dispatch 允许你从 Actions 标签手动运行 Reaper。首次几次运行时手动执行并查看输出,确认逻辑符合预期后再信任计划任务。日志会为每个命名空间打印 KEEPING 或 DELETING 以及原因,这样你可以在无人值守运行前验证逻辑是否符合预期。
## 第 22 步:整体测试
git add . git commit -m "Add Ingress, TLS, PR comments, guardrails, and reaper" git push
创建一个分支,进行明显修改,然后打开一个 Pull Request:
/think
git checkout -b test-preview-urls
编辑 app/main.go —— 修改问候语文本
git add . git commit -m "Test preview URLs" git push -u origin test-preview-urls
打开 PR 并查看 Actions 标签页。
当它完成时,应该会出现包含你 URL 的机器人评论。打开该链接。
由于你仍在使用暂存颁发者,浏览器会显示证书警告。类似 "你的连接不安全",颁发者为 "Let's Encrypt Staging"。
这是成功状态。这意味着 DNS 解析成功,负载均衡器路由正确,Ingress 匹配了主机名,HTTP-01 挑战完成,并且已颁发证书。所有流程都正常工作。证书只是来自一个不受信任的测试颁发机构。
点击高级,忽略警告,并确认应用程序能正确显示 PR 编号和命名空间。
从命令行检查证书:
kubectl get certificate --namespace=pr-2
确保使用你自己的 pr 编号
你希望看到 READY: True。如果显示 False,请检查问题所在:
kubectl describe certificate preview-app-tls --namespace=pr-2 kubectl get challenges --all-namespaces
一个始终未解决的待处理挑战通常意味着 DNS 问题或 80 端口不可达。使用以下命令确认:
curl -I http://pr-2.preview.yourdomain.com/.well-known/acme-challenge/test
你希望看到 NGINX 的任何 HTTP 响应。404 是可以接受的,因为它证明路由有效。连接超时意味着请求未到达你的集群。
### 切换到生产证书
当暂存流程端到端工作正常后,修改工作流中 env: 块的一行:
CLUSTER_ISSUER: letsencrypt-prod
在现有 PR 分支上进行此修改并推送。你不需要先关闭拉取请求或合并到主分支。你也可以修改 main.go 文件中的消息以确认一切正常。
向 PR 推送新提交。环境将重新部署,cert-manager 会请求真实证书,这次浏览器会显示锁形图标。
在隐私浏览窗口中打开 URL 进行检查。你的浏览器可能缓存了针对该主机名的暂存证书例外,普通标签页可能显示看似可信的页面,但实际上只是记忆的覆盖并仍显示不安全。
### 验证防护机制
确认配额已激活:
kubectl describe resourcequota preview-quota --namespace=pr-2
你会看到每个资源的已使用量与硬限制的对比。然后尝试突破限制:
kubectl scale deployment/preview-app --replicas=50 --namespace=pr-2 kubectl get events --namespace=pr-2 | grep -i quota
部署会部分扩展后停止,事件会解释超出配额的情况。这就是防护机制的作用:命名空间受到限制,而集群的其他部分不受影响。
### 验证清理和收割者
关闭拉取请求。清理工作流触发,命名空间进入 Terminating 状态。
然后测试收割者对不应触碰的命名空间。手动创建一个没有标签的命名空间:
kubectl create namespace pr-9999
从 Actions 标签页手动运行收割者。
它应该完全忽略 pr-9999,因为该命名空间没有 app.kubernetes.io/managed-by=pr-preview 标签。日志甚至不会提到它。
自行清理:
kubectl delete namespace pr-9999
这个测试看起来可能不显眼,但非常重要。你刚刚验证了自动化删除任务无法触碰它未创建的资源。
## 成本说明
我们应具体说明这一点,因为成本控制是支持此模式的论据中的一半。
在 europe-west2 持续运行,以下是大致的月度费用:
组件
大致成本
GKE 控制平面
约 74 美元/月(一个区域集群,免费层级在某些账户下覆盖一个集群)
2 ×
e2-standard-2节点
约 97 美元/月
区域负载均衡器
约 18 美元/月 + 数据处理
静态 IP(正在使用中)
附加时免费
Artifact Registry 存储
约 0.10 美元/GB/月
Let's Encrypt 证书
免费
GitHub Actions(公共仓库)
无论运行多少个预览环境,始终在线的基础设施大致每月 190 美元。
关键数字是不随 PR 数量扩展的部分。一个负载均衡器为所有环境服务。一个 DNS 记录覆盖所有主机名。证书免费。第 11 个预览环境的边际成本仅是其 Pod 消耗的节点容量,对于这种规模的应用,每天仅几便士。
与每个环境使用 LoadBalancer 服务的天真方法相比:10 个开放的 PR 会仅在负载均衡器上产生约 180 美元/月的费用,这还没算有人忘记清理的情况。ResourceQuota 中的 services.loadbalancers: "0" 行专门设计用来防止这种错误。
为个人项目削减持续成本有两种方式。使用抢占式节点,在 Terraform 的 node_config 块中添加 spot = true,可获得约 60-70% 的节点小时折扣。预览环境是理想的抢占式工作负载:如果节点被回收,Pod 会重新调度,最坏情况是预览环境短暂重启。或者通过计划工作流在夜间将节点池扩展到零,因为凌晨 3 点没人会审查 PR。
清理
完成时:
# 删除每个预览命名空间
kubectl delete namespaces -l app.kubernetes.io/managed-by=pr-preview
# 移除共享基础设施
helm uninstall cert-manager --namespace cert-manager
helm uninstall ingress-nginx --namespace ingress-nginx
kubectl delete namespace cert-manager ingress-nginx在运行 terraform destroy 之前先卸载 Helm 发布。Ingress 控制器的 Service 拥有一个 Terraform 不知道的 Google Cloud 负载均衡器,因为 Kubernetes 而非 Terraform 创建了它。先销毁集群可能导致该负载均衡器成为孤儿,静默地在没有任何其他内容的项目中计费。
如果你计划以后重新构建
不要运行完整的 terraform destroy 。仅拆除昂贵的部分:
cd terraform
terraform destroy
-var="project_id=YOUR_PROJECT_ID"
-var="github_repo=YOUR_USERNAME/pr-preview-envs"原因如下。当你删除 Workload Identity Pool 时,Google 不会立即删除它。它会标记为 DELETED 并保留 ID 30 天。Terraform 会忘记它,但 Google 没有。在该窗口期内重新创建堆栈会失败,提示:
Error: Error creating WorkloadIdentityPool: googleapi: Error 409: Requested entity already exists你的大部分成本来自集群及其节点。身份池、提供者、服务账户、IAM 绑定、静态 IP 和 Artifact Registry 仓库保留原地几乎不产生成本或仅几便士。之后重新构建只需 terraform apply ,你 GitHub 中的 WIF_PROVIDER 和 WIF_SERVICE_ACCOUNT 密钥仍有效。无需更新它们。
如果你想保留集群但停止为闲置节点付费,改为将节点池扩展到零:
gcloud container clusters resize pr-preview-cluster
--node-pool pr-preview-cluster-pool
--num-nodes 0
--zone europe-west2-a你仍然需要为GKE控制平面付费,因此这会降低成本但不会完全消除成本。
如果你已经完成并打算彻底清理:
cd terraform
terraform destroy
-var="project_id=YOUR_PROJECT_ID"
-var="github_repo=YOUR_USERNAME/pr-preview-envs"然后确认没有残留资源:
gcloud compute forwarding-rules list
gcloud compute addresses list这两个命令的输出应该为空,或仅包含你其他项目的资源。不要忘记删除通配符DNS记录。
结论
你构建了一个大多数工程团队要么没有、要么需要付费购买的系统。
你编写了一个Go应用程序,通过链接器标志在编译时注入自身的构建元数据,使得运行环境可以始终准确地告诉你它来自哪个提交。你使用多阶段Dockerfile将其封装,生成一个小型的、静态链接的、非root用户镜像。
你使用Terraform创建了GKE集群和Artifact Registry仓库,这意味着整个环境可以通过单个命令完全复现和销毁。你配置了Workload Identity Federation,使GitHub Actions通过短期OIDC令牌而非长期服务账号密钥认证到Google Cloud,并通过属性条件将权限严格限制到单个仓库。
你编写了一次基础的Kubernetes清单,并使用在运行时生成的Kustomize覆盖层,将相同的应用部署到不同命名空间,使用不同的镜像标签和不同的主机名处理每个拉取请求。你为每个环境配置了一个共享的Ingress控制器并分配了稳定的静态IP,使得一个负载均衡器可以服务任意数量的预览环境。你将一个通配符DNS记录指向它,这意味着新环境完全不需要DNS配置:路由通过集群内部的Host头实现。你添加了cert-manager,为每个环境自动颁发并更新有效的TLS证书,并让流水线通过一个可以实时更新的固定PR评论进行自我声明。
你通过ResourceQuota和LimitRange为每个命名空间设置资源限制,其中包含一条使最昂贵的错误在结构上不可能发生的限制。你构建了一个"清道夫"程序,用于捕获主流程遗漏的资源,且仅对能证明自己创建的命名空间进行操作。
最终结果是,每个变更的默认状态就是一个隔离的、生产环境形态的、可公开访问的环境,而不是需要开发者主动申请、在Slack频道声明或记得清理的特殊资源。
后续发展方向
你可以探索几个方向,按对真实团队体验提升程度排序:
- 为每个环境准备种子数据。我们构建的系统最大限制是应用无状态。真实应用需要数据库。常见方案包括:每个命名空间使用从清理后的快照初始化的临时数据库,或使用共享数据库配合每个环境的模式隔离。这是预览环境中最难解决的问题,也是剩余工程工作量的主要部分。
- 在预览环境中运行测试套件。一旦环境有真实URL,CI可以对其运行端到端测试,这比对模拟数据的测试能提供更有力的验证信号。
- 通过 DNS-01 的通配符证书。如第 15 步所述,当您的 PR 流量接近 Let's Encrypt 的速率限制上限时,这将消除该上限。
- 迁移到 GitOps。与其直接运行
kubectl apply,不如将生成的清单提交到 Argo CD 或 Flux 会同步的仓库。这样集群状态将具有 Git 历史记录,您的 CI 流水线再也不需要任何集群写权限。
- 预览环境指标。将命名空间接入您在其他地方使用的成本可视化工具,您就可以用具体数字回答“预览环境每月实际成本是多少”,而不是耸耸肩。
项目文件结构
pr-preview-envs/
├── app/
│ ├── main.go — 报告构建元数据的 Go HTTP 服务端
│ ├── go.mod — Go 模块定义
│ └── Dockerfile — 带 ldflags 注入的多阶段构建
├── terraform/
│ ├── main.tf — GKE、Artifact Registry、WIF、静态 IP
│ ├── variables.tf — 输入变量
│ └── outputs.tf — 集群、WIF 和入口 IP 值
├── k8s/
│ ├── base/
│ │ ├── deployment.yaml — 带探针和 Downward API 的部署
│ │ ├── service.yaml — ClusterIP 服务
│ │ ├── ingress.yaml — 带 cert-manager 注解的入口
│ │ └── kustomization.yaml — 基础 Kustomize 配置
│ └── cluster/
│ ├── letsencrypt-staging.yaml — 测试环境 ClusterIssuer
│ ├── letsencrypt-prod.yaml — 生产环境 ClusterIssuer
│ └── namespace-guardrails.yaml — ResourceQuota 和 LimitRange
└── .github/
└── workflows/
├── preview-deploy.yml — 构建、部署、证书、PR 评论
├── preview-teardown.yml — PR 关闭时删除命名空间
└── preview-reaper.yml — 定时清理孤儿资源本教程的所有源文件均可在配套的 GitHub 仓库中找到:https://github.com/Amiynarh/pr-preview-envs
合作伙伴推荐课程
(在新标签页中打开)
ADVERTISEMENT
- Udemy 上的 Azure Kubernetes Service 与 Azure DevOps 和 Terraform Kalyan Reddy Daida 4.7(满分5星)(6,523人评价) 1天4小时6分钟 初学者 证书 查看课程(在新标签页中打开)
- Coursera 上的 Gateway API 与 NGINX Fabric Gateway Mumshad Mannambeth 5周,每周1小时 中级 证书 查看课程(在新标签页中打开)
- Udemy 上的 Terraform on AWS EKS Kubernetes IaC SRE- 50 个真实世界演示 Kalyan Reddy Daida 4.7(满分5星)(2,314人评价) 1天21小时16分钟 初学者 证书 查看课程(在新标签页中打开)
- Udemy 上的 DevOps 与 Claude Code:Terraform、EKS、ArgoCD & Helm Pravin Mishra 4.5(满分5星)(101人评价) 12小时 初学者 证书 查看课程(在新标签页中打开)
Class Central 上的更多 Git 课程
→
我是 Amina Lawal,一名热爱自动化、基础设施并致力于让云计算工程普及化的 DevOps 与云工程师。我构建并参与开发了简化 Kubernetes 和数据库迁移的工具,帮助公司降低成本并高效扩展。除了编程,我还撰写关于技术、身份与人类体验交汇点的文章。目前我为 freeCodeCamp 贡献内容,并领导 G3Women,这是一个通过职业、财务和健康计划赋权女性的 NGO。
如果你读到这里,请感谢作者,让他们知道你关心他们。说声谢谢
免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者并找到工作。立即开始