每次当我们发布新版本的时候总是慌兮兮,一方面是担心有 bug,另一方面其实重启应用会带来一些抖动,可能有几秒钟或者几个请求的不正常,从而担心用户在这段时间内的操作。那么如何在应用重启的过程中尽可能的保证不会带来抖动,从而平滑又优雅的重启呢?

本文只针对于应用版本更新时,进行版本发布时进行的重启操作,从而导致的相关问题的解决。不涉及由于应用本身 panic 导致的重启,也不涉及蓝绿发布或回滚等操作。当前版本更新是在 k8s 中进行的重启操作,访问负载均衡交由 k8s 的 service 处理,再上层的网关层面的负载则不在本次讨论范围内。

通过本文你可以学到:

  • go 应用优雅退出所需要做的事情
  • go 应用优雅退出 k8s 所需要的配置
  • k8s 应用关闭时 pod 的生命周期

测试程序

先写一个最简单的测试程序(当然有很多压测工具都能满足需求,但是我还是想自己弄个最简单的,不想搞复杂),当然这个测试程序不能满足所有使用场景和情况,如并发的一些场景等,只是为了展现出固定的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
"fmt"
"sync"
"time"

"github.com/go-resty/resty/v2"
"go.uber.org/atomic"
)

func main() {
var success, failed atomic.Int64
for {
time.Sleep(500*time.Millisecond)
startTime := time.Now()
response, err := resty.New().R().Get("http://1.1.1.1:8080/health")
if err != nil {
failed.Inc()
} else {
if response.IsSuccess() {
success.Inc()
} else {
failed.Inc()
}
}
fmt.Printf("成功:%s,失败:%s, 耗时:%ds\n", success.String(), failed.String(), time.Now().Unix()-startTime.Unix())
}
}

简单的说就是每间隔一段时间请求一次,计数成功和失败的次数

问题 1:重启后初始化时间长

1
2
3
4
5
6
7
8
9
func main() {
// 模拟延迟初始化
time.Sleep(5 * time.Second)
s := gin.New()
s.GET("/health", func(ctx *gin.Context) {
ctx.String(200, "OK")
})
s.Run(":8080")
}
1
2
3
4
5
6
7
8
9
10
11
12
成功:34,失败:0, 耗时:0s
成功:35,失败:0, 耗时:0s
成功:35,失败:1, 耗时:0s
成功:35,失败:2, 耗时:0s
成功:35,失败:3, 耗时:0s
成功:35,失败:4, 耗时:0s
成功:35,失败:5, 耗时:0s
成功:35,失败:6, 耗时:0s
成功:35,失败:7, 耗时:0s
成功:35,失败:8, 耗时:0s
成功:36,失败:8, 耗时:0s
成功:37,失败:8, 耗时:0s

很明显中间初始化的时间用户没有办法正常访问接口

解决方式

添加健康检查接口,添加 readinessProbe 配置

1
2
3
4
5
6
7
8
9
10
func main() {
// 模拟延迟初始化
time.Sleep(5 * time.Second)
s := gin.New()
s.GET("/healthz", func(ctx *gin.Context) {ctx.String(200, "OK")})
s.GET("/health", func(ctx *gin.Context) {
ctx.String(200, "OK")
})
s.Run(":8080")
}
1
2
3
4
5
6
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 3
periodSeconds: 3
1
2
3
4
成功:34,失败:0, 耗时:0s
成功:35,失败:0, 耗时:0s
成功:36,失败:0, 耗时:0s
成功:37,失败:0, 耗时:0s

可以看到重启过程中已经完成正常

当然 readinessProbe 只会将你的应用从你的 service 里面摘除,但是不会重启,如果需要监控应用不能正常服务就进行重启的话需要配置 livenessProbe 具体可以参考 https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-a-liveness-http-request

问题 2:正在运行中的请求

在我们之前改动完成的基础之上,我们来看看当我们的单个请求时间较长的时候会发生什么

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
// 模拟延迟初始化
time.Sleep(10 * time.Second)
s := gin.New()
s.GET("/healthz", func(ctx *gin.Context) {ctx.String(200, "OK")})
s.GET("/health", func(ctx *gin.Context) {
// 模拟请求时间较长
time.Sleep(10*time.Second)
ctx.String(200, "OK")
})
s.Run(":8080")
}
1
2
3
4
成功:1,失败:0, 耗时:10s
成功:2,失败:0, 耗时:10s
成功:2,失败:1, 耗时:6s
成功:3,失败:1, 耗时:10s

可以看到,当我们请求时间较长的时候,就会出现,在重启的过程中请求失败的情况。

这里我只是放大了问题,毕竟正常的请求时间很短,但是也就意味着我们的请求还是有可能在过程中被打断,从而导致请求失败,从而导致抖动。

解决方式

所以我们的目标很明确,就是当我们重启的时候需要保证当前请求一定已经处理完成,如果还没有处理完成,需要等待请求处理完成之后再进行关闭,所以我们需要修改代码,捕获最终应用停止的对应信号,并在关闭时对请求做相关处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func main() {
// 模拟延迟初始化
time.Sleep(10 * time.Second)
s := gin.New()
s.GET("/healthz", func(ctx *gin.Context) {ctx.String(200, "OK")})
s.GET("/health", func(ctx *gin.Context) {
// 模拟请求时间较长
time.Sleep(10*time.Second)
ctx.String(200, "OK")
})

srv := &http.Server{
Addr: ":8080",
Handler: s,
}
go func() {
if err := srv.ListenAndServe(); err != nil && errors.Is(err, http.ErrServerClosed) {
log.Printf("listen: %s\n", err)
}
}()

quit := make(chan os.Signal)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")

// 此处设定为 15s 原因是当前模拟请求时间延迟设定时间较长,具体时间根据实际业务场景设定
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("Server exiting")
}

然后进行测试

1
2
3
4
5
成功:1,失败:0, 耗时:10s
成功:2,失败:0, 耗时:10s
成功:3,失败:0, 耗时:10s
成功:4,失败:0, 耗时:10s
成功:5,失败:0, 耗时:10s

可以看到,即使在重启过程中,也能保证请求不断,正常关闭。其实主要的功劳是在 golang 的 http.Server 提供了 Shutdown 方法,能保证当前正在处理的请求能正常的处理完成,并正常的关闭。

问题 3:过长的关闭时间

场景 1:在我们的系统中,有时会跑着一些定时的任务,当这些定时任务在运行的过程中如果遇到应用需要重启的情况,如果之间重启,那么势必会遇到任务运行到一半,下次重启之后可能会导致任务重复执行或其他异常情况

场景 2:有些时候我们可能需要在关闭应用的时候做大量的持久化工作来保存当前缓存的相关数据,这些数据我们不希望在重启的过程中导致丢失

类似这样的场景总结就是在停止服务的时候会占用很多时间,并不是直接就能关闭的,故针对这样的场景我们来看看如何解决。

解决方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func main() {
// 模拟延迟初始化
time.Sleep(10 * time.Second)
s := gin.New()
s.GET("/healthz", func(ctx *gin.Context) { ctx.String(200, "OK") })
s.GET("/health", func(ctx *gin.Context) {
// 模拟请求时间较长
time.Sleep(10 * time.Second)
ctx.String(200, "OK")
})

srv := &http.Server{
Addr: ":8080",
Handler: s,
}
go func() {
if err := srv.ListenAndServe(); err != nil && errors.Is(err, http.ErrServerClosed) {
log.Printf("listen: %s\n", err)
}
}()

quit := make(chan os.Signal)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")

ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}

// 模拟用户需要在应用关闭时,持久化大量数据
time.Sleep(45 * time.Second)

log.Println("Server exiting")
}

如果和之前一样,正常情况下,所有代码执行完成后会打印 Server exiting 日志,而当我们需要在关闭之前做大量操作的时候,那么就会发现最后一条日志无法正常打印出来。

导致这个问题的原因很简单:当 SIGTERM 发送给 pod 之后,会等待一个时间,如果再这个时间内,应用还是没有正常结束则会发送 SIGKILL 信号并强制删除,故程序就被 kill 了。

而我们只需要修改这个等待时间即可。

1
2
spec:
terminationGracePeriodSeconds: 60

配置完成之后,重启就会发现,Server exiting 能正常被打印出来了

当然其实这个值需要根据具体的业务场景进行配置,默认为 30s,其实有时已经绰绰有余了

小结一下

优雅关闭所需要做的配置

  • 应用添加健康检查接口,并在可以正常提供服务之后才表示自己健康,并配置 readinessProbe
  • 捕获 SIGTERM 信号并在捕获之后做关闭后的相关处理,如保证请求正常结束,数据库连接正常断开,文件写入完毕等
  • 在特殊情况时需要配置 terminationGracePeriodSeconds 以保证最终正常处理完成后再关闭

pod 关闭时的生命周期

经过我们几次的优化之后,基本已经满足了我们现阶段的需求,根据这次改动,我们来回顾一下在 k8s 重启过程中 pod 的生命周期是怎么样的。

  1. K8S 启动新的 pod :这个部分包括拉取对应新的镜像,启动
  2. K8S等待新 pod 进入 Ready(Running) 状态:这时就用到了我们的 readinessProbe,K8S 会等我们的应用真正能对外正常提供服务后才进行下一步
  3. K8S 创建 Endpoint:这时才会将新服务纳入 service,也就是新服务开始接收请求了
  4. pod 设置为 Terminating 状态,并从所有服务的 Endpoints 列表中删除:此时流量就不会打到老的 pod 上了,但此时容器还是正常运行的,并且正在处理当前的请求
  5. preStop Hook被执行:本文中还未提到这个 preStop 的钩子,它是一个发送到 pod 中容器的特殊命令或请求,当应用程序无法通过接收 SIGTERM 进行关闭时,也可以通过 preStop Hook 来触发正常关闭,当前暂时未用到
  6. SIGTERM 信号被发送到 pod :这个时候就是我们的应用会受到 SIGTERM 信号,我们可以根据这个信号去处理我们的优雅停止
  7. Kubernetes 等待优雅的终止:等待 terminationGracePeriodSeconds 时间
  8. SIGKILL 信号被发送到 pod,并删除 pod:当等待 terminationGracePeriodSeconds 时间之后,如果应用还是没有正常关闭则会发送 SIGKILL 信号到 pod 去强制让应用关闭

总结

至此对于我们应用本身的优雅关闭已经暂告一个段落了,基本能满足大多数场景的需求。

在大多数小的实际业务中,可能优雅关闭不会对你的应用造成多大影响,可能只是小小的一两次抖动就过去了,但其实优雅关闭能保证你的应用时刻保持一个健康的状态去面向用户,也是完成 SLA 的关键。不过有时应用的重启如果没有正确处理,会带来一些意想不到的问题,这是需要根据具体场景来看了。

当然优雅关闭还有更多可以优化的点,如:当应用出现 panic 时怎么办,上层网关层面的重启如何优雅等等,剩下的就需要你再工作中吸取经验了。

参考链接

https://cloud.google.com/blog/products/containers-kubernetes/kubernetes-best-practices-terminating-with-grace