← 返回蜂巢洞察

如何使用 GitHub Actions、Ingress 以及自动 TLS 功能,在 GKE 上搭建临时性的 PR 预览环境

每一个发布软件的团队最终都会搭建一个测试环境。而每一个搭建了测试环境的团队,最终都会遇到同样的问题。 测试环境是共享的。一个环境,却有大量的拉取请求。有人会部署一个尚未完成的功能来进行测试,其他人会在其基础上继续进行开发,还有第三个人会执行数据库迁移操作。到了周四下午,就没人能弄清楚到底哪个分支的哪个版本正在运行中,“在测试环境中可以正常使用”这句话已经完全失去了任何参考意义。 常见的解决办法包括:创建一个Slack频道,让大家把测试环境当作会议室来使用;制定轮换部署计划;或者再搭建第二个、甚至第三个测试环境,但每个测试环境的配置都会与生产环境越来越偏离。 其实有更好的解决方案。像Vercel

每一个发布软件的团队最终都会搭建一个测试环境。而每一个搭建了测试环境的团队,最终都会遇到同样的问题。

测试环境是共享的。一个环境,却有大量的拉取请求。有人会部署一个尚未完成的功能来进行测试,其他人会在其基础上继续进行开发,还有第三个人会执行数据库迁移操作。到了周四下午,就没人能弄清楚到底哪个分支的哪个版本正在运行中,“在测试环境中可以正常使用”这句话已经完全失去了任何参考意义。

常见的解决办法包括:创建一个Slack频道,让大家把测试环境当作会议室来使用;制定轮换部署计划;或者再搭建第二个、甚至第三个测试环境,但每个测试环境的配置都会与生产环境越来越偏离。

其实有更好的解决方案。像Vercel、Netlify和Shopify这样的公司的平台团队已经将这种做法确立为标准:**为每一个拉取请求专门分配一个测试环境**。

当你提交一个拉取请求时,你的应用程序会自动被部署到一个全新的、孤立的测试环境中;当你再次提交代码修改时,该环境会自动更新;当你合并或关闭这个拉取请求时,整个测试环境就会被删除。完全没有排队等待、资源争夺、清理工作,也不会出现配置偏离生产环境的情况。

这就是我们在本教程中要构建的内容。从审核者的角度来看,这个过程是这样的:一个机器人会在拉取请求上留言,提供一条可正常使用的URL链接。

一个GitHub拉取请求页面,上面有一条由机器人生成的评论,其中包含了可访问的URL、代码提交的SHA值以及命名空间名称,所有检查也都显示为通过。

你将构建什么

读完本手册后,你将会拥有一个可以正常使用的系统,在这个系统中:

  • 提交拉取请求会触发GitHub Actions工作流,从而生成专门为该请求准备的容器镜像。

  • 生成的镜像会被上传到Google Artifact Registry中。

  • 会在GKE集群中创建专门的Kubernetes命名空间(例如`pr-42`、`pr-43`等),并在任何内容被部署之前设置资源配额。

  • 应用程序会被部署到这些命名空间中,从而与其他拉取请求所使用的环境完全隔离。

  • 会使用共享的Ingress控制器将`pr-42.preview.yourdomain.com`这样的地址路由到相应的命名空间,并自动生成有效的TLS证书。

  • 机器人会在拉取请求上留言,提供最新的URL链接,并且每次提交代码修改时都会更新这条评论。

  • 每当有新的代码被提交时,系统会自动使用更新后的代码重新部署环境。

  • 关闭或合并拉取请求后,相应的命名空间及其内部的所有内容都会被删除。

  • 还会安排定时任务来清理那些可能被遗漏的测试环境。

  • 你还将使用Terraform来构建底层的基础设施,其中会涉及到三个关键的设计决策,每个决策都会在专门的章节中详细说明。

    1. 无需使用服务账户密钥: GitHub Actions通过Workload Identity Federation与Google Cloud进行身份验证。短暂有效的OIDC令牌会通过与仓库相关的信息进行比对来验证其合法性。而将服务账户的JSON密钥保存在GitHub的秘密存储系统中,是目前CI/CD管道中最为常见的安全问题之一,实际上你根本不需要使用这些密钥。

    2. 只需一个负载均衡器,无需为每个拉取请求都配置一个: 一个共享的Ingress控制器会根据主机名来路由流量,而一个通配符DNS记录就能覆盖所有可能存在的主机名。如果采用传统的方案,即为每个环境配置一个单独的负载均衡器,那么成本会迅速上升。

    3. 采取“以失败为前提”的清理策略: 当拉取请求被关闭时,相关资源会被自动清除。但系统还会定期删除那些对应的命名空间——无论是因为相关的拉取请求已经消失,还是因为这些命名空间的使用时间已经超过了规定的最长期限。尽管这种自动化清理机制的有效率高达95%,但仍会存在数据泄漏的情况,而这些泄漏带来的成本也会反映在账单上。

    以下是我们正在努力建立的架构:

    完整系统的架构图。GitHub Actions通过Workload Identity Federation和Artifact Registry将代码构建并部署到GKE集群中;浏览器则通过通配符DNS记录、共享的Google Cloud负载均衡器以及由cert-manager管理的NGINX Ingress控制器来访问相应的命名空间,所有这些组件都负责处理TLS加密通信。

    该架构图展示了同一系统中两条不同的数据流动路径。

    实线蓝色路径代表自动化流程:拉取请求会触发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控制器来为所有环境提供服务。

    目录

    先决条件

    在开始之前,请确保您已经准备好了以下所需物品:

    • 一个已启用计费的Google Cloud项目。如果您还没有这样的项目,可以在console.cloud.google.com上创建它。执行本教程大约需要5到10美元的费用,具体费用取决于您让集群运行的时间长度。请务必完成最后的清理步骤。

    • 已安装并完成了身份验证的gcloudCLI。请运行gcloud auth login进行登录,然后使用gcloud config set project YOUR_PROJECT_ID将CLI配置为您的项目。

    • 至少需要Terraform 1.5版本或更高版本。请通过terraform version命令检查其版本。

    • 已安装kubectl。这是用于与Kubernetes集群交互的CLI工具。

    • 在本地安装了Docker,以便在将代码提交给持续集成系统之前先进行测试。

    • 拥有一个属于自己的GitHub仓库。这个仓库必须能够用于推送分支以及接收拉取请求,因为我们将通过这些操作来推进整个开发流程。

    • 需要对Docker(镜像、层结构、Dockerfile)、Kubernetes(Pod、部署任务、服务、命名空间)以及GitHub Actions(工作流、作业、步骤)有基本的了解。虽然不需要成为这些技术的专家,但至少应该知道它们的基本概念。

    • 需要Helm 3版本,以便安装Ingress控制器和cert-manager。请通过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提交哈希值直接嵌入到应用程序中,这样运行中的程序就能清楚地知道自己当前使用的是哪个版本的代码。

    最后,Go的编译速度很快,而持续集成工具的时间成本并不低。因此,选择Go语言能够有效提高开发效率。

    现在来创建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.environ("POD_NAME")
    
    	if namespace == "" {
    	(namespace = "not-in-kubernetes"
    	}
    	if podName == "" {
    		podName, _ = os.Hostname()
    	}
    
    	fmt.Fprintf(w, "预览环境已启动\n\n")
    	fmt.Fprintf(w, "PR编号:%s\n", prNumber)
    	fmt.Fprintf(w, "Git提交哈希值:%s\n", gitSha)
    	fmtfprintf(w, "命名空间:%s\n", namespace)
    	fmt fprintf(w, "Pod名称:%s\n", podName)
    }
    
    func healthz(w http.ResponseWriter, r *http.Request) {
    	w.WriteHeader(http.StatusOK)
    	fmt.Fprintln(w, "系统运行正常")
    }
    
    func main() {
    	http.HandleFunc("/", handler)
    	http.HandleFunc("/healthz", healthz)
    
    	fmt.Println("服务器正在8080端口上运行...")
    	if err := http.ListenAndServe(":8080", nil); err != nil {
    		fmt.Fprintf(os.Stderr, "服务器错误:%v\n", err)
    		os.Exit(1)
    	}
    }
    

    在 `app/ 目录内初始化 Go 模块:

    cd app
    go mod init preview-app
    

    让我们来了解一下这段代码的作用,以及每部分代码存在的意义。

    var prNumber = "local" 和 var gitSha = "dev" 是具有默认值的包级字符串变量。如果不通过 Docker 构建直接在本地运行二进制文件,就会看到这些默认值。

    在 CI 构建过程中,Go 链接器会用实际的 PR 编号和提交 SHA 值替换这些默认值。这是整个项目中最为实用的调试功能:当应用程序运行在预览环境中时,它会明确告诉你它是根据哪个提交版本构建的,完全不需要猜测。

    os.Getenv("POD_NAMESPACE") 和 os.environ("POD_NAME") 用于读取 Kubernetes 通过所谓的 向下 API 注入到容器中的环境变量。我们将在第 8 步的部署配置文件中设置这些变量。

    向下 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 编号 : local” 和 “Git SHA : dev” 这些信息。这就是预期的本地行为。

    在非 Docker 构建环境下运行时,curl 命令显示的应用程序信息:PR编号为“local”,Git SHA为“dev”

    步骤 3:编写 Dockerfile

    创建 `app/Dockerfile` 文件:

    # -----------------------------------------------------------
    # 第一阶段:构建
    # -----------------------------------------------------------
    FROM golang:1.27-alpine AS builder
    # 确保使用的版本与你的 go.mod 文件中指定的版本一致
    
    # 这些参数会在构建过程中由持续集成管道添加进来
    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
    
    # -----------------------------------------------------------
    # 第二阶段:运行时环境配置
    # -----------------------------------------------------------
    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。

    这种做法仅适用于那些在源代码中已被声明的 string 类型变量。你不能用这种方式创建新的变量,也不能将其应用于 int 或 bool 类型。正因如此,在 main.go 中,prNumber 虽然表示一个数字,但实际上却被定义为字符串类型。

    为什么不直接通过部署配置文件传递环境变量呢?因为构建元数据属于 镜像,而不是 部署过程。如果将环境变量作为配置参数传递,有人就可以使用名为 abc123 的镜像进行部署,同时设置环境变量使其显示为 def456,这样应用程序就会显示错误的信息。而将元数据直接嵌入到二进制文件中,就能确保镜像始终能够反映其真实的状态。

    CGO_ENABLED=0 这一配置选项的作用

    默认情况下,Go语言可以链接C语言库。但这样做会导致生成的二进制文件在运行时依赖于系统的C库。Alpine操作系统使用的是 musl 库,而不是更常见的 glibc,这种不匹配往往是导致“在构建阶段正常运行,但在运行阶段出现故障”的原因。

    将 CGO_ENABLED=0 设置为默认值,就可以生成一个完全由Go语言编写、不依赖任何外部库的二进制文件。这样的程序可以在任何Linux系统上运行,甚至包括那些没有任何内置软件的 scratch 镜像。

    在复制 main.go 之前,先复制 go.mod

    Docker是分层构建镜像的,并会缓存每一层。如果我们先复制 go.mod 文件,然后运行 go mod download 命令来下载依赖库,就可以为后续的构建过程创建一个已缓存的依赖关系层。

    在后续的构建过程中(请注意,这个构建流程会针对每个开放性拉取请求中的每一次提交进行执行),只要 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编号:99” 和 “Git哈希值:abc1234” 这些信息,说明注入功能已经正常工作。

    通过curl命令显示的终端输出结果,其中包含了在构建过程中通过Docker参数注入的PR编号99和Git哈希值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_artifactRegistry_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会自动帮您获取这个编号,而无需您手动去查找并复制它。

    for_each on google_project_service:与其编写四段几乎完全相同的代码来启用四个API,for_each会遍历这些配置项,并为每一项创建一个相应的资源。设置disable_on_destroy = false意味着当执行terraform destroy命令时,这些API不会被关闭——这正是我们所需要的,因为项目中的其他组件可能仍在使用这些API。

    remove_default_node_pool = true:在创建Kubernetes集群时,GKE会默认创建一个默认节点池。将这个设置设置为true后,系统会立即删除这个默认节点池,然后我们会单独定义自己的节点池。这样做的好处是,当节点池的配置发生变化(例如调整规模、更换机器类型或进行升级)时,不会影响到整个集群资源的配置。

    node_count = 2 with e2-standard-2:这意味着会创建两个节点,每个节点配备2个vCPU和8GB的内存。预览环境通常规模较小,但你会同时运行多个预览环境,再加上GKE自身的系统Pod,因此两个节点已经足够满足需求了。如果你打算同时维持超过八个预览环境的运行,可以适当增加节点数量。

    deletion_protection = false:GKE添加了这个设置是为了防止误删生产环境中的集群。但由于这是一个用于教学的示例集群,我们会在使用结束后直接删除它,因此将这个设置设置为false是合适的。如果要将这个配置应用于实际生产环境,请将其设置为true。

    步骤5:使用Workload Identity Federation配置无密钥认证

    这一部分非常重要,因此我们需要仔细了解它的具体内容。

    服务账户密钥存在的问题

    GitHub Actions需要相应的权限才能将镜像推送到Artifact Registry,并在GKE集群中部署应用程序。大多数教学资料都是按照以下步骤来处理这个问题的:

    1. 在GCP中创建一个服务账户。

    2. 为该服务账户生成一个JSON密钥。

    3. 将这个JSON密钥保存到GitHub的秘密配置中,命名为GCP_SA_KEY。

    4. 在工作流程中加载这个密钥以便使用。

    这种做法虽然可行,但也存在安全隐患,原因如下:

    首先,这些密钥永远不会过期。服务账户的JSON密钥是一种长期有效的凭证,除非有人明确将其撤销,否则它将永远有效——因为实际上很少有人会记得定期更换这些密钥。

    其次,这些密钥属于“持证人令牌”类型。任何获得了这个JSON文件的人,就相当于拥有了你的服务账户的访问权限。因此不存在二次验证机制、IP地址限制或过期期限等因素,任何人都可以复制这个文件并在世界任何地方使用它。

    第三,这种攻击的影响范围极其广泛。 GitHub上的秘密信息可能会被工作流所利用。如果第三方服务出现安全漏洞,或者在你的构建过程中使用了恶意依赖项,又或者有人通过拉取请求修改了相关工作流,这些秘密信息就有可能被泄露出去。一旦这些信息外泄,攻击者就能持续访问你的云项目。

    这种情况并非理论上的假设——泄漏的服务账户密钥其实是导致云安全事件的最常见原因之一。

    Workload Identity Federation是如何解决这个问题的

    Workload Identity Federation会彻底消除那些长期有效的密钥。其具体工作流程如下:

    1. GitHub Actions会为正在运行的工作流生成一个有效期较短的OIDC令牌。这个令牌由GitHub签名,其中包含了经过验证的信息:当前正在运行的是哪个仓库、哪个分支、哪个工作流,以及执行该工作的用户是谁。

    2. 你的工作流会将这个令牌提交给Google Cloud的安全令牌服务。

    3. Google会用GitHub的公钥来验证这个令牌的签名,并根据你配置的规则来检查其中包含的信息,确保该令牌确实来自你的仓库,而不是其他地方。

    4. 如果所有验证都通过,Google就会颁发一个有效期约为一小时的低效访问令牌。

    5. 工作流会使用这个令牌进行操作,而这个令牌在一段时间后就会失效。

    这样一来,就没有任何秘密信息会被存储在任何地方,也就不存在需要泄露、定期更换或撤销的密钥了。如果有人克隆了你的仓库并运行相同的工作流,他们生成的OIDC令牌中会包含他们的仓库名称,因此Google的验证机制会失败,他们将无法获取任何访问权限。

    请将以下代码添加到`terraform/main.tf`文件中:

    # -----------------------------------------------------------
    # GitHub Actions将使用这个服务账户进行身份验证
    # -----------------------------------------------------------
    resource "google_service_account" "github_actions" {
      account_id = "github-actions-preview"
      display_name = "GitHub Actions — PR预览环境"
    }
    
    # 授权将镜像推送到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}"
    }
    
    # -----------------------------------------------------------
    # Workload Identity Federation配置
    # -----------------------------------------------------------
    resource "google_iam_workload_identity_pool" "github" {
      workload_identity_pool_id = "github-pool"
      display_name = "GitHub Actions身份验证池"
      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提供者"
    
      # 将GitHub OIDC令牌中的信息映射到Google可识别的属性上
      attribute_mapping = {
        "google.subject" = "assertion.sub",
        "attribute.actor" = "assertion(actor",
        "attribute_repository" = "assertion.repository"
      }
    
      # 这个条件非常重要:如果没有它,互联网上的任何GitHub仓库都可能使用这个身份验证池进行认证。
      attribute_condition = "assertionrepository == '${var.github_repo}'"
    
      oidc {
        issuer_uri = "https://token.actions.githubusercontent.com"
      }
    }
    
    # 允许来自特定仓库的令牌使用该服务账户进行身份验证
    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中,且其attribute.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 "artifactregistry_url" {
      description = "用于推送镜像的基本URL"
      value       = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifactRegistry_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.workloadidentity_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已成功初始化!”的提示。

    接下来预览一下将会创建哪些资源:

    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步中您会需要用到它们:

    artifactregistry_url = "europe-west2-docker.pkg.dev/your-project/pr-preview"
    service_account_email = "github-actions-preview@your-project.iam.gserviceaccount.com"
    workload_identity_provider = "projects/123456789/locations/global/workloadIdentityPools/github-poolproviders/github-provider"
    

    您随时都可以使用`terraform output`命令来查看这些配置信息。

    Terraform应用配置后的输出结果,其中包含了所需的认证信息

    使用`get_credentials_command`命令得到的指令,将`kubectl`连接到新创建的集群:

    gcloud container clusters get-credentials pr-preview-cluster \
      --zone europe-west2-a \
      --project YOUR_PROJECT_ID
    

    验证连接是否成功:

    kubectl get nodes

    您应该会看到有两个节点处于“Ready”状态。

    使用kubectl get nodes命令查看到的两个处于Ready状态的GKE节点

    步骤7:手动验证应用程序的生命周期

    在自动化任何操作之前,先手工尝试一遍。

    这并不是重复性的工作。自动化本质上只是执行一些你本来就可以手动输入的命令而已。如果你不知道这些命令的具体内容,那么当自动化过程出现故障时,你就无法进行调试……而这种故障通常会在演示前的晚上11点发生。现在花十分钟去做这件事,以后就能节省很多时间。

    首先创建一个命名空间:

    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或其他任何命名空间都无法检测到它的存在。正是这种隔离机制,使得整个项目能够正常运行:每个命名空间都像是一道屏障,同一个命名空间内的所有资源对其他命名空间来说都是不可见的。

    现在把这个Pod删除掉:

    kubectl delete namespace pr-test

    观察会发生什么:

    kubectl get namespace pr-test

    你会看到系统先显示“Terminating”状态,随后这个命名空间就会完全消失。删除一个命名空间意味着会清除其中所有的资源:部署对象、Pod、服务、配置映射、密钥等,所有内容都会被彻底清除。只需要一条命令,就能完成整个清理过程。

    正是这种隔离机制,使得每个Pull Request都使用独立的命名空间这一设计非常适合用于预览环境。删除操作并不需要按照特定的顺序来执行,只需一条命令即可,而且不会留下任何残留物。

    步骤8:使用Kustomize编写Kubernetes配置文件

    每个预览环境都会部署相同的应用程序,pr-42和pr-43之间的唯一区别就在于命名空间和镜像标签的不同。

    而这正是Kustomize所解决的问题。你只需要一次性编写好基础配置文件作为模板,之后就可以在此基础上进行一些小的、声明式的修改,而无需使用模板制作、字符串替换或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
              # 这只是一个占位符,后续会用实际的注册地址和Pull Request特定的标签来替换它。
              image: preview-app
              ports:
                - containerPort: 8080
              env:
                - 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:在生产环境中,通常会部署多个副本以确保高可用性。但预览环境并不需要这种高可用性。每个环境只部署一个副本,并且这个设置适用于所有正在处理的拉取请求,这样就能有效控制节点数量,从而避免产生不必要的费用。

    image: preview-app:这是一个空的占位符,没有注册表信息也没有标签。它永远不会被实际使用。Kustomize的工具会根据这个名称来替换相应的内容,因此基础配置文件根本不需要知道注册表的URL。

    Downward API 的 `env` 部分:`POD_NAMESPACE` 和 `POD_NAME` 这两个值就是从这里获取的。`fieldRef` 会在运行时从Pod的配置文件中读取相应的字段,并将其作为环境变量暴露出来。因此,完全不需要进行任何API调用、使用凭证或服务账户。

    type: ClusterIP:这种类型仅用于内部使用。每个 `LoadBalancer` 服务都会为应用程序分配一个真实的Google Cloud负载均衡器,这会产生实际的费用并且需要一个公共IP地址。如果为每个拉取请求都配置这样的负载均衡器,将会非常浪费资源。目前我们暂时通过 `kubectl port-forward` 来访问应用程序,而在第13步中,将会使用一个共享的Ingress控制器来为所有环境提供服务,从而避免重复配置负载均衡器的开销。

    资源请求与限制:`requests` 用于预留资源空间,这样调度器就能知道某个Pod需要多少资源。`limits` 则用于限制Pod可以消耗的资源量。如果没有这些限制,那么一个运行失控的预览环境就可能会占用节点上所有其他资源的带宽,从而导致其他环境无法正常运行。由于有很多临时创建的环境会频繁地出现和消失,因此设置这些限制是必不可少的。

    测试基础配置文件是否能够正确生成YAML格式的输出:
    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   # 这是Workload Identity Federation所必需的权限
      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: Build and deploy preview
        runs-on: ubuntu-latest
    
        steps:
          - name: 检出代码
            uses: actions/checkout@v4
    
          - name: 设置环境变量
            run: |
              PR_NUMBER=${{ github.event.pull_request.number }}
              SHORT_SHA=$(echo "${{ github.eventpull_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: 配置Docker以使用Artifact Registry
            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: ${NAMESPACE}"
              kubectl get pods, svc --namespace="${NAMESPACE}"
    

    添加这两个秘密信息

    请前往您在 GitHub 上的仓库,依次进入设置、秘密与变量、操作,然后选择新建仓库秘密。需要添加的内容如下:

    秘密名称 值
    WIF_PROVIDER Terraform 输出的 workload_identity_provider 值
    WIF_SERVICE_ACCOUNT Terraform 输出的 service_account_email 值
    GitHub 仓库秘密页面,显示 WIF_PROVIDER 和 WIF_SERVICEACCOUNT 的信息

    需要注意的是,这两项内容并不属于凭证信息。提供者路径和服务账户邮箱仅是标识符,并非真正的秘密。它们本身并不能赋予任何访问权限,因为实际控制访问的是 OIDC 令牌以及相关的属性条件。将它们作为秘密信息进行存储,只是为了便于管理而已。请根据您的实际情况,更新工作流中 PROJECT_ID 以及区域设置对应的值。

    了解工作流的运作原理

    on: pull_request: types: [opened, synchronize, reopened]:这三项分别是三种不同的触发条件。

    • opened:当一个新的 Pull Request 被创建时,这个触发条件就会被激活。

    • synchronize:每当有人向该分支提交新的代码时,这个触发条件就会被执行,这样环境就能保持最新状态。

    • reopened:用于处理那些被关闭后又重新打开的 Pull Request。如果没有这个触发条件,那么被关闭的 Pull Request 对应的环境就无法得到更新。

    concurrency:这个设置能够大大节省调试时间。如果连续快速地提交三个变更请求,那么就会有三次工作流运行,而且它们都会针对同一个命名空间进行操作。这些运行会同时进行竞争,最终只有完成得最晚的那次运行结果会被保留下来,而这个结果并不一定是最新的代码。因此,一个快速完成的早期构建可能会覆盖掉一个较慢进行的后期构建,从而导致预览环境继续使用过时的代码,而 Pull Request 的状态却显示为“已通过”。如果使用 cancel-in-progress: true 根据 Pull Request 编号来分组运行任务,那么只有最新的那次运行结果才会被保留。

    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文件,然后将其传递给kubectl apply命令;如果命名空间还不存在,该命令就会创建它;如果已经存在,则什么也不会发生。这种设计使得该脚本具有幂等性。

    heredoc覆盖配置:工作流不会为每个拉取请求都生成覆盖配置文件(因为事先并不知道具体的拉取请求编号),而是在运行时才生成相应的配置文件,并在运行流程结束后立即将其删除。对于这种临时性的环境来说,这样的配置文件也是暂时性的。

    kubectl rollout status这个命令能够确保工作流的正确性。如果没有这个命令,那么只要kubectl apply命令被API服务器接受,任务就会被视为成功完成——而实际上此时还没有一个Pod开始运行呢。对于那些会陷入无限循环的镜像来说,这个命令也能确保它们显示为“绿色状态”。rollout status命令会一直等待直到所有的Pod都准备好才结束执行;如果180秒后仍然没有完成,就会显示失败结果。绿色状态表示一切正常。

    关于分支拉取请求的一些注意事项

    在将这个流程部署到任何公共环境中之前,有一点是需要了解的:按照设计原则,来自分支的拉取请求不会被授予id-token: write权限。GitHub会阻止这种行为,这样外人就无法通过拉取请求来窃取你的云服务凭证。

    对于私有仓库或个人项目来说,这一点并不会对你产生任何影响;但对于那些接受外部贡献的公共仓库而言,你就需要采用基于workflow_run的模式了。在这种模式下,只有在不受信任的构建过程完成后,才会从基础仓库启动具有相应权限的工作流来执行相关操作。虽然这超出了本手册的范围,但如果你要在开源项目中使用这个流程,千万不要忽略这一点。

    步骤10:构建拆除工作流

    仅仅能够快速创建环境还不够,一个永远不会被销毁的环境只会让你的云服务费用不断增加。

    创建文件.github/workflows/preview-teardown.yml:

    name: 拆除拉取请求预览工作流
    
    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: 删除预览命名空间
        runs-on: ubuntu-latest
    
        steps:
          - name: 设置命名空间
            run: echo "NAMESPACE=pr-${{ github.event.pull_request.number }}" >>> $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> 获取GKE凭证
            uses: google-githubActions/get-gke-credentials@v2
            with:
              cluster_name: ${{ env.CLUSTER_NAME }}
              location: ${{ env.ClUSTER_ZONE }}
    这里有一些细节的重要性超出了表面看上去的程度。 首先,types: [closed] 这一设置也涵盖了合并操作。GitHub并没有单独的merged事件类型。当合并一个拉取请求时,会触发closed事件,且其负载字段中会包含merged: true这一信息。因此,这个触发条件同时能够表示“合并操作成功完成”以及“合并请求被放弃并关闭”的两种情况。由于这两种情况都会导致相关环境的终止,所以我们没有必要对它们进行区分。 接下来是--ignore-not-found=true这一选项:如果某个命名空间不存在(可能是部署失败了,或者有人手动删除了它),那么使用kubectl delete命令通常会遇到错误并使工作流程状态变为红色。但实际上,我们希望看到的最终结果是“该命名空间确实不存在”,而这个标志的作用就是让步骤在没有任何需要操作的情况下也能正常完成。 最后是--wait=false这一选项:命名空间的删除是一个异步过程。Kubernetes会先将该命名空间的状态标记为Terminating,然后再逐一删除其中的所有资源。如果等待这些操作全部完成,可能会让CI运行器无谓地等待一分钟甚至更长时间。因此,我们应该直接执行删除操作,让集群按照自己的节奏来完成后续处理。

    步骤11:创建一个拉取请求并观察其运行过程

    首先将所有更改提交并推送到远程仓库:
    git add .
    git commit -m "添加用于预览环境的配置"
    git push -u origin main
    
    接下来创建一个新分支,并在其中做出一些可见的修改:
    git checkout -b test-preview
    
    编辑文件app/main.go,更改处理器输出中的第一行内容:
    fmt.Fprintf(w, "预览环境已启动——正在测试相关流程\n\n")
    
    再次提交并推送到远程仓库:
    git add .
    git commit -m "测试预览环境"
    git push -u origin test-preview
    
    在GitHub上创建一个拉取请求,然后进入Actions选项卡。你应该会看到“Deploy PR Preview”这个步骤正在运行中。 GitHub Actions的日志显示‘Deploy PR Preview’步骤已成功完成 按照顺序观察各个步骤的运行过程:首先会进行身份验证,而且不需要使用密钥;接着会构建并推送镜像文件;然后会创建命名空间;随后Kustomize工具会生成相应的配置文件;部署操作也会随之完成;最后rollout status状态会显示Pods是否已经准备就绪。 GitHub Actions的详细日志显示部署、等待部署完成以及查看部署详情等步骤均已成功完成 当所有步骤都显示为绿色状态时,你可以在终端中再次进行验证:
    kubectl get namespaces | grep pr-

    你应该能够看到你的pr-1(或者你的拉取请求所对应的编号)。

    kubectl get pods --namespace=pr-1

    现在,连接到该应用程序:

    kubectl port-forward --namespace=pr-1 service/preview-app 8080:80

    在另一个终端中:

    curl http://localhost:8080

    你应该会看到如下信息:

    预览环境已启动——正在测试流程
    
    拉取请求编号:1
    Git哈希值:a1b2c3d
    命名空间:pr-1
    Pod名称:preview-app-7d9f8b6c4-xk2mp

    该应用程序会显示在构建时被嵌入到其二进制文件中的拉取请求编号和Git哈希值,以及通过Downward API获取的命名空间。这些信息分别由流程中的不同环节生成,但它们所显示的内容是一致的。这就是证明整个流程能够正常运行的证据。

    终端输出结果,显示预览环境报告了拉取请求编号1、其Git哈希值、命名空间pr-1以及Pod名称

    现在,向同一个分支再次提交一个变更,然后观察环境如何自动更新。

    GitHub Actions日志,显示在向同一分支提交了第二个变更后,Deploy PR Preview工作流程重新开始运行 终端显示针对命名空间pr-1执行的kubectl port-forward命令,以及用于访问该服务的curl命令 终端输出结果,显示在第二次提交后,问候语文本已更新,同时显示了新的Git哈希值,证明环境已经成功重新部署

    然后关闭这个拉取请求,观察后续的拆除工作流程如何执行。

    GitHub Actions中的拆除工作流程已被触发并正在运行中 GitHub Actions中的拆除工作流程即将完成
    kubectl get namespace pr-1
    终止后,相关资源就会被彻底清除。 终端输出显示“kubectl get namespace”命令返回“namespaces pr-1 not found”的结果” height=

    你已经拥有的资源,以及还缺少的东西

    停下来仔细想想吧,因为所有的基础设施相关工作都已经完成。现在,提交一个拉取请求就可以创建出一个真正独立的环境,而且完全不需要人工干预;关闭这个环境后,所有相关的痕迹都会被彻底清除。整个流程都是自动化的。

    但是再看看你当初是如何实现这一目标的:

    kubectl port-forward --namespace=pr-1 service/preview-app 8080:80

    要使用这个命令,你必须已经安装了kubectl工具,拥有访问该集群的权限,并且知道哪个命名空间对应着哪个拉取请求。对于构建这个流程的人来说,这些要求当然没问题;但对于那些只是需要检查代码修改内容的设计人员、负责审核文本编辑结果的产品经理,或者需要在提交之前测试整个流程的质量保证工程师来说,这些要求就显得很不方便了。

    创建预览环境的意义就在于任何人都能查看这些更改。如果只有工程师才能访问这个环境,那它也就算不上是一个真正的预览环境,而更像是一种调试工具罢了。

    本教程的后续内容将填补这一空白,并确保最终创建的环境能够安全、稳定地运行下去。首先,我们会为每个预览环境设置一个公共的HTTPS地址;然后,我们会采取一些措施来防止这些环境成为你在云服务账单上才会注意到的“隐性开销”。

    步骤12:预留一个静态IP地址

    在开始安装任何软件之前,你首先需要预留一个外部IP地址。

    这一点非常重要,因为当你在系统中安装Ingress控制器时,它会创建一个类型为LoadBalancer的Kubernetes服务,而Google Cloud会为你分配一个带有公共IP地址的真实负载均衡器。默认情况下,这个IP地址是临时分配的;如果该服务被删除后再重新创建,你将会得到一个新的IP地址。

    你的通配符DNS记录就是指向这个IP地址的。如果这个IP地址发生了变化,所有的预览环境都会立刻出现故障,而直到有人告诉你链接已经失效时,你才会发现这个问题。因此,提前预留这个IP地址意味着它属于你的项目,而不是负载均衡器;无论你对集群进行什么操作,这个IP地址都不会受到影响。

    将以下代码添加到terraform/main.tf文件中:

    # -----------------------------------------------------------
    # 用于Ingress控制器的静态外部IP地址
    # -----------------------------------------------------------
    resource "googlecompute_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       = googlecompute_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地址: python terraform output ingress_ip 把这个IP地址记下来。在接下来的两个步骤中你会需要用到它。 显示预留IP地址的terraform输出结果 重要提示:这个IP地址必须属于与你的集群相同的区域,而region = var.region正好可以确保IP地址位于正确的区域。Google Cloud也提供全局IP地址,但这些地址通常用于全球范围内的HTTP(S)负载均衡器。 ingress-nginx创建的LoadBalancer服务属于区域级负载均衡器,因此它无法使用全局IP地址。如果将这两种类型的负载均衡器混用,会导致服务永远处于Pending状态,并且会出现无法明确说明原因的错误。

    步骤13:安装Ingress控制器

    Ingress是一种Kubernetes资源,用于定义HTTP路由规则:“针对某个主机名的请求会被转发到相应的服务。”然而,Ingress资源本身仅仅是一个描述性配置文件。实际上需要有人来读取这些规则并负责路由流量,而这个角色就是Ingress控制器。 我们将使用ingress-nginx这款由社区维护、基于NGINX构建的控制器。它会在你的集群中以Pod的形式运行,持续监控Kubernetes API中发布的Ingress资源配置,并在配置发生变化时自动更新自身的NGINX配置文件。 对于预览环境来说,这种设计之所以可行,是因为一个控制器可以服务于所有命名空间。每个预览环境都会创建自己的Ingress资源,控制器会捕获这些资源信息,为相应的主机名添加路由规则,然后将请求转发到正确的服务上。无论存在多少个预览环境,控制器都无需进行任何调整。 相比之下,如果我们不使用这样的设计,那么每个预览环境都需要单独配置一个LoadBalancer服务,这意味着每份PR提交都会导致一个新的Google Cloud负载均衡器被创建,而这些负载均衡器都会产生每小时的费用,同时还需要为它们分配独立的IP地址,这些信息都需要DNS系统进行维护。如果同时有10份PR在处理中,那就意味着需要配置10个负载均衡器;而使用共享的Ingress控制器的话,只需要配置一个即可。 使用Helm来安装Ingress控制器:
    helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx  
    helm repo update  
    
    helm 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:该参数告诉Kubernetes将负载均衡器绑定到您预先预留的特定地址上,而不是分配一个新的地址。正是这一设置使得您的DNS记录能够保持稳定。

    controller.service.externalTrafficPolicy=Local:这个选项可以保留客户端的真实源IP地址。如果不使用这个选项,Kubernetes可能会将请求路由经过中间节点,而当请求到达NGINX时,源IP地址可能会被修改为中间节点的内部地址。这样一来,您的访问日志中显示的就会是集群内部的IP地址,而不是用户的真实IP地址。

    不过,这样的设置也有其弊端:只有那些实际运行着控制器Pod的节点才会接收这些请求,但在当前的情况下,这种设计并没有问题。

    通过helm install命令部署ingress-nginx chart后,终端输出显示该chart已成功部署到ingress-nginx命名空间中

    请留意系统会为该服务分配一个外部IP地址:

    kubectl get service ingress-nginx-controller \
      --namespace ingress-nginx \
      --watch
    

    在Google为负载均衡器配置好相关资源后,系统中会显示状态,过一两分钟之后,就会显示出步骤12中预留的静态IP地址。一旦该地址出现,请立即按Ctrl+C键终止命令的执行。

    接下来,请确认控制器是否正常运行:

    kubectl get pods --namespace ingress-nginx
    
    通过kubectl get service和kubectl get pods命令查看ingress-nginx控制器的运行状态,可以看到该控制器已经使用了预留的静态外部IP地址

    步骤14:将通配符DNS地址指向负载均衡器

    这个步骤大约需要两分钟的时间,正是这个步骤使得整个方案具备可扩展性。

    请前往您管理域名DNS服务的平台,创建一个A记录:

    字段 值
    类型 A
    名称/主机 *.preview
    值 步骤1中获得的静态IP地址
    TTL 300秒(或您的DNS服务提供商允许的最短时间)

    就这样,整个项目的DNS配置就完成了。

    DNS服务提供商的界面显示,*.preview这个通配符A记录被指向了负载均衡器的静态IP地址

    这就是为什么只需要一条DNS记录就足够的原因。通配符DNS记录能够匹配该位置上的任何标签。条记录*.preview.yourdomain.com可以解析为pr-1.preview.yourdomain.com、pr-42-preview.yourdomain.com以及pr-99999.preview.yourdomain.com,所有这些请求都会被指向同一个IP地址,而且根本不需要人为地为它们创建额外的DNS记录。

    这一设计确实值得我们仔细思考,因为正是这样的设计使得整个系统保持简洁性。显然,另一种可行的方法是让持续集成管道在PR提交时自动创建DNS记录,在PR关闭后再将其删除。但这样一来,就需要在管道中配置DNS相关的API凭据,而这又会增加系统出现故障的风险,也有可能导致这些凭据被泄露,进而造成资源浪费的问题。

    而使用通配符的方式则完全避免了这类问题:只需手动配置一次DNS记录,之后就再无需对其进行任何修改了。

    实际上,DNS并不会负责将请求路由到正确的环境。所有主机名都会被解析为同一个负载均衡器,而Ingress控制器会根据每个传入请求的Host头信息来决定应该由哪个命名空间中的服务来处理该请求。

    请验证这条DNS记录是否已经生效:

    dig +short anything-at-all.preview.yourdomain.com
    

    你应该能重新获取到自己的静态IP地址。需要注意的是,此时子域名还无需真正存在——这正是通配符机制的精髓所在。

    通过dig命令查询到的、从未被明确创建过的子域名的静态IP地址,这一结果证明了通配符记录确实能够生效

    如果没有任何结果返回,建议等待几分钟后再尝试。通常情况下,DNS记录的传播速度很快(因为TTL值较低),但过程并不是即时的。

    步骤15:安装cert-manager并创建ClusterIssuer

    现在来说说HTTPS的相关配置。

    cert-manager是一个Kubernetes控制器,它能够自动完成证书生命周期管理的所有环节。它会监控那些标有发行者信息的Ingress资源,向证书颁发机构申请证书,验证你对相应域名的控制权,将证书存储在Kubernetes的Secret中,并在证书过期前及时为其续期。这样一来,就完全不需要手动操作或处理证书过期的问题了。

    安装cert-manager的方法如下:

    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所支持的自定义资源定义类型,包括Certificate、Issuer、ClusterIssuer、CertificateRequest等。这些自定义资源定义类型扩展了Kubernetes API的功能。如果没有这些配置,cert-manager就无法正常工作。

    检查它们是否正在运行:

    kubectl get pods --namespace cert-manager
    

    你应该会看到三个Pod:控制器、Webhook以及cainjector。

    kubectl get pods的输出结果,显示cert-manager控制器、Webhook和cainjector Pod正在运行

    创建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: YOUR_EMAIL@example.com
        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: YOUR_EMAIL@example.com
        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 cluster issuer
    

    两个配置文件都应该显示`READY: True`。

    kubectl get cluster issuer的输出结果,显示letsencrypt-staging和letsencrypt-prod的状态均为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挑战机制,具体的操作流程如下:

    1. Let’s Encrypt会向cert-manager提供一个随机生成的令牌。

    2. cert-manager会创建一个临时容器、服务以及Ingress规则,将这些资源配置为在http://pr-42.preview.yourdomain.com/.well-known/acme-challenge/<token>路径下提供该令牌。

    3. Let’s Encrypt会从公共互联网向该URL发送一个真实的HTTP请求。

    4. 如果令牌信息匹配,就说明你确实拥有对该域名的控制权,此时Let’s Encrypt就会颁发证书。

    5. cert-manager会删除这些临时资源,并将颁发的证书存储在Secret中。

    要使这一机制正常运行,必须满足三个条件;而当出现问题时,也需要检查这三个条件是否成立:首先,DNS系统必须能够将该域名解析到你的负载均衡器上;其次,互联网上的设备必须能够访问端口80(注意不要在负载均衡器层面将所有请求都重定向到HTTPS地址,因为这个挑战机制使用的是普通的HTTP协议);最后,Ingress控制器必须能够正确地路由请求。

    关于扩展性的注意事项

    对于每个域名,HTTP-01机制都会颁发一份证书,而每个预览环境都有一个唯一的域名,因此每个拉取请求都会对应一份证书。

    考虑到Let’s Encrypt规定每周最多只能颁发50份证书,这对于个人项目或小型团队来说完全足够。但如果一个活跃的团队每周要提交超过50个拉取请求,就会遇到上限限制。

    对于生产环境而言,解决方案是为*.preview.yourdomain.com这一域名颁发一份通配符证书,然后让所有命名空间都能共享这份证书。需要注意的是,通配符证书不能通过HTTP-01机制来颁发,而是需要使用DNS-01挑战机制——在这种机制下,cert-manager需要通过创建TXT记录来证明自己对域名的控制权,而不是直接提供文件。

    利用Cloud DNS服务,cert-manager可以使用Google的服务账户自动完成这一操作。之后,你可以使用reflector这样的工具,将生成的Secret配置文件复制到每个预览命名空间中。

    虽然这种方法涉及的环节比较多,但当你的拉取请求量确实很大时,这确实是可行的解决方案。在遇到这一限制之前,了解它的存在是非常有必要的。

    步骤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,而在下一步中,这些值都会被替换为实际的域名。使用这样的虚拟主机名可以确保:如果某个补丁操作失败,系统会立即显示出明显的错误信息,而不会导致隐藏的路由问题。

    pathType: Prefix 与 path: / 的组合:这意味着这条规则会匹配主机上的所有路径。预览环境会提供整个应用程序的服务,而不是某个特定的子路径。

    步骤 17:针对每次拉取请求修改主机名

    每个预览环境都需要在 Ingress 配置中的两个地方替换为主机名。Kustomize 使用 JSON 6902 补丁机制 来实现这一功能,这种机制可以精确地修改资源文件中指定的路径内容。

    在第九步中,工作流生成了一个配置文件,该文件设置了命名空间和图像标签。我们通过添加一个补丁块来扩展这个生成的配置文件。以下是针对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处的值替换为pr-42.preview.yourdomain.com,同时也将/spec/tls/0/hosts/0处的值替换为pr-42.preview.yourdomain.com。

    这里的0表示数组中的索引位置。/spec/rules/0指的是第一条规则,/spec/tls/0/hosts/0则是指第一个TLS配置项中的第一个主机名。如果后来为某个环境添加了第二个主机名,就需要添加一个索引值为1的补丁操作。

    为什么使用JSON格式的补丁文件而不是战略性合并补丁呢?因为对于包含多个对象的数组来说,战略性合并操作的含义可能会产生歧义。Kubernetes必须判断你是想“替换这个列表中的所有元素”,还是想“将这些元素合并到现有的条目中”,而这种判断取决于每个字段所使用的合并规则。而对于需要在已知位置上替换某个具体值的情况,使用明确的op: replace指令能够准确表达你的意图;如果路径不存在,这样的操作也会立即产生错误提示。

    工作流会在运行时生成这个文件,因此你根本不需要为每个拉取请求都单独提交修改后的配置文件。我们会在第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
    

    各组配件的作用

    资源配额限制了整个命名空间的总消耗量。需要将所有Pod的请求量和限制值加在一起,其总和不得超过这些设定的数值。任何会导致命名空间超出配额的请求,在被安排执行之前,都会被API服务器直接拒绝。

    其中services.loadbalancers: "0"这一配置值得特别注意。它从结构上保证了预览环境无法配置Google Cloud负载均衡器——即使有人在配置文件中指定了type: LoadBalancer,系统也无法执行该操作。这是一种直接的财务保护机制:这种设置有效地防止了可能带来的高昂损失。

    限制范围是针对单个容器而非整个命名空间来设置的。对于那些没有明确指定资源需求的容器,系统会为其设定默认值;同时也会规定一个绝对的最大上限,确保没有任何容器能够超过这个限度。

    为何必须将它们一起部署

    一旦某个命名空间中设置了限制requests.cpu的资源配额,那么该命名空间内的所有容器都必须明确指定资源需求。如果没有这些配置信息,Kubernetes就无法判断该容器是否会在配额范围内使用资源,因此会直接拒绝这样的请求。

    这意味着,如果单独设置资源配额,那些没有指定资源需求的配置文件就会失效——而实际上,在现实中大多数配置文件确实都没有包含这些详细信息。

    限制范围通过提供默认值来解决这个问题。对于没有明确指定资源需求的容器,系统会在接纳请求时自动为其分配defaultRequest和default值,这样就能确保其需求在配额范围内。

    如果只部署其中一项配置,就会导致各种混乱的错误;而如果同时部署两项配置,系统虽然会容忍那些不完整的配置文件,但依然能确保所有的资源使用都在规定的范围内。

    数值的设定方法

    上述数值适用于像我们这样的小型应用程序。如果要调整这些数值,可以依据你的节点容量来反向计算。

    两个e2-standard-2节点总共能提供4个vCPU和16GB的内存资源,不过其中大约15%会被GKE系统占用,因此实际可用资源约为3.4个vCPU。如果每个命名空间的requests.cpu配置值为“1”,那么理论上三个预览环境就可以占满整个集群。

    我们的应用程序实际上只需要50毫秒的CPU时间,所以实际上可以部署的数量远超过三个。不过,配额毕竟是对一个命名空间最多能使用多少资源的限制,因此你应该将这个配额设置得远远低于“一个预览环境就能耗尽所有资源”的水平。

    如果你预计会有十个并发运行的环境,那么每个命名空间的配额应该设定为可用资源的十分之一左右;或者,你也可以增加节点数量来满足需求。

    步骤19:更新部署工作流程

    以下是完整的.github/workflows/preview-deploy.yml文件,其中本节添加的内容都标有# NEW标签。请将第9步中的配置文件替换为这个新的版本。

    name: 部署PR预览版本
    
    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: YOURPROJECT_ID
      REGION: europe-west2
      CLUSTER_NAME: pr-preview-cluster
      CLUSTER_ZONE: europe-west2-a
      REPOSITORY: pr-preview
      IMAGE_NAME: preview-app
      # 新增:所有预览环境使用的基础域名
      PREVIEW_DOMAIN: preview.yourdomain.com
      # 新增:在验证流程有效后,切换到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
              # 新增:指定该环境可访问的主机名
              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-githubActions/setup-gcloud@v2
    
          - name: 配置Docker以使用Artifact Registry
            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 -
    
              # 新增:为该命名空间添加标签,以便Reaper识别它属于我们
              kubectl label namespace "${NAMESPACE}" \
                app.kubernetes.io/managed-by=pr-preview \
                preview.pr="${PR_NUMBER}" \
                --overwrite
    
          # 新增:在部署任何资源之前,先应用相应的配额和限制规则
          - 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 <

    关于这些新的操作步骤,有几点需要了解。

    注释路径中的~1:这看起来像是一个拼写错误,但实际上并非如此:

    path: /metadata/annotations/cert-manager.io~1cluster-issuer

    JSON指针路径使用/作为分隔符,因此键值对中的纯/字符需要被转义。转义方式为~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步中已经花费了大量精力来处理那些长期存在的认证凭证问题,如果再添加一个未经审核、且能够直接写入你的仓库的第三方工具,那就显得很不合理了。

    隐藏的标记评论正是让这条评论具有“粘性”的关键机制。代码在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;甚至可能是GitHub的事件推送服务出现了问题等等。

    不过,这些情况其实都不太常见。但无论如何,如果这些错误发生得太频繁,就会导致某些命名空间持续处于运行状态,从而消耗集群资源,甚至造成经济损失。即使这种清理操作在95%的情况下都能成功执行,但它仍然属于一种“漏洞”。

    解决这个问题的方法就是使用一个“Reaper”工具:这个工具会定期检查当前正在运行的命名空间与应该运行的命名空间之间是否存在差异,并自动删除那些多余的命名空间。

    创建文件.github/workflows/preview-reaper.yml:
    name: 清理那些被遗弃的预览环境
    
    on:
      schedule:
        # 每天03:00 UTC执行
        - 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期限的命名空间
      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-githubActions/get-gke-credentials@v2
            with:
              cluster_name: ${{ env.CLUSTER_NAME }}
              location: ${{ env.ClUSTER_ZONE }}

    `GITHUB_TOKEN`是从哪里来的?

    与你在第6步中创建的`WIF_PROVIDER`和`WIF_SERVICE ACCOUNT`不同,`GITHUB_TOKEN`并不是你手动设置的。你不会在“秘密”页面上找到它,也不应该将它添加到那里。

    GitHub会在每次工作流运行开始时生成一个新的安装令牌,将其作为`secrets.GITHUB_TOKEN`暴露出来,而当任务完成后,这个令牌就会被立即撤销。它的作用范围仅限于正在运行该工作流的仓库,不会影响其他任何地方。

    了解Reaper的工作原理

    Reaper是在GitHub Actions中运行的,而不是在Kubernetes集群内部运行的。如果使用Kubernetes中的CronJob来查询未解决的PR,就需要将GitHub令牌存储为集群的“秘密”信息,而这样的凭证会在集群中长期存在——这正是我们在第5步中试图避免的情况。

    已安排好的工作流本身就已经拥有`GITHUB_TOKEN`(这个令牌的作用范围是有限的、是自动生成的、且生命周期很短),并且已经通过Workload Identity Federation机制完成了与GCP的认证过程,因此不需要再添加新的“秘密”信息。

    标签选择器就是一种安全保障机制。查询条件是`-l app.kubernetes.io/managed-by=pr-preview`,而不是像`pr-*`这样的名称模式。这种设计是经过深思熟虑的:如果使用名称模式进行匹配,那么可能会误删除某个名为`pr-routing-service`的生产环境命名空间。而Reaper只能看到那些被工作流明确标记为需要清理的命名空间,因此它能够删除的对象也只能是那些它自己创建的对象。

    在尝试模仿这一机制时一定要小心。任何自动清理操作都应该只针对那些被明确允许删除的对象进行,而不能使用可能匹配到其他无关对象的通用规则。

    我们还制定了两条删除规则,用于解决两种不同的问题。规则1用于处理那些PR已经关闭但清理操作却从未被执行的命名空间;规则2则用于处理那些已经开放了三周、两周内没有任何人对其进行过修改、但却仍在占用集群资源的命名空间。将7天作为默认期限是合理的;如果有人需要恢复他们的环境,他们只需提交一个变更请求,几分钟后系统就会重新部署相应的资源。

    set -euo pipefail:`-e`选项表示一旦有任何命令失败,脚本就会立即终止执行;`-u`选项表示当遇到未定义的变量时,脚本也会停止运行;`-o pipefail`选项则意味着只要有任何一个阶段失败,整个管道流程都会终止。对于用于删除资源的脚本来说,无声无息地失败绝对是不可接受的,因此必须确保一旦出现错误就能立即停止执行。

    grep -qx:`-x`选项要求匹配的内容必须整行都符合查询条件。如果不使用这个选项,PR编号为`4`的请求可能会被误认为是编号为`42`的未解决请求,从而导致Reaper永远无法删除那些被遗漏的命名空间。虽然这是一个很小的细节,但却可能引发严重的错误。

    workflow_dispatch允许你通过“Actions”选项卡手动启动Reaper。在初次使用这个功能时,建议先运行几次并仔细查看输出结果,然后再将其设置为自动执行。日志会明确显示每个命名空间被标记为“保留”还是“删除”,以及具体的原因,这样你就可以确认其操作逻辑是否符合你的预期,然后再让它自动运行。

    步骤22:整体测试

    将所有更改提交并推送到远程仓库:

    git add .
    git commit -m "添加Ingress配置、TLS设置、PR评论、安全防护机制以及reaper工具"
    git push
    

    创建一个新分支,进行一些可见的修改,然后提交一个拉取请求:

    git checkout -b test-preview-urls
    # 修改app/main.go文件中的问候语文本
    git add .
    git commit -m "测试预览URL"
    git push -u origin test-preview-urls
    

    打开拉取请求,查看“Actions”选项卡。

    使用新证书及评论功能进行的部署流程

    测试完成后,系统会生成一条包含你的URL的机器人评论,请点击查看该评论。

    显示新URL的机器人评论

    由于你当前使用的是测试环境中的证书颁发机构,因此浏览器会显示证书警告,提示“你的连接不安全”,并且证书颁发机构显示为“Let’s Encrypt Staging”。

    这就是成功的标志。这意味着DNS解析已经完成,负载均衡器也已正确配置,Ingress设置与主机名匹配,HTTP-01挑战测试也通过了,而且确实生成了一枚证书。所有步骤都运行正常了,只不过这张证书来自一个不受信任的测试机构而已。

    浏览器针对预览环境URL显示的安全警告,指出证书由Let’s Encrypt Staging颁发而非受信任的机构

    点击“Advanced”选项,忽略警告信息,确认应用程序确实使用了正确的PR编号和命名空间。

    在浏览器中加载预览环境页面,页面上会显示正确的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错误码也没关系,因为这说明路由配置是正确的;而如果连接超时,则说明请求没有到达您的集群。

    确认DNS可达性

    切换到生产证书

    当 staging 流程能够完整地正常运行时,请修改工作流程中env:块中的一行代码:

      CLUSTER_ISSUER: letsencrypt-prod
    

    请在您现有的PR分支上进行此项修改,然后推送更改。无需先关闭拉取请求或将其合并到main分支中。您也可以在main.go文件中修改相关代码,以确认所有配置确实有效。

    将新的提交推送到PR中。系统会重新部署环境,cert-manager会申请真实的证书,此时浏览器应该会显示挂锁图标。

    请在隐私浏览窗口中打开该网址进行测试。由于您的浏览器可能会缓存staging证书的相关信息,因此普通标签页可能会显示一个看似可信的页面,但实际上这些设置只是浏览器保留的旧配置,页面上仍然会显示“不安全”提示。

    预览环境使用有效的证书通过HTTPS加载

    验证防护机制是否正常工作

    请确认配额设置是否处于激活状态:

    kubectl describe resourcequota preview-quota --namespace=pr-2
    
    kubectl describe resourcequota的输出结果,其中列出了命名空间pr-1中preview-quota的CPU、内存、Pod、服务及负载均衡器的使用量与上限

    您会看到每种资源的实际使用量与上限数值。接下来,尝试突破这些限制:

    kubectl scale deployment/preview-app --replicas=50 --namespace=pr-2
    kubectl get events --namespace=pr-2 | grep -i quota
    

    系统会尝试扩展部署规模,但最终会停止执行操作,因为请求量超出了配额限制。这就是防护机制在发挥作用:该命名空间内的资源使用受到严格限制,而集群的其他部分则不会受到影响。

    终端输出显示部署规模仅扩展到了50个副本,Kubernetes事件提示请求量超过了命名空间配额

    验证自动删除机制及Reaper工具的功能

    关闭该拉取请求,此时自动删除流程将会被触发,相应的命名空间状态会变为Terminating。

    GitHub Actions中的自动删除流程已完成

    接下来,测试Reaper工具是否会对某个本不应被删除的命名空间产生作用。可以手动创建一个没有pr-preview标签的命名空间:

    kubectl create namespace pr-9999
    终端界面显示正在创建没有pr-preview标签的命名空间pr-9999

    然后通过“Actions”选项卡手动运行Reaper工具。

    GitHub Actions界面显示“Reap Orphaned Preview Environments”流程,其中“Run”按钮可通过workflow_dispatch来使用

    Reaper工具应该会完全忽略pr-9999这个命名空间,因为该命名空间并没有app.kubernetes.io/managed-by=pr-preview标签。日志中也不会提到它。

    GitHub Actions的日志显示Reaper工具未找到任何需要处理的预览环境,因此完全忽略了没有标签的pr-9999命名空间

    你需要自己手动删除这个命名空间:

    kubectl delete namespace pr-9999

    这项测试其实非常重要。通过它,你可以确认自己的自动删除机制确实不会去影响那些并非自己创建的命名空间。

    这需要多少成本

    由于成本控制是采用这种模式的重要考虑因素,所以我们有必要具体说明一下相关费用。

    如果在europe-west2地区持续运行该系统,那么每月的大致费用如下:

    项目 大致费用
    GKE控制平面 约74美元/月(一个区域集群;某些账户的免费 tier可覆盖一个集群)
    2个e2-standard-2节点 约97美元/月
    区域负载均衡器 约18美元/月,另需支付数据处理费用
    静态IP地址 连接使用时免费
    Artifact Registry存储空间 约0.10美元/GB/月
    Let’s Encrypt证书 免费
    GitHub Actions(公共仓库) 免费

    总的来说,无论你运行多少个预览环境,这套持续运行的基础设施每月所需的费用大约为190美元。

    真正重要的在于那些不会随着拉取请求的数量而增加的成本。一个负载均衡器可以为所有环境提供服务;一条DNS记录就可以覆盖所有的主机名;证书也是免费的。对于这样一个规模的应用程序来说,建立第十一个预览环境的边际成本仅仅就是这些节点所消耗的资源容量,每天只需要花费几便士而已。

    相比之下,如果为每个环境分别创建一个LoadBalancer服务,那么十个未解决的拉取请求就会使负载均衡器的使用成本每月增加大约180美元,而这还不包括那些被遗忘而迟迟没有处理的拉取请求。在ResourceQuota配置中设置services.loadbalancers: "0"这一条规则,正是为了防止人们犯这种错误。

    对于个人项目来说,有两种方法可以降低长期运营成本:一种是使用临时节点,在Terraform的node_config配置中添加spot = true选项,这样就可以以大约60%到70%的价格购买节点使用时间;另一种方法是利用预览环境——如果某个节点被回收,相应的Pod会自动重新调度,最坏的情况也不过是预览环境短暂重启而已。当然,也可以通过定时任务在夜间将节点池的资源完全释放出来,因为毕竟没人会在凌晨3点去审查拉取请求。

    清理工作

    当您完成所有操作后,请执行以下命令:

    
    # 删除所有的预览命名空间
    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控制器所使用的Google Cloud负载均衡器是由Kubernetes创建的,而不是Terraform,所以如果先销毁集群,这个负载均衡器可能会被遗弃,从而导致不必要的费用产生。

    如果您打算以后重新构建这个项目

    请不要直接运行terraform destroy命令。只需删除那些成本较高的部分即可:

    
    cd terraform 
    terraform destroy 
    -var="project_id=YOURPROJECT_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=YOURPROJECT_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令牌来验证身份,而无需使用长期有效的服务账户密钥;而且这些令牌的作用范围仅限于特定的仓库。

    你只编写了一次Kubernetes配置文件,然后通过运行时生成的Kustomize配置文件,在每次提交请求时都能将相同的程序部署到不同的命名空间中,同时为每个新环境分配不同的镜像标签和主机名。你在所有环境中都使用了一个共享的Ingress控制器,并为其分配了固定的静态IP地址,这样就可以通过一个负载均衡器来服务多个预览环境。你还设置了一条通配符DNS记录,这样一来新的环境就不需要再进行任何DNS配置设置了:所有的路由请求都在集群内部的Host头字段中完成处理。

    你为每个命名空间都设置了ResourceQuota和LimitRange限制,其中某些限制措施从结构上就确保了不可能出现最糟糕的情况。此外,你还编写了一个自动清理程序,用于处理那些在正常流程中被忽略的异常情况,这个程序只会针对那些你能确认是自己创建的命名空间进行操作。

    最终的结果是:你得到了一个隔离性良好、具备生产环境特性且能够公开访问的系统环境,这种环境设置已经成为默认标准,开发者根本不需要专门申请或去处理相关的后续事宜。

    下一步该怎么做

    有几种方法可以尝试。以下是这些方法的列表,大致按照它们对实际团队使用体验的改善程度排序:

    1. 为每个环境准备初始数据。我们当前构建的系统最大的局限性在于它属于无状态架构。而真实的应用程序通常需要数据库来存储数据。常见的解决方案包括:为每个命名空间创建一个基于经过清理后的快照初始化的临时数据库,或者使用一个支持不同环境之间模式隔离的共享数据库。这是预览环境中最棘手的问题,也是大部分开发工作需要集中解决的地方。

    2. 使用预览环境进行测试。一旦环境拥有了真实的URL地址,就可以在持续集成系统中针对该环境进行端到端的测试了。这种测试方式比使用模拟环境进行的测试效果要好得多。

    3. 通过DNS-01机制获取通配符证书。如第15步所述,这种方法可以有效地解决当你的拉取请求量接近Let’s Encrypt的限制时所遇到的问题。

    4. 转向GitOps架构。不要直接使用`kubectl apply`命令来执行工作流程,而是应该将生成后的配置文件提交到仓库中,然后让Argo CD或Flux来负责对这些配置进行同步。这样一来,集群的状态就会拥有完整的Git历史记录,而你的持续集成管道也就完全不需要具备对集群的写入权限了。

    5. 监控预览环境的各项指标。

      将相关命名空间接入到你平时用于监控成本的工具中,这样你就能准确地知道“预览环境每月实际上会给我们带来多少开销”,而不再只是凭猜测来估算。

    项目文件结构

    pr-preview-envs/
    ├── app/
    │   ├── main.go                      — Go语言编写的HTTP服务器,用于报告构建过程中的元数据
    │   ├── go.mod                       — Go模块的定义文件
    │   └── Dockerfile                   — 多阶段构建脚本,支持在构建过程中插入特定的参数
    ├── terraform/
    │   ├── main.tf                      — 用于配置GKE、Artifact Registry、WIF以及静态IP地址的相关设置
    │   ├── variables.tf                 — 输入变量配置文件
    │   └── outputs.tf                   — 集群相关配置信息,包括WIF和Ingress IP地址等
    ├── k8s/
    │   ├── base/
    │   │   ├── deployment.yaml          — 部署配置文件,包含探针设置及Downward API相关内容
    │   │   ├── service.yaml             — ClusterIP类型的服务配置文件
    │   │   ├── ingress.yaml             — Ingress配置文件,其中包含了cert-manager相关的注释
    │   │   └── kustomization.yaml       — 基础的Kustomize配置文件
    │   └── cluster/
    │       ├── letsencrypt-staging.yaml — 预发布环境的ClusterIssuer配置文件
    │       ├── letsencrypt-prod.yaml    — 生产环境的ClusterIssuer配置文件
    │       └── namespace-guardrails.yaml — 用于控制资源配额和限制范围的配置文件
    └── .github/
        └── workflows/
            ├── preview-deploy.yml       — 负责构建、部署、处理证书相关事宜以及编写拉取请求评论的脚本
            ├── preview-teardown.yml     — 在拉取请求关闭后删除相关命名空间的脚本
            └── preview-reaper.yml       — 定时清理不再需要的资源的脚本
    

    本教程所有的源代码文件都存储在相应的GitHub仓库中:https://github.com/Amiynarh/pr-preview-envs。

相关文章

技术实践

如何阻止人工智能代理生成尼日利亚的邮政编码

2026年10月1日,尼日利亚的邮政编码从六位数字改为由11个字符组成的代码,这种编码能够唯一标识某栋建筑,例如 EK-01-A03-FK-01 。 在此之前训练的语言模型仍然会自信地使用旧格式来处理邮政编码。这会影响两种类型的程序:一种是负责生成验证代码的编程程序,另一种是在运行时从客户信息中读取邮政编码或填写配送表格的程序。 本文将介绍如何解决这个问题。对于那些生成验证代码的程序,你需要检测它们目前生成的代码是否存在错误,并为它们提供相应的规则来纠正这些错误;而对于在运行时处理邮政编码的程序,则需要为它们设定明确的边界:当模型提出一个邮政编码时,由特定的程序来判断该编码是否符合规范、是否已

阅读全文
技术实践

如何在数据传输流程中对个人身份信息进行加密,同时确保这些信息仍可被检索到

在Intuit,我的团队构建了一个数据处理流程,用于处理TurboTax电子申报系统中产生的数据。这些数据需要被用于制作仪表板、进行分析,以及满足其他后续使用需求。 其中最大的挑战之一就是如何保护那些属于 个人身份信息 的数据,比如社会安全号码。这类信息具有极高的敏感性,因此在数据处理的每一个环节都必须得到妥善保护。与此同时,分析团队有时也需要进行一些正常的业务操作,例如识别与特定客户相关的记录,或者进行精确匹配查询。 那么,如何在保证这些敏感信息不被泄露的同时,允许人们对这些数据进行受控的搜索呢?在本文中,我将解释解决这一问题所涉及的加密技术原理,包括以下内容: 概率加密与确定性加密 对加密

阅读全文
技术实践

如何使用Next.js、Supabase以及TypeSafe Jev来构建一个用于筛选人工智能相关简历的工具

每当我们发布一份工程招聘启事时,一周内就会收到300到400份简历。仔细阅读每一份简历大约需要两分钟的时间。因此,仅仅为了处理一个职位空缺,我们在开始任何面试之前就已经要花费11个小时来处理这些简历了。 但实际上,并没有人会真的去阅读每一份简历。相反,人力资源部门会进行快速的筛选工作。他们会快速浏览应聘者的职位名称、工作经验年限以及所掌握的技术框架,然后在大约15秒的时间内,将简历分为“需要进一步查看”和“可能不符合要求”两类。当他们看到第40份简历时,他们对这份简历的关注程度就已经远远低于对第4份简历时的关注了。 我们的目标就是取代这种快速筛选的过程,而不是放弃阅读简历这一环节。当阅读简历确

阅读全文
技术实践

学习如何部署、保护以及自动化全栈Web应用程序的开发过程。

云基础设施 配置Ubuntu服务器,启用安全的SSH密钥认证机制,并完全禁用密码登录和root账户登录。 安全与防火墙设置 使用UFW阻止未经授权的端口访问,同时配置Fail2Ban来自动拦截暴力攻击。 应用运行环境搭建 安装Python和Node.js,为资源有限的服务器配置交换内存,并使用Gunicorn和Supervisor来管理后台进程。 数据存储与搜索功能 自行搭建轻量级的Meilisearch实例,为其分配专用的系统用户账户,并实现数据库快照的自动化生成。 全球部署方案 通过DNS将自定义域名映射到服务器上,使用Let’s Encrypt证书确保连接安全,配置Nginx作为反向代理

阅读全文