业务容器化最佳实践–优雅退出

kubernetes的重要性已经不言而喻了,目前不少的企业已经选择将自己的业务运行在kubernetess上。我本人在多家企业落地kubernetes,也和很多企业负责人聊过类似话题。我们发现很多人把业务容器化简单理解为dockerfile + 原有代码。这是不对的,新的运行形态就决定了我们的代码必须做相应的适配,才可以充分发挥kubernetes的强大。本系列文章主要讲述业务容器化最佳实践,希望对大家有所帮助。

今天我们主要阐述 应用优雅退出

不要把代码架构的失败归结于Kubernetes

开源的世界里,大家已经习惯了引入各种框架来解决自己的问题,但是我们都知道技术的世界是没有银弹的。

我在早期参加一些meetup的时候,很多来参会的人会提出一个问题:如何在pod缩容的时候,避免5xx?进而怀疑kubernetes可用性。

这个问题其实比较好理解。业务pod通过service或是ingress的方式,暴露出去。大多数场景都是pod作为负载均衡器的upstream存在,但是任何的负载均衡器不能实时探测你的健康状态,当你的pod退出的时候,中间有一定的时间差,真实流量被分发到了一个退出的pod当中,自然就出现了5xx。

业务运行在pod形相对于普通的虚拟机,具有动态的特性,比如扩缩容,或是被驱赶等等。这就要求我们的业务要做对应的适配。

如何优雅退出

在kubernetes中,正确的平滑退出的顺序应该如下:

  • 监听并捕获SIGTERM信号
  • 停止接受新的连接
  • 完成已经存在的活跃请求
  • 关闭keepalive
  • 退出

我们知道当pod被delete的时候,SIGTERM信号被发送到每个容器中的主进程(PID 1),默认开始20s的倒计时(该值可以设置),20s后,SIGKILL信号发送,程序被kill。

首先可以查看我们的应用是否是1号主进程,如果不是,那么我们需要让1号主进程去通知业务进程,即1号进程的子进程。此类问题,1号进程很多可能是一个 run.sh 启动脚本。

例如golang项目:

sigTerm := make(chan os.Signal, 1)

signal.Notify(sigTerm, syscall.SIGTERM)

...

go server.ListenAndServe()

<-sigTerm

log.Println("got SIGTERM, prepare to shut down in 20s")

对于 停止接受新的请求连接 ,我们知道在k8s中存在LivenessProbe 和 ReadinessProbe两种healthcheck探针。

  • LivenessProbe :用于判断容器是否存活,如果 LivenessProbe 探针探测到容器不健康,则 kubelet 将杀掉容器,并根据容器的启动策略做相应的处理,如果一个容器不包括 LivenessProbe 探针,kubelet 认为该容器的 LivenessProbe 探针返回的值永远是 Success。
  • ReadinessProbe:用于判断容器是否启动完成(ready 状态),可以接受请求,如果 ReadinessProbe 探针检测到失败,则 Pod 的状态则被更改。

这里我们应该使用的是ReadinessProbe。ReadinessProbe 探测影响的是该pod是否是一个准备就绪的pod,只有检测通过的pod才会加到lb的upstream中。

所以我们需要保证:

  • 我们的应用提供并设置了ReadinessProbe。
  • 我们的ReadinessProbe是退出有关系的。

两个保证是有一定的递进关系的,详细解释一下。

如果我们设置了如下的探针:

func healthHandler(w http.ResponseWriter, r *http.Request) {
    typ := r.URL.Query().Get("type")
    log.Printf("healthy - %s", typ)
    w.WriteHeader(200)
    w.Write([]byte("ok"))
}

该探针只是可以保证,pod在启动的过程中,pod处于就绪状态,流量才会打过来。在pod彻底被kill之前,该探针一直返回就绪状态,而我们要实现在停机的过程中,停止接受新的请求连接。所以我们的探针必须是和停止有关,正确的实现方式应该如下:

func healthHandler(w http.ResponseWriter, r *http.Request) {
    typ := r.URL.Query().Get("type")
    if !healthy {
        log.Printf("not healthy - %s", typ)
        w.WriteHeader(503)
        w.Write([]byte("not ready"))
    } else {
        log.Printf("healthy - %s", typ)
        w.WriteHeader(200)
        w.Write([]byte("ok"))
    }
}

设置一个环境变量healthy,初始化值为true。

当接收到系统发送的SIGTERM, 业务pod开始了停机过程,先将healthy更改为false。进而探针探测失败,新的流量不再打到处于停机过程中的pod。

对于完成已经存在的活跃请求

此处要求不要急于退出,sleep一定时间,保证已经活跃的请求完成。

代码如下:

time.Sleep(sigtermTimeout)

假如存在一些长连接服务, 关闭keepalive 也是非常重要的。

代码实现如下:

if !keepAlive {
        log.Println("SetKeepAlivesEnabled=false")
        server.SetKeepAlivesEnabled(false)
    }

总结

该文主要讲了业务如何在代码上做一些措施,实现pod的优雅退出。接下来,我们会继续实践系列,计划从资源分配等角度展开。