[翻译]用 Go 实现零停机升级 TCP 服务

零停机升级几乎是现代网络服务的标配,其实现原理并不复杂……blablabla……( 从文件描述符讲起,省略一万字)。现在有人确认 Go 也可实现零停机升级 TCP 服务或者更加简短的叫法——热更新。

原文在此:Zero Downtime upgrades of TCP servers in Go

—————-翻译分隔线—————-

用 Go 实现零停机升级 TCP 服务

最近在 golang-nuts 邮件列表上有篇帖子提到 Nginx 可以保持服务的时候进行升级,而无需停止它正在监听的 socket。秘诀是取消监听的 socket 上的 close-on-exec,然后 fork 并运行一个新的服务(用升级后的二进制文件),并用参数告诉它使用继承的文件描述符,而不是调用 scoket() 和 listen(s)。

于是我就想试试在 Go 中是否能做到同样的事情,以及对于标准库需要做什么样的修改来达到这个效果。最终我实现了这个功能,而且只需要很小的修改,接下来会解释一下是如何做到的。

相关的代码在这里

在这个程序里有许多有趣的东西,我会逐一介绍。然后,我使用了“接口注入”的模式。这在 Go 中是一个重要的模式,但我不认为这个模式被广泛接受并编写进入文档。

当我开始思考这个问题的时候,我意识到其中的一个问题是需要在 http.(*Server).Serve 内部找到方法,当旧的服务器正确关闭的时候,让其停止调用 Accept()。问题是那里没有钩子;唯一的跳出循环(“Accept,开启一个 goroutine 来处理,然后重复这个过程”)的办法是 Accept 返回一个错误。但是如果你认为 Accept 是一个系统调用,你可能会想:“我不能进入其中,并插入一个错误”。但是 Accept() 不是一个系统调用:它是 net.Listener 的一个接口。这意味着如果创建一个实现了 net.Listener 的自有对象,就可以将其传递给 http.(*Server).Serve 然后在 Accept() 中做想做的事情。

在第一次了解结构的内嵌类型时,我非常迷惑并失去了方向。而尝试的时候,得到了各种混合的指针,并且发生了许多无法解释的空指针错误。这次,我又重新阅读代码,并有了一个大致的概念。当想要注入某个接口的方法时,类型嵌入是必须的。这使得可以继承全部底层对象的实现,然后根据需要重新定义。参阅在 upgradable.go 中的 stoppableListener。net.Listener 接口需要三个方法,包括 Accept, Close 和 Addr。但是我只定义了其中的一个:Accept()。stoppableListener 为何能实现了 net.Listener 呢?因为另外两个方法通过嵌入的方式实现了。只有 Accept() 有更加明确的定义。当编写 Accept() 的时候,我需要明确指出如何同底层对象通信,以便传递 Accept() 的调用。这里的诀窍就是要理解嵌入类型是如何在结构体中使用类型名创建了一个新的字段。因此可以通过 stoppableListenersl 的 sl.Listener 来调用其中的 net.Listener,同时可以通过 sl.Listener.Accept() 调用内部的 Accept()。

接下来,考虑如何处理 Serve() 的“停止”错误。用 os.Exit(0) 立刻退出是不正确的,因为可能还有 goroutine 正在服务 HTTP 客户端。需要某种办法了解到所有的客户端都已经完成。再次利用注入,可以将 Accept() 返回的 net.Conn 进行封装,然后来检测当前运行的连接的数量。这个注入 net.Conn 对象的技术还会有一些其他有趣的应用。例如,通过捕获 Read() 或者 Write() 调用,可以对连接进行强制限速,而无需协议上的任何实现。甚至可以投机取巧地做一些加密之类的蠢事,同样,无需在协议上进行。

当我确信可以完美的关闭服务之后,就需要了解如何在正确的文件描述符上启动一个新的服务。这是通过对 net 包的一个相当简单的改动实现的。参阅补丁。由于我的懒惰,仅仅在 TCPListener 上实现了。理论上,对于使用其他 socket 的服务的零停机升级也是可能的,不过对于 net 包的修改仅仅适用 TCP 服务。Rog Peppe 向我指出了 net.FileListener 对象可以从 *os.File 创建(可以使用 os.NewFile 生成)。

最后一个问题是 net 总是会在其打开的 socket 文件描述符设置 close-on-exec 标识。因此需要在监听的 socket 上关闭它,这样文件描述符在新的进程中也可使用。这需要在 syscall 库上增加一些东西(关闭或不关闭)。

我手工测试了工作正常(在其他窗口用命令行 GET 调用)。同时使用 http_load 测试了负载。在 20 秒的基准负载测试中,得到了 3937 请求/秒的成绩;然后再次测试,添加 “GET http://localhost:8000/upgrade”,同时在负载测试的时候执行了若干次二进制文件的替换,这次得到了 3880 请求/秒的成绩!这很酷!

Join the Conversation

5 Comments

  1. 好文,但是对于网游这种有较复杂状态需要维持的应用,还是比较难解决啊。

Leave a comment

Your email address will not be published. Required fields are marked *