Loading [MathJax]/jax/output/CommonHTML/config.js
前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >平滑重启你的后台TCP服务

平滑重启你的后台TCP服务

原创
作者头像
glendai
发布于 2022-02-11 10:54:57
发布于 2022-02-11 10:54:57
2.5K0
举报
文章被收录于专栏:网络加速网络加速

1. 何为平滑重启以及为何平滑重启重要?

后台业务一般都是通过TCP协议提供服务。服务难免需要版本升级,需要经历旧进程的退出和新进程的启动。为保证用户链接不异常中断,需要旧进程继续运行,直至处理完用户请求后再退出。这样才不会打断用户请求,这就是所谓的Graceful Shutdown:优雅退出。如果不做优雅退出,用户交互过程中任何一个步骤可能被升级打断,往小了有些不重要的业务,中断一下可以忍受,但如支付的基础服务,升级服务如果不支持优雅退出,造成大量用户掉线,进而造成恶劣的影响。所以对服务实现,不论对什么业务来说都是很有必要的。这也是为什么Go从1.8版本开始,标准库net/http对HTTPServer就添加了一个新的方法GracefulShutdown,使得进程可以把现有请求都处理完了再退出。

但升级的流程不仅仅包括旧进程的退出,还包括新进程的启动。如何保证升级过程中新用户完全无感知,这就涉及另一个更进阶的话题,也就是所谓的Gracefule Restart: 优雅重启,也叫平滑重启,其目标是在服务升级进程重启过程中要平滑,不要让用户感受到任何异样,不要有任何停机时间,要保证服务持续可用。因此,优雅退出只是实现平滑重启的一个必要部分,平滑重启还要求更多。可见平滑重启是后台服务的一个十分重要的基础能力。

2. 如何实现平滑重启?

平滑重启能力这么重要,要如何实现呢?初看平滑重启只需要:

  1. 旧进程继续运行,停止accpet新链接,只处理已有的历史连接,处理完成后退出;
  2. 新进程accept新连接,接管后续所有新的请求;

1很容易实现:停止accept,关闭监听套接字就好?2看似也很简单:新开一个套接字,监听同一地址,accept新连接?初步看起来,这样做应该能实现平滑重启。让我们具体来分析下,这种方案能否实现我们的平滑重启的需求。

让我们先暂时搁置平滑重启的实现,详细看下linux下TCP连接建立过程中的交互,以及其中的维护的两个队列:

  • 半连接队列:也叫syn队列,服务端收到客户端发起的syn请求后,内核会把该连接存储到半连接队列,并向客户端回复syn+ack;
  • 全连接队列:也叫accept队列;客户端收到服务端的syn+ack后,会向服务端回复ack,完成3次握手后,tcp连接就建立了。这时linux会将该连接从半连接队列放入全连接队列,待服务端调用accept逐个取出来服务。
半连接与全连接队列, 图片来自小林coding博客
半连接与全连接队列, 图片来自小林coding博客

通过上述分析可知,linux下每一个服务端的套接字都维护一个全连接队列和半连接队列。TCP的握手流程是由linux内核协议栈异步完成的。新的用户连接是源源不断过来的,服务端需要把半连接队列(半连接队列的连接完成后续握手流程后会进入全连接队列)和全连接的队列中的连接全部取出来,才不会漏掉用户连接,这样才能做到用户无感知。从这个角度分析来看,服务重启或升级时,新进程新建新的套接字(新套接字有自己的半连接和全连接队列),旧进程停止accept新连接的方案,会导致旧进程全连接队列和半连接队列里的连接被丢掉,要真正做到无损,用户无感知,只剩一种方案,那就是新进程继承旧进程套接字,而不新建套接字。

如果新的用户连接建立的速度远远超过服务端处理速度,还是会造成半连接或全连接队列满后,被内核丢掉连接。这种严重超过服务端处理能力的异常情况,一般是恶意攻击导致的。不在这里的讨论之列。平滑重启需要保证在服务预期的处理能力之内,能做到用户无感知。如何配置这两个队列的大小,以及如何查看队列溢出等异常,可以参考这里 进一步了解。

2.1 fork实现父子进程套接字继承共享

上面讨论到了服务重启或升级时,只有新进程继承旧进程监听的套接字才能真正做到平滑重启。新进程如何继承旧进程的套接字资源呢?答案是:通过Unix类系统独有的fork系统调用可以实现父子进程的资源共享,当然也包括套接字的资源共享,然后使用exec系统调用加载新的二进制更新服务端到新版本。

服务首次启动时,直接监听监听套接字,对外提供服务。通过如下流程完成一次平滑重启:

  1. 通过信号或其他手段,通知当前服务进程fork子进程,子进程启动后,就继承了父进程的套接字资源;
  2. 调用exec加载新的二进制更新服务端到新版本前,通过设置环境变量或其他手段告知哪些fd是继承的套接字资源,服务起来后直接用这些fd,而不是重新监听。
  3. 新进程起来后,通过信号或其他手段通知旧进程停止accept新连接,处理完历史连接后主动退出;

一个Go实现的平滑重启简单代码如下:

代码语言:Go
AI代码解释
复制
package main

import (
    "context"
    "flag"
    "fmt"
    "net"
    "net/http"
    "os"
    "os/exec"
    "os/signal"
    "syscall"
)

var (
    upgrade bool
    ln      net.Listener
    server  *http.Server
)

func init() {
    flag.BoolVar(&upgrade, "upgrade", false, "user can't use this")
}

func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "hello world from pid:%d, ppid: %d\n",
        os.Getpid(), os.Getppid())
}

func main() {
    flag.Parse()
    http.HandleFunc("/", hello)
    server = &http.Server{Addr: ":8999"}
    var err error
    if upgrade { // 如果是平滑重启,会在fork时添加-upgrade的命令行参数
        // 继承的fd时从3开始的,(0,1,2分别备stdin,stdout,stderr占据了)
        fd := os.NewFile(3, "")
        // 平滑重启时,直接通过fd=3来继承套接字, 通过fd构造net.Listener时
        //,会将原理fd dup一份,因而下面要手动close以释放资源
        ln, err = net.FileListener(fd)
        if err != nil {
            fmt.Printf("fileListener fail, error: %s\n", err)
            os.Exit(1)
        }
        fd.Close() // 释放fd 3
    } else { // else分支对应服务首次启动,需要主动listen
        ln, err = net.Listen("tcp", server.Addr)
        if err != nil {
            fmt.Printf("listen %s fail, error: %s\n", server.Addr, err)
            os.Exit(1)
        }
    }
    go func() {
        err := server.Serve(ln)
        if err != nil && err != http.ErrServerClosed {
            fmt.Printf("serve error: %s\n", err)
        }
    }()
    setupSignal()
    fmt.Println("over")
}

func setupSignal() {
    ch := make(chan os.Signal, 1)
    signal.Notify(ch, syscall.SIGUSR2, syscall.SIGINT, syscall.SIGTERM)
    sig := <-ch
    switch sig {
    case syscall.SIGUSR2: // 通过给服务发送USR2信号触发平滑重启
        fmt.Println("SIGUSR2 received")
        err := forkProcess()
        if err != nil {
            fmt.Printf("fork process error: %s\n", err)
        }

        // 调用go标准库里的优雅重启方法,方法中会停止accept新连接,
        // 处理完历史连接后就退出
        err = server.Shutdown(context.Background())
        if err != nil {
            fmt.Printf("shutdown after forking process error: %s\n", err)
        }

    case syscall.SIGINT, syscall.SIGTERM: // 这两个信号只触发优雅退出
        signal.Stop(ch)
        close(ch)
        err := server.Shutdown(context.Background())
        if err != nil {
            fmt.Printf("shutdown error: %s\n", err)
        }
    }
}

func forkProcess() error {
    flags := []string{"-upgrade"} // 添加命令行参数,告知子进程继承fd而不要重新监听
    fmt.Printf("forkProcess - arg: %v", os.Args[0])
    // 将fork+exec两个系统后调用封装到了一起,os.Args[0]就是服务的binary
    // 所在路径,如果升级服务,平滑重启前需要覆盖服务的binary!!!
    cmd := exec.Command(os.Args[0], flags...)
    cmd.Stderr = os.Stderr
    cmd.Stdout = os.Stdout
    l, _ := ln.(*net.TCPListener)
    lfd, err := l.File()
    if err != nil {
        return err
    }
    // ExtraFiles填入继承的fd,GO标准库会保证继承的
    // fd时从3开始(0,1,2分别备stdin,stdout,stderr占据了)
    cmd.ExtraFiles = []*os.File{lfd}
    return cmd.Start()
}

除了父子进程间继承fd以外,还可以通过Unix Domain Socket在不同进程间共享套接字fd,也能达到共享fd的目的,实现原理简单来讲就是kernel帮忙dup了一下给到目的进程。详情参考: https://copyconstruct.medium.com/file-descriptor-transfer-over-unix-domain-sockets-dcbbf5b3b6ec

2.2 kernel新特性reuseport只提升建连效率,【不可以】实现平滑重启

讲完了平滑重启的实现,很多读者有一个误区,认为新版linux内核(>=3.9版本)添加的新特性reuseport特性也能实现平滑重启。事实上新旧进程使用reuseport监听同一地址是做不到无损的平滑重启的

reuseport特性的加入,是可以让多个进程/线程监听同一个地址(ip:port),每监听一次就会新建一个新的套接字,每个套接字都有自己的半连接和全连接队列。内核将不同用户的握手请求随机分配到不同的套接字的半连接队列,完成了完整的握手流程后再进入半连接所在套接字对应的全连接队列中供accept。实现reuseport时为了充分利用多核,提升连接建立的效率

如果启用reuseport,让新进程可以直接监听同一个地址,这会在新进程里创建一个新的套接字。通过上面的分析可知,旧进程的套接字有自己的半连接和全连接队列,新进程的套接字也有自己的半连接和全连接队列。服务升级时,旧进程停止accept,只处理已经accept的历史连接再退出服务,那么在旧进程全连接队列中未被accept的连接旧丢失了,也就实现不了无损平滑重启了。如果旧进程不停止accept,那么内核会源源不断把部分请求分配给旧套接字,这样旧进程也就永远无法退出,也就不能实现服务的更新了。

reuseport不能实现平滑重启,但是能提升建连效率。reuseport和“fork共享套接字”是互补的关系。nginx在1.9.1版本后也添加了reuseport支持,实现上是直接在master里监听worker数量对应的reuseport套接字,再让每个worker进程继承从中继承一个套接字。这样就完美而高效的结合2者的优点。实现细节请参考nginx源码分析—reuseport的使用

3. 做一个工程友好的平滑重启库

基本的单地址的平滑重启可能不能满足我们的需求,因为随着业务的演进和更新:

  1. 同一个服务往往会增监听地址来提供新的能力,或是多监听几个端口提升处理能力;
  2. 也可能会有旧的能力因为各种原因需要下掉旧的监听地址;
  3. 服务在发布更新时也可能面临新服务起不来的问题,这时需要终止平滑重启流程,让老进程继续服务;
  4. 对于长连接类的应用,可能用户不会主动退出,需要旧服务进程显示的设置一个旧链接存活时间主动关闭链接退出旧服务;
  5. 平滑重启异常支持输出日志,或执行指定的回调上报异常;
  6. 支持配置指定的信号触发平滑重启; ...

因此,实现一个工程友好的平滑重启库,将上述种种工程上的考量纳入库的设计时很有必要的,实现中也是需要纳入考量的,有必要可以封装一个公共的库来给团队使用。

4. 总结

TCP后台服务难免需要升级更新,需要具备平滑重启能力,才能让服务升级对用户无感知。本文简析了平滑重启的原理及相关实现要点,澄清了reuseport实现平滑重启的误区,并结合工程上的考量实现一个通用的平滑重启库,以期为读者了解、实现健壮的平滑重启做一点点微薄的贡献。

参考文献:

  1. https://goteleport.com/blog/golang-ssh-bastion-graceful-restarts/
  2. https://swsmile.info/post/golang-graceful-restart-process/
  3. http://nginx.org/en/docs/control.html
  4. Why does one NGINX worker take all the load?
  5. nginx源码分析—reuseport的使用

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
Go 如何实现热重启
作者:zhijiezhang,腾讯 PCG 后台开发工程师 最近在优化公司框架 trpc 时发现了一个热重启相关的问题,优化之余也总结沉淀下,对 go 如何实现热重启这方面的内容做一个简单的梳理。 1.什么是热重启? 热重启(Hot Restart),是一项保证服务可用性的手段。它允许服务重启期间,不中断已经建立的连接,老服务进程不再接受新连接请求,新连接请求将在新服务进程中受理。对于原服务进程中已经建立的连接,也可以将其设为读关闭,等待平滑处理完连接上的请求及连接空闲后再行退出。通过这种方式,可以保
腾讯技术工程官方号
2020/09/10
2.6K0
在Go程序中实现服务器重启的方法
Go被设计为一种后台语言,它通常也被用于后端程序中。服务端程序是GO语言最常见的软件产品。在这我要解决的问题是:如何干净利落地升级正在运行的服务端程序。 目标: 不关闭现有连接:例如我们不希望关掉已部署的运行中的程序。但又想不受限制地随时升级服务。 socket连接要随时响应用户请求:任何时刻socket的关闭可能使用户返回'连接被拒绝'的消息,而这是不可取的。 新的进程要能够启动并替换掉旧的。 原理 在基于Unix的操作系统中,signal(信号)是与长时间运行的进程交互的常用方法
李海彬
2018/03/23
1.6K0
Golang的优雅重启
如果您有Golang HTTP服务,可能需要重新启动它以升级二进制文件或更改某些配置。如果你(像我一样)因为网络服务器处理它而优雅地重新启动是理所当然的,你可能会发现这个配方非常方便,因为使用Golang你需要自己动手。
sunsky
2020/08/20
9360
Golang中的热重启
这几天在写组里的一个http框架,于是研究了下,在golang中如何实现服务的热重启,从而实现整个服务的重启可以实现对请求客户端的透明。
netkiddy
2019/01/28
4.6K3
Golang中的热重启
Go语言优雅关闭与重启
后端服务程序在配置更新,程序修改后发布的过程中存在一些未处理完成的请求,和当前服务中为落地的资源(缓存、记录、日志等数据),为了减少这种情况带来的数据异常,需要有一种机制,在服务收到重启或者关闭信号的同时进行一些数据收尾处理。
码农小辉
2022/09/07
1.8K0
endless 如何实现不停机重启 Go 程序?
前几篇文章讲解了如何实现一个高效的 HTTP 服务,这次我们来看一下如何实现一个永不不停机的 Go 程序。
luozhiyun
2021/07/17
1.7K0
优雅的重启服务
每次更新完代码,更新完配置文件后 就直接这么 ctrl+c 真的没问题吗,ctrl+c到底做了些什么事情呢?
sunsky
2020/08/20
1.8K0
如何使用Go来实现优雅重启服务?
一般服务器重启可以直接通过 kill 命令杀死进程,然后重新启动一个新的进程即可。但这种方法比较粗暴,有可能导致某些正在处理中的客户端请求失败,如果请求正在写数据,那么还有可能导致数据丢失或者数据不一致等。
用户7686797
2020/08/25
3.4K0
【网络编程】三、TCP网络套接字编程
​ 可见这是比 udp 通信要复杂的,我们不仅仅要搞清楚如何进行通信,还要搞清楚通信的本质以及原理!这里我们先来解决如何搭建通信的环境!
利刃大大
2025/05/09
1010
【网络编程】三、TCP网络套接字编程
golang 服务平滑重启小结
在业务快速增长中,前期只是验证模式是否可行,初期忽略程序发布重启带来的暂短停机影响。当模式实验成熟之后会逐渐放量,此时我们的发布停机带来的影响就会大很多。我们整个服务都是基于云,请求流量从 四层->七层->机器。
王清培
2019/10/21
9560
golang 服务平滑重启小结
在业务快速增长中,前期只是验证模式是否可行,初期忽略程序发布重启带来的暂短停机影响。当模式实验成熟之后会逐渐放量,此时我们的发布停机带来的影响就会大很多。我们整个服务都是基于云,请求流量从 四层->七层->机器。
王清培
2019/10/19
3.7K0
golang 服务平滑重启小结
Golang服务器热重启、热升级、热更新(safe and graceful hot-restart/reload http server)详解
服务端代码经常需要升级,对于线上系统的升级常用的做法是,通过前端的负载均衡(如nginx)来保证升级时至少有一个服务可用,依次(灰度)升级。
sunsky
2020/08/20
8.6K0
服务优雅重启 facebook/grace 简介
服务优雅退出是指在服务关闭时,让服务有足够的时间来处理完已接收的请求,避免任何数据的丢失。在服务退出时,需要先停止接收新的请求,等待所有已经接收的请求处理完毕,然后再关闭服务。这样做可以确保服务在关闭时不会影响服务的稳定性和数据的完整性。服务优雅退出通常是在编写服务时需要考虑的一个重要问题。
JeffXue
2023/05/24
9970
通过Node.js的Cluster模块源码,深入PM2原理
众所周知,Node.js中的JavaScript代码执行在单线程中,非常脆弱,一旦出现了未捕获的异常,那么整个应用就会崩溃。
Peter谭金杰
2020/05/09
3K1
通过Node.js的Cluster模块源码,深入PM2原理
【译】使用 SO_REUSEPORT 套接字开发高并发服务
原文地址:https://blog.flipkart.tech/linux-tcp-so-reuseport-usage-and-implementation-6bfbf642885a
黑光技术
2023/02/23
8060
【译】使用 SO_REUSEPORT 套接字开发高并发服务
golang的httpserver优雅重启
去年在做golangserver的时候,内部比较头疼的就是在线服务发布的时候,大量用户的请求在发布时候会被重连,在那时候也想了n多的方法,最后还是落在一个github上的项目,facebook的一个golang项目grace,那时候简单研究测试了一下可以就直接在内部使用了起来,这段时间突然想起来,又想仔细研究一下这个项目了。 从原理上来说是这样一个过程:
黑光技术
2019/03/06
1.1K0
Socket编程实践(3) 多连接服务器实现与简单P2P聊天程序例程
SO_REUSEADDR选项 在上一篇文章的最后我们贴出了一个简单的C/S通信的例程。在该例程序中,使用"Ctrl+c"结束通信后,服务器是无法立即重启的,如果尝试重启服务器,将被告知: bind: Address already in use 原因在于服务器重新启动时需要绑定地址: bind (listenfd , (struct sockaddr*)&servaddr, sizeof(servaddr)); 而这个时候网络正处于TIME_WAIT的状态,只有在TIME_WAIT状态退出后,套接字被
Tencent JCoder
2018/07/02
6500
Go Web服务中如何优雅平滑重启?
文章链接:https://cloud.tencent.com/developer/article/2465636
南山竹
2024/11/15
1310
Go Web服务中如何优雅平滑重启?
多个套接字可以绑定同一个端口吗
在日常的开发过程中,经常会遇到端口占用冲突的问题。那是不是不同的进程不能同时监听同一个端口呢?这个小节就来介绍 SO_REUSEPORT 选项相关的内容。
挖坑的张师傅
2022/05/13
3K0
多个套接字可以绑定同一个端口吗
day01-从一个基础的socket服务说起
在linux中,一切都是文件,所有文件都有一个int类型的编号,称为文件描述符。服务端和客户端通信本质是在各自机器上创建一个文件,称为socket(套接字),然后对该socket文件进行读写。
会玩code
2022/04/24
1.3K2
day01-从一个基础的socket服务说起
相关推荐
Go 如何实现热重启
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档