基础设施

Cloud Run 服务与作业:一个 Go 二进制文件,两个入口点

Cloud Run 服务和作业可以共享一个 Go 镜像和一次构建。本文介绍单个二进制文件如何拆分为服务模式和批处理作业,以及何时不应这样做。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

Cloud Run 提供了两种运行容器的方式。Service 会保持运行并响应 HTTP 请求。Job 会启动、执行工作,然后退出。大多数指南将它们视为使用独立镜像的独立产品。但并非必须如此。我们使用同一个 Go 二进制文件、同一个镜像和同一个构建标签来运行一个 Web 服务器及其批处理作业。该二进制文件会检查其第一个参数,并决定要进入哪种模式。本文将展示分派代码、部署形态,以及在何种情况下,将其拆分为两个镜像反而是正确的做法。

一个二进制文件如何选择其入口点

整个技巧在于,一个容器镜像只有一个 ENTRYPOINT,但 Cloud Run 允许每个服务或作业向该入口点传递自己的参数。因此,二进制文件会读取第一个参数并进行分支。

func main() {
	if len(os.Args) < 2 {
		log.Fatal("usage: blog-engine <serve|ingest|index>")
	}
	switch os.Args[1] {
	case "serve":
		runServe(os.Args[2:]) // Cloud Run Service: long-lived HTTP
	case "ingest":
		runIngest(os.Args[2:]) // Cloud Run Job: parse, translate, load
	case "index":
		runIndex(os.Args[2:]) // Cloud Run Job: create DB indexes, then exit
	default:
		log.Fatalf("unknown command %q", os.Args[1])
	}
}

Dockerfile 将入口点设置为该二进制文件,仅此而已。它不会固化任何模式。

ENTRYPOINT ["/blog-engine"]

服务在创建时以 serve 作为其参数。每个作业在创建时都有自己的动词。镜像摘要相同,但第一个令牌不同。

gcloud run deploy blog-engine \
  --image "$IMAGE" --args serve

gcloud run jobs create blog-engine-ingest \
  --image "$IMAGE" --args ingest

一次构建产生一个构件。服务和每个作业拉取完全相同的字节,因此提供页面的代码和将该页面写入数据库的代码之间没有版本偏差。

serve 模式是一个 Cloud Run 服务

serve 是一个请求-响应进程,它本身不应该自行结束。Cloud Run 会使其保持“热”状态,将 HTTP 流量路由到它,并根据流量伸缩实例数量。由于博客需要为搜索引擎爬虫提供快速的首页字节,该服务以 min-instances 1 运行,因此总有一个实例处于就绪状态,在宁静早晨的第一个请求到来时不会有冷启动。

服务必须正确处理的一个平台细节是关闭。当 Cloud Run 缩减实例或推出新修订版本时,它会发送 SIGTERM 信号,并在发送 SIGKILL 信号之前给予进程一个短暂的宽限期。忽略 SIGTERM 的服务器会丢弃正在处理的请求。因此,serve 会阻塞在该信号上并进行排空(drain)。

func runServe(args []string) {
	srv := &http.Server{Addr: ":" + port(), Handler: router()}
	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("serve: %v", err)
		}
	}()

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
	defer stop()
	<-ctx.Done() // block here for the life of the instance

	shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	_ = srv.Shutdown(shutCtx) // let open requests finish
}

其结构很简单:启动监听器,等待终止信号,然后给打开的连接一个有界的时间窗口来完成。关于该服务的一切都假定进程的生命周期比任何单个请求都要长。

ingest 和 index 模式是 Cloud Run Jobs

Job 是一个相反的契约。它不附加到端口,也不接收任何流量。它会运行至完成然后退出,其退出代码就是结果。退出代码为零表示任务成功。任何非零退出都会将任务标记为失败,这正是触发重试的原因。

func runIngest(args []string) {
	ctx := context.Background()
	if err := ingest.Run(ctx); err != nil {
		log.Fatalf("ingest: %v", err) // non-zero exit, the Job task fails
	}
	// clean return, exit 0, the Job task succeeds
}

对于博客而言,ingest 会解析 Markdown 博文,将更改过的部分发送给翻译模型,并将结果写入 Firestore。index 会一次性创建数据库索引然后退出。两者都不属于 Web 请求。它们可以运行数分钟,按计划或手动触发,其成功与否是“是”或“否”的判断,而不是一个 HTTP 正文。

Job 还带有 Service 所没有的自身执行设置。这些是对于批处理步骤很重要的设置。

设置 控制内容 Ingest 值
task-timeout 任务被终止前的最长持续时间 3600s
max-retries 每个失败任务的重试次数 0
parallelism 同时运行的任务数 1

该博客设置 max-retries 0 是因为一个未完成的翻译运行不应该被静默重复。失败应该停止并接受检查,而不是循环。另一个工作负载可能需要重试和高并行度,例如处理独立文件的扇出(fan-out)。关键在于,这些控制选项存在于 Job 上,而作为 Service 运行的同一个二进制文件永远不会看到它们。

为何使用单个二进制文件而非两个仓库

将它们融合在一起的原因是,Web 服务器和批处理作业并非毫无关联。它们共享入口点之下的几乎所有内容。服务代码从 store 包中读取文章,而数据提取也使用同一个包写入文章。两者都使用相同的数据模型、相同的分类加载器、相同的 Firestore 连接代码以及相同的配置解析。

如果将其拆分为两个仓库或两个镜像,你就需要维护两份存储层的副本,或者一个带有自身版本控制的共享库。这两部分会逐渐产生差异。服务器开始期望一个数据提取端尚未写入的字段,或者数据提取端写入了一个服务器已更新并停止读取的结构。这类错误是不可见的,直到数据在不同版本的两个半部分之间流动时才会暴露出来。

使用单个二进制文件,Post 结构体只有一个定义的地方,查询结构也只有一个存在的地方,并且一次编译要么对两种模式都成功,要么都失败。如果数据提取开始写入一个新字段,服务代码会在同一次构建中针对相同的定义进行编译。写入方和读取方之间的版本偏差变得不可能发生,因为它们是同一个程序。

这样做的成本是真实存在的,但很小。镜像会携带其当前未运行模式的代码。服务实例的二进制文件中包含未使用的数据提取代码,而数据提取作业则编译了 HTTP 服务器但从不进行监听。对于一个 Go 二进制文件来说,这只是增加了几兆字节,并非真正的限制。你还需要 main 函数中的分发逻辑,也就是上面的 switch 语句。这就是全部的代价。

部署共享镜像而不擦除密钥

共享一个镜像改变了重新部署的方式。只会进行一次构建。当构建完成后,服务(Service)和每个作业(Job)都会指向新的镜像,而它们其他方面没有任何改变。

gcloud run services update blog-engine --image "$IMAGE"
gcloud run jobs update blog-engine-ingest --image "$IMAGE"

一个惨痛的教训换来的规则是:重新部署时,只动 --image,不动其他任何东西。环境变量和密钥在创建时就已一次性设置好。如果重新部署时也传递了 --set-secrets--set-env-vars,这些标志会替换掉整个现有的集合,而不是合并进去。在重新部署命令中漏掉一个密钥,它就会从正在运行的服务中消失。第一次在生产环境中发生这种情况时,服务器会因数据库 URL 为空而启动,导致每个页面都返回 500 错误。因此,create 命令是用来设置 envsecrets 的地方,而 update 命令只携带镜像信息。

值得将作业(Job)作为列表来管理,而不是使用单个硬编码的名称,因为批处理工作负载很少会长期只有一个作业。

JOBS=("blog-engine-ingest")

for job in "${JOBS[@]}"; do
  gcloud run jobs update "$job" --image "$IMAGE"
done

当出现第二个作业时,比如每晚重建站点地图,它会被加入数组,然后循环会用同一个镜像来更新它。如果忘记添加它,那个作业就会在没有任何警告的情况下继续运行旧镜像,因为一个过时的作业不会失败,它只是悄悄地做着错误的事情。数组让完整的作业集合成为一个你可以查看和扩展的整体。

何时应使用作业而不是后台 goroutine

一个诱人的捷径是将所有内容都保留在服务中。你已经有了一个长期运行的进程,那么为什么不在响应请求后,在一个 goroutine 中启动繁重的工作呢?对于任何需要大量 CPU 或数分钟实际运行时间的任务,在 Cloud Run 上这是一种错误的直觉。

服务实例的计费和扩缩容是围绕请求进行的。在启动它的请求返回后,当实例缩容时,goroutine 中的后台工作可能会被中断,并且它会与请求处理争夺为该实例分配的 CPU。这项工作没有任务超时、没有重试,也没有明确的成功或失败信号。它对平台是不可见的。

对于在请求之外运行的任何任务,作业是正确的选择。这个决定近乎是机械性的:

  • 响应 HTTP 请求、保持会话、提供页面:服务。
  • 定时任务、数据迁移、批量导入、夜间重建:作业。
  • 在请求之外需要大量 CPU 或较长实际运行时间:作业,而不是挂在服务上的 goroutine。

博客的数据采集是一个教科书式的案例。它按计划运行,可能需要数分钟,需要严格的超时和明确的失败与否的结果,并且绝不能占用页面渲染的 CPU 周期。这是一个作业,即使服务已经在运行相同的二进制文件,并且技术上可以完成这项工作。

简单说明其中的权衡

一个具有两个入口点的二进制文件,为你带来的是单一的构建、单一的版本,以及写入数据的代码和读取数据的代码之间没有差异。对于一个服务器和批处理作业共享数据模型的系统来说,这些都是巨大的优势。为此付出的代价是稍大一些的镜像和 main 函数中一个小小的分发开关。对于大多数在在线路径和离线路径之间共享代码的服务而言,这是一笔划算的交易。

当两边不再共享时,这就不再是一笔划算的交易了。如果你的 Job 引入了服务器不需要的重度依赖树,例如机器学习运行时或大型数据工具包,那么将其并入服务器镜像会使每个服务实例及其冷启动因从未运行的代码而变得臃肿。如果这两条路径由不同的团队所有,并按不同的节奏部署,那么将它们强制放入一个构建标签中,就会将想要独立推进的发布耦合在一起。而且,如果它们真的什么都不共享,没有模型、没有存储、没有配置,那么这个单一的二进制文件就只是两个不相关的程序共用一个入口点,而使用两个镜像会更清晰。

检验标准在于入口点之下的共享代码。当 Service 和 Job 通过相同的包读写相同的类型时,就将它们保留在一个二进制文件中,并让第一个参数来选择模式。当它们在依赖、所有权或数据方面出现分歧时,就拆分镜像,让每一方按自己的方式构建。