优雅升级背后的想法是在进程运行时交换其配置和代码,而不会引起任何人的注意。如果这听起来容易出错、危险、不受欢迎并且通常是个坏主意——我支持你。但是,有时您确实需要它们。这通常发生在没有负载均衡层的环境中。我们在 Cloudflare 有这些,这导致我们调查并实施了各种解决方案来解决这个问题。

Dingle Dangle!格兰特 C. ( CC-BY 2.0)

巧合的是,实现优雅升级涉及一些有趣的低级系统编程,这可能就是为什么已经有大量选项的原因。继续阅读以了解有哪些取舍,以及为什么您应该真正使用我们即将开源的 Go 库。对于不耐烦的人,代码在github 上  ,您可以阅读godoc 上的文档

基础

那么进程执行优雅升级意味着什么?让我们以 Web 服务器为例:我们希望能够在其上触发 HTTP 请求,并且永远不会看到错误,因为正在进行优雅升级。

我们知道 HTTP 在底层使用 TCP,并且我们使用 BSD 套接字 API 与 TCP 交互。我们已经告诉操作系统我们希望在端口 80 上接收连接,并且操作系统给了我们一个监听套接字,我们调用Accept()来等待新的客户端。

如果操作系统不知道端口 80 的侦听套接字,或者没有任何内容在其上调用Accept(),则新客户端将被拒绝。优雅升级的诀窍是确保在我们以某种方式重新启动服务时不会发生这两件事。让我们看看我们可以实现这一目标的所有方法,从简单到复杂。

只是 Exec()

好吧,这有多难。让我们只是Exec()新的二进制文件(先不做fork)。这正是我们想要的,通过用磁盘中的新代码替换当前运行的代码。

// The following is pseudo-Go.

func main() {
var ln net.Listener
if isUpgrade {
ln = net.FileListener(os.NewFile(uintptr(fdNumber), "listener"))
} else {
ln = net.Listen(network, address)
}

go handleRequests(ln)

<-waitForUpgradeRequest

syscall.Exec(os.Argv[0], os.Argv[1:], os.Environ())
}

不幸的是,这有一个致命的缺陷,因为我们无法“撤消”执行。想象一个配置文件中有太多的空格或一个额外的分号。新进程将尝试读取该文件,出现错误并退出。

即使 exec 调用有效,此解决方案也假定新进程的初始化实际上是即时的。我们可能会遇到内核拒绝新连接的情况,因为侦听队列溢出

如果Accept()未足够频繁地调用,新连接可能会被丢弃

具体来说,新的二进制文件将在 Exec() 之后花费一些时间来初始化,这会延迟对 Accept() 的调用。这意味着新连接的积压会增长,直到某些连接被删除。普通的exec不在游戏中

Listen() 所有的事情

由于只使用 exec 是不可能的,我们可以尝试下一个最好的方法。让我们 fork 并执行一个新进程,然后执行其通常的启动例程。在某些时候,它会通过侦听某些地址来创建一些套接字,但由于 errno 48(也称为地址已在使用中)而无法开箱即用。内核阻止我们监听旧进程使用的地址和端口组合。

当然,有一个标志可以解决这个问题:SO_REUSEPORT. 这告诉内核忽略给定地址和端口已经有一个侦听套接字的事实,而只是分配一个新的。

func main() {
ln := net.ListenWithReusePort(network, address)

go handleRequests(ln)

<-waitForUpgradeRequest

cmd := exec.Command(os.Argv[0], os.Argv[1:])
cmd.Start()

<-waitForNewProcess
}

现在两个进程都有工作监听套接字并且升级工作正常。对?

SO_REUSEPORT它在内核中的作用有点特殊。作为系统程序员,我们倾向于将套接字视为套接字调用返回的文件描述符。然而,内核对套接字的数据结构和指向它的一个或多个文件描述符进行了区分。如果您使用 SO_REUSEPORT绑定,它会创建一个单独的套接字结构,而不仅仅是另一个文件描述符。因此,旧进程和新进程指的是两个独立的套接字,它们恰好共享相同的地址。这会导致不可避免的竞争条件:旧进程使用的套接字上的新但尚未接受的连接将被内核孤立并终止。GitHub 写了一篇关于这个问题的优秀博客文章

GitHub 的工程师通过使用 sendmsg 系统调用的一项名为辅助数据的模糊功能解决了SO_REUSEPORT问题事实证明,辅助数据可以包括文件描述符。使用这个 API 对 GitHub 来说是有意义的,因为它允许他们优雅地与 HAProxy 集成。由于我们可以更改程序,因此我们可以使用更简单的替代方案。

NGINX:通过 fork 和 exec 共享套接字

NGINX 是 Internet 久经考验且值得信赖的主力,并且恰好支持优雅升级。作为奖励,我们也在 Cloudflare 使用它,因此我们对其实施充满信心。

它是用每核进程模型编写的,这意味着 NGINX 不会产生一堆线程,而是在每个逻辑 CPU 核上运行一个进程。此外,还有一个协调优雅升级的主要过程。

主节点负责创建NGINX使用的所有监听套接字,并与工作线程共享。这相当简单:首先,清除所有侦听套接字上的FD_CLOEXEC位。这意味着在创建exec()系统调用时时它们不会关闭。然后,主节点会按照惯例fork()/exec()跳舞来生成workers,将文件描述符编号作为环境变量传递。

优雅升级使用相同的机制。我们可以按照 NGINX 文档生成一个新的主进程(PID 1176)这就像workers一样从旧的主进程(PID 1017)继承现有的侦听器。然后新的主节点产生自己的workers:

 CGroup: /system.slice/nginx.service
├─1017 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
├─1019 nginx: worker process
├─1021 nginx: worker process
├─1024 nginx: worker process
├─1026 nginx: worker process
├─1027 nginx: worker process
├─1028 nginx: worker process
├─1029 nginx: worker process
├─1030 nginx: worker process
├─1176 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
├─1187 nginx: worker process
├─1188 nginx: worker process
├─1190 nginx: worker process
├─1191 nginx: worker process
├─1192 nginx: worker process
├─1193 nginx: worker process
├─1194 nginx: worker process
└─1195 nginx: worker process

此时有两个完全独立的 NGINX 进程在运行。PID 1176 可能是 NGINX 的新版本,或者可以使用更新的配置文件。当端口 80 的新连接到达时,内核会选择 16 个工作进程之一。

执行完剩下的步骤后,我们最终得到了一个完全替换的 NGINX:

   CGroup: /system.slice/nginx.service
├─1176 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
├─1187 nginx: worker process
├─1188 nginx: worker process
├─1190 nginx: worker process
├─1191 nginx: worker process
├─1192 nginx: worker process
├─1193 nginx: worker process
├─1194 nginx: worker process
└─1195 nginx: worker process

现在,当请求到达时,内核会在剩余的八个进程之一中进行选择。

这个过程相当多变,所以 NGINX 有一个适当的保护措施。尝试在第一次升级尚未完成时请求第二次升级,您将在错误日志中找到以下消息:

[crit] 1176#1176: the changing binary signal is ignored: you should shutdown or terminate before either old or new binary's process

这是非常明智的,没有充分的理由为什么在任何给定时间点应该有两个以上的进程。在最好的情况下,我们也希望我们的 Go 解决方案具有这种行为。

优雅升级愿望清单

NGINX 实现优雅升级的方式非常好。有一个明确的生命周期决定了任何时间点的有效操作:

它还解决了我们在其他方法中发现的问题。真的,我们希望 NGINX 风格的优雅升级作为 Go 库。

  • 升级成功后没有旧代码继续运行
  • 新进程在初始化期间可能会崩溃,但不会产生不良影响
  • 在任何时间点都只有一个升级处于活动状态

当然,Go 社区专门为这个场合制作了一些优秀的库。我们看了

仅举几个。它们中的每一个在实现和权衡方面都不同,但没有一个能满足我们的所有要求。最常见的问题是它们旨在优雅地升级 http.Server。这使得他们的 API 更好,但消除了我们需要支持其他基于套接字的协议的灵活性。所以真的,除了编写我们自己的库,称为 tableflip 之外,绝对别无选择。玩得开心不是等式的一部分。

tableflip

tableflip 是一个用于 NGINX 风格优雅升级的 Go 库。这是使用它的样子:

upg, _ := tableflip.New(tableflip.Options{})
defer upg.Stop()

// Do an upgrade on SIGHUP
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGHUP)
for range sig {
_ = upg.Upgrade()
}
}()

// Start a HTTP server
ln, _ := upg.Fds.Listen("tcp", "localhost:8080")
server := http.Server{}
go server.Serve(ln)

// Tell the parent we are ready
_ = upg.Ready()

// Wait to be replaced with a new process
<-upg.Exit()

// Wait for connections to drain.
server.Shutdown(context.TODO())

调用Upgrader.Upgrade会产生一个带有必要的 net.Listeners 的新进程,并等待新进程发出信号,表明它已完成初始化、终止或超时。在升级过程中调用它会返回错误。

Upgrader.Fds.Listen受启发facebookgo/grace并允许轻松继承 net.Listener。在幕后,Fds确保清理未使用的继承套接字。这包括 UNIX 套接字,由于UnlinkOnClose ,这很棘手。如果您愿意,您也可以直接向上传递*os.File进入新流程。

最后,Upgrader.Ready清理未使用的 fds 并向父进程发出初始化已完成的信号。然后父级可以退出,从而完成正常升级周期。