Go 语言 热更新 实战:从原理到落地
先交个底:严格意义上的热更新,Go 做不到
事情是这样的:手上有个内部网关服务,转发规则改得比谁都想得勤,每次改完都得重启,高峰期重启就是直接掐断在途请求,前端那边肉眼可见地报错。同事问我能不能搞热更新,我心想 Go 是编译型语言,哪来的热更新,但话出口了活儿还得干,就把能想到的几条路全趟了一遍。结论先放这儿:像 Erlang 那种运行中原地换代码,Go 是做不到的,编译完就是一个静态二进制,语言层面压根没留这个口子。平时大家说的“Go 热更新”,十有八九指的是优雅重启——把监听中的 socket 原封不动交给新进程,旧进程把手上请求处理完再退,用户看起来服务没断,效果上等同于热更。
plugin 包:看着像那么回事,用完只想骂人
标准库里有个 plugin 包,能编译出 .so 然后 plugin.Open 加载,听上去就是为热更新准备的,我一开始也是从它入手的,然后一天之内心态就没了:只能在 Linux 上用,Windows 想都别想,macOS 理论上支持但基本是坏的;主程序和插件必须用同一个版本的 Go 编译,连公共依赖的版本都得完全一致,差一点就报 plugin was built with a different version of package xxx,这个报错唯一的作用是告诉你“反正就是不行”;plugin.Open 之后没有 unload,反复加载内存只涨不降;最要命的是插件里一个 panic,整个主程序跟着一起走。用它做热更新不如说是给自己埋雷,后面就没再碰了。
实战:手写优雅重启
回到正路。思路不复杂:父进程把监听的 fd 通过 exec.Cmd 的 ExtraFiles 传给子进程(0/1/2 被标准输入输出占了,所以从 3 开始算),子进程拿这个 fd 还原出 net.Listener 继续接受连接,父进程再调 http.Server.Shutdown 把在途请求处理完,然后退出。
子进程这边:
func listen() (net.Listener, error) {
fdStr := os.Getenv("LISTEN_FD")
if fdStr == "" {
return net.Listen("tcp", ":8080") // 没有环境变量就是正常冷启动
}
fd, err := strconv.Atoi(fdStr)
if err != nil {
return nil, err
}
f := os.NewFile(uintptr(fd), "")
defer f.Close()
return net.FileListener(f) // 把 fd 还原成 Listener
}
父进程收到 SIGHUP 的时候:
func restart(l net.Listener) error {
tcp, ok := l.(*net.TCPListener)
if !ok {
return errors.New("only tcp listener supported")
}
f, err := tcp.File() // 拿到底层 fd
if err != nil {
return err
}
defer f.Close()
// 注意这里别偷懒直接写 /proc/self/exe,原因见下文
bin := os.Getenv("SERVER_BIN")
if bin == "" {
bin = "/proc/self/exe"
}
cmd := exec.Command(bin)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.ExtraFiles = []*os.File{f} // 子进程里就是 fd 3
cmd.Env = append(os.Environ(), "LISTEN_FD=3", "SERVER_BIN="+bin)
return cmd.Start()
}
信号处理和优雅退出的部分:
srv := &http.Server{Handler: mux}
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGHUP)
for range sig {
if err := restart(l); err != nil {
log.Println("restart failed:", err)
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx) // 等在途请求处理完,超时兜底
}
}()
srv.Serve(l)
更新流程就三行:
go build -o server.new .
mv server.new server # 别用 cp 直接覆盖,会报 text file busy
kill -HUP $(pgrep server)
不出问题的话就没有问题了,你会看到进程 pid 换了,端口全程能通,curl 一次都不会失败,这就成了。
几个不踩不知道的坑
第一个,mv 是原子的,跑着的进程不受影响,但注意新二进制编好之后,旧进程拉起的新进程必须跑的是新文件。如果代码里写的是 /proc/self/exe,mv 之后它指向的还是被改名掉的旧 inode(readlink 一下能看到后面挂了个 (deleted)),等于升级了个寂寞。这个坑我排查了半天,还以为是自己编译没生效。所以我现在的习惯是启动脚本里统一带上 SERVER_BIN=$(pwd)/server,显式把路径传过去,每次重启都按路径重新解析,拿到的永远是最新文件。
第二个,讲究一点的话,子进程就绪后应该通知父进程(比如传一个管道,往里写个字节),父进程收到再 Shutdown,时序上更稳。图省事直接退也行,内核的 accept backlog 会把新连接兜住,我实测也没出过事,看你对自己服务的容忍度。
不想自己写,用现成的
fd 传递这套东西第一次写会觉得别扭,写完其实就是模板代码。想省事的话直接用 cloudflare/tableflip,升级流程、等待旧进程退出这些都封装好了,我后来在别的服务里直接换的它。Facebook 早年的 facebookarchive/grace 也能跑,不过归档好几年了,没准儿你用的时候连依赖都拉不动了,新项目就别碰了。
另外还有个更粗的野路子:SO_REUSEPORT,新旧两个进程同时 bind 同一个端口,内核做负载均衡,新进程起来后旧的再退。写起来最简单,但关停期间个别新连接可能被分给快死的旧进程,直接吃 reset,对内服务无所谓,对外还是老老实实传 fd 吧。
如果需求是“改逻辑不重启”
有些场景确实要原地换逻辑,比如限流规则、风控策略,改完一分钟内要生效,连优雅重启都嫌重。这种情况就别在二进制上较劲了,往进程里嵌个解释器,Go 生态里 yaegi(Traefik 那家做的)可以直接跑 Go 源码:
i := interp.New(interp.Options{})
i.Use(stdlib.Symbols)
// 规则文件改完,重新 Eval 一遍就生效,进程完全不动
i.Eval(string(src))
代价也明摆着:解释执行性能差一截,别把核心热路径塞进去。我们只把策略判断这类改动频繁的逻辑交给它,跑了一年,除了偶尔语法写错需要重读文件,没出过别的幺蛾子。
最后
总结一下:Go 的热更新,本质是把“重启”做到无感——plugin 包是坑,优雅重启是正路,改逻辑就嵌解释器。上面那几段代码我抽成了个小工具放在网关项目里,一直用到现在。你要是只想用不想写,直接上 tableflip,fd 传递那些边边角角的坑它都替你踩过了。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。