gRPC的平滑关闭和在Kubernetes上的服务摘流方案总结

平滑关闭服务摘流是保证部署了多节点的应用可以持续稳定对外提供服务的两个重要手段,平滑关闭保证了应用节点在关闭以前处理完已接收到的请求,之前在文章「学习用Go编写HTTP服务」里给你们介绍过怎么用net/http库提供的 http.ShutDown平滑关停HTTP 服务,今天再给你们介绍一下gRPC分布式服务的平滑关停方法。应用在进入平滑关闭阶段后拒绝为新进来的流量提供服务,若是此时继续有新流量访问而来,势必会让发送请求的客户端感知到服务的断开,因此在平滑关闭应用前咱们还要对应用节点作摘流操做,保证网关不会再把新流量分发到要关闭的应用节点上才行。编程

若是服务部署在云主机上,摘流只须要运维人员从负载均衡上把机器节点的IP拿掉,待应用重启或者更新完毕后再将机器节点的IP挂回负载均衡上便可。可是人工摘流这对 Kubernetes 这样的进行多节点集群资源调度的系统显然是不可能的,因此咱们在文章里还会介绍如何让 Kubernetes 系统自动为咱们即将关停的应用节点完成摘流操做。bash

平滑关闭

在这个章节里除了介绍 gRPC框架平滑关闭应用的方法外还会介绍一下Kubernetes集群里完成Pod删除的整个生命周期,由于若是咱们的gRPC服务部署在Kubernetes集群里的话,服务的平滑关闭和摘流都会依赖这个Pod 删除的生命周期,或者叫Pod关闭序列来实现。若是在下面内容里看到我一下子说 「Pod 的关闭序列」,一下子说「 Pod 删除的生命周期」请必定要记住他们是一个东西的两种表述方式而已。markdown

gRPC的gracefulStop

gRPC 框架使用的通讯协议是HTTP2HTTP2对于链接关闭使用 goaway 帧信号(类型是0x7,用于启动链接关闭或发出严重错误状态信号)。goaway 容许服务端点正常中止接受新的流量,同时仍然完成对先前已创建的流的处理。app

Go 语言版本的 gRPC Server 提供了两个退出方法StopGracefulStop,光看名字就知道后面这个是实现平滑关闭用的。GracefulStop 方法里首先会关闭服务监听,这样就没法再创建新的请求,而后会遍历全部的当前链接发送goaway帧信号。serveWG.Wait()会等待全部handleRawConn协程的退出(在gRPC Server里每一个新链接都会建立一个handleRawConn协程,而且增长WaitGroup的计数器的计数)。负载均衡

func (s *Server) GracefulStop() {
    s.mu.Lock()
    ...

    // 关闭监听,再也不接收新的链接请求
    for lis := range s.lis {
        lis.Close()
    }

    s.lis = nil
    if !s.drain {
        for st := range s.conns {
            // 给全部的链接发布goaway信号
            st.Drain()  
        }
        s.drain = true
    }


    // 等待全部handleRawConn协程退出,每一个请求都是一个goroutine,经过WaitGroup控制.
    s.serveWG.Wait()

    // 当还有空闲链接时,须要等待。在退出serveStreams逻辑时,会进行Broadcast唤醒。只要有一个客户端退出就会触发removeConn继而进行唤醒。
    for len(s.conns) != 0 {
        s.cv.Wait()
    }
...
复制代码

Stop 方法相对于 GracefulStop 来讲少了给链接发送 goaway 帧信号和等待链接退出的逻辑,这里就再也不作过多介绍了。框架

应用监听OS信号,启动平滑关闭

知道 gRPC框架提供的服务平滑关闭的方法后,与HTTP服务的平滑关闭同样,咱们的应用要能接收到OS发来的TERMInterrupt之类的信号,而后主动去触发调用GracefulStop进行服务的平滑关闭,固然调用平滑关闭前咱们还能够作一些其余应用内的首尾工做,好比应用使用Etcd实现的服务注册,那么这里我建议要先去主动的把节点的IP对应的Key从Etcd上注销掉,若是Key不能及时过时,那么客户端作负载均衡时没有收到这个节点IP删除的通知就仍有可能会往要关闭的端点上发请求。运维

下面是gRPC服务启动后监听 OS 发来的断开信号时开始平滑关闭的方法,演示的代码只是一些伪代码,不过真实度已经很高了,实际应用时能够直接往这个代码模板里套用本身的方法。分布式

errChan := make(chan error)

stopChan := make(chan os.Signal)

signal.Notify(stopChan, syscall.SIGTERM, syscall.SIGINT, syscall.SIGKILL)

go func() {
     if err := grpcServer.Serve(lis); err != nil {
        errChan <- err
     }
}()

select {
case err := <- errChan:
   panic(err)
case <-stopChan:
   // TODO 作一些项目本身的清理工做
   DoSomeCleanJob()
   // 平滑关闭服务
   grpcServer.GracefulStop()
}
复制代码

Kubernetes Pod关闭经历生命周期

Kubernetes 在应用须要更新、节点须要升级维护、节点资源耗尽的时候都会删除Pod再从新建立和调度Pod,Pod 在Kubernetes集群中被删除前会经历如下生命周期:ide

  1. Pod 状态被标记为Terminating, 此时 Pod 开始中止接收新流量。
  2. Pod 的 preStop 钩子会被执行,在钩子里咱们能够设置要执行的命令或者要发送的HTTP请求,大部分应用能够处理OS发来的TERM中断信号,可是若是应用依赖了不受自主控制的外部系统,能够经过钩子里发送请求完成注销之类的动做,后面介绍的服务摘流也会用到 preStop钩子。
  3. Kubernetes向Pod 发送 SIGTERM 信号。
  4. Kubernetes 会默认等待30秒让Pod完成关闭,若是须要等待超过30秒应用才能正常退出,可使用terminationGracePeriodSeconds 在Deployment里本身配置平滑关闭Pod Kubernetes要等待的时间。须要注意的是上面说的 preStop 钩子的执行和 SIGTERM 信号的发送都包含在这个时间里,若是应用早于这个时间关闭会当即进入生命周期的下个阶段
  5. Kubernetes向应用发送 SIGKILL 信号,而后删除Pod。

上面那个 gRPC 服务,部署在Kubernetes集群里后,假如遇到节点升级或者其余要关闭某个节点上Pod的状况,应用就能够收到Kubernetes 向Pod发送的TERM信号,主动完成平滑关闭服务的操做。oop

关于Pod关闭所经历的生命周期更详细的内容能够看一看我最近写的文章「如何优雅地关闭Kubernetes集群中的Pod

Kubernetes服务摘流

提及Kubernetes的服务摘流,咱们就不得再也不把Kubernetes里的Pod和Service这种资源的概念再简单捋一遍。咱们的应用服务运行在容器里,容器被 Kubernetes 封装在Pod里,Pod里能够有多个容器,但只能有一个运行主进程的主容器,其余容器都是辅助用的,即Pod 支持的(sidecar)边车模式。

Pod 自身每次重建IP都会变,且Pod自身的IP只能在节点内访问,因此 Kubernetes 就用了一种叫作的 Service 的控制器来管控一组Pod,为它们向外部提供统一的访问方式,Service 它经过selector 指定 Pod的标签来把符合条件的Pod 都加到它的服务端点列表里。

因此咱们应用启动后向注册中心注册的IP,不是应用所在的Pod的IP,而是上层 NodePort 类型的Service的IP,这个IP是VIP,访问它的时候会自动作负载均衡,随机把流量路由给Service后面挂的Pod。

若是你以前对Pod 和 Service的概念了解的较少,能够看下我以前的文章,就能理解上面说的这些东西了、

Kubernetes Pod入门指南

学练结合,快速掌握Kubernetes Service

Service 自己实际上是会为Pod作探活和摘流的,可是若是你的应用的访问量足够大,Service的摘流有时候并不及时,在Pod 关闭的时候仍是会有新流量进来。这就致使了在重启服务,或者是Kubernetes集群内部有一个节点升级、重启之类的动做,节点上的Pod被调度到其余节点上时,客户端仍是能感知到闪断。这实际上是一个很大的问题,由于 Kubernetes 集群内部作资源从新调度,切换新节点之类的动做仍是挺常见的。通过翻看Kubernetes相关的资料和一些社区里的讨论,咱们终于找到了Service摘流慢的缘由(其实没费多大劲,Github和Kubernetes In Action 那本书里都有说过这个问题)。

缘由是 Kubernetes 删除 Pod 前会向 Kubernetes 集群内广播 Pod 的删除事件,会同时有几个子系统接收广播处理事件,其中就包括:

  • 要删除的 Pod 所在节点上的Kubelet接收到事件后会开启上面介绍的Pod 关闭的生命周期,Pod拒绝伺服新流量等待生命周期内的动做执行完成后被删除。
  • Pod 的 Service 控制器收到事件后会把要关闭的 Pod 从服务端点列表里移除出去。

上面动做会同时并行发生,这就致使了有可能Pod已经进入关闭序列了,可是Service那里尚未作完摘流,Service仍是有可能会把新来的流量路由给要关闭的Pod上。

社区里和Kubernetes In Action 这本书里针对这个问题,都给出了一个相同的解决方案。利用 Pod 关闭生命周期里的preStop 钩子,让其执行 sleep 命令休眠5~10秒,经过延迟关闭Pod来让Service先完成摘流,preStop的执行并不影响Pod内应用继续处理现存请求

在 preStop 钩子里引入延迟的方法,能够参考下面的配置文件片断。

containers:
  - args:
  - /bin/bash
  - -c
  - /go-big-app
  ... 
  # 下面的preStop钩子里引入10秒延迟
  lifecycle:
    preStop:
      exec:
        command:
          - sh
          - -c
          - sleep 10
复制代码

这样就让并行执行的摘流和平滑关闭动做在时间线上尽可能错开了,也就不会出现Service摘流可能会有延迟的问题了。

关于这个问题详细的描述和解决方案能够参考我前面翻译的文章「借助 Pod 删除事件的传播实现 Pod 摘流」,里面有详细的图文解释来讲明这个问题的由来和解决办法。

总结

文章里讲的这些内容算是咱们研发团队以前作服务高可用保证时总结出来的一些经验吧,里面介绍的知识点,单看每一个在它本身的领域其实都不是什么难掌握的东西,用Go开发的基本上都会用signal.Notify接收OS信号完成应用的平滑关闭,作 Kubernetes 运维的对后面那些概念和解决问题的方法应该也都是轻车熟路,可是做为研发若是咱们能"跨界"多学一些像Kubernetes这样的在生态里和程序开发紧密结合的知识,靠研发主动推进运维配合咱们解决这些问题,所得到的经验和成就感仍是挺不同。

闲话也很少说了,对Go编程实践和进阶还有Kubernetes感兴趣的同窗能够访问个人公众号『网管叨bi叨』进入公众号在菜单栏里便可访问这些专题的原创文章。但愿这些通俗易懂的文章能帮助到一块儿努力的同路人。

图片

图片

图片

相关文章
相关标签/搜索