蛮荆

Kubernetes 应用最佳实践 - 优雅关闭长连接

2023-10-20

概述

Kubernetes 中的网络连接由节点上面的 Service 代理进行管理,Service 会将流量转发到对应的 Pod 中,相比于传统网络编程中使用 IP 地址连接到单个服务器的方式, Kubernetes 可以提供更高的灵活性和扩展性。

Pod 是 Kubernetes 中应用运行的最小单位,虽然单个 Pod 拥有自己的 IP 和主机名称,但是 Pod 可能会因为伸缩、驱逐、容器内进程异常等情况被终止并重启, 重启后的 Pod 和之前之前的 Pod 拥有不同的 IP 地址,这样一来,也就产生了下面的问题:

网络编程长连接场景中,Pod 被终止后,容器内应用服务管理的所有连接都会被释放,如何在 Pod 终止前通知到所有长连接的客户端呢?

针对这个问题,Kubernetes 提供了成熟的 “优雅关闭应用” 方案。

优雅关闭

在 Kubernetes 中要优雅地关闭长连接,需要保证执行以下步骤:

  1. 确保应用程序可以快速启动和关闭,并且可以处理 SIGTERM 信号 (这个是前提)
  2. Pod 被终止时, kubelet 会向 API Server 发送 DELETE 请求
  3. API Server 处理完 DELETE 请求会通知监听对象 kubelet 和 EndPoint 控制器
  4. kubelet 执行 Pre-Stop 钩子函数并且向容器内应用程序发送 SIGTERM 信号
  5. Endpoints 控制器删除 Pod 对应的 EndPoint 对象, 然后发送事件到 API Server
  6. API Server 通知所有关注这个被删除的 Endpoint 对象的观察者,其中也包括了被删除 Pod 所在节点上面的 kube-proxy
  7. kube-proxy 更新转发规则,更新完成后就会停止向被终止的 Pod 转发流量
  8. 应用程序在接收到 SIGTERM 信号时,应该继续处理新的连接请求,同时开始进行收尾工作 (通知客户端并关闭连接,例如向所有客户端发送广播消息后关闭所有连接)
  9. 客户端在连接断开后,尝试发起新的连接,然后新的连接会被转发到新的 Pod 中

Pod 关闭后转发规则变化

如图所示,当 Pod-2 终止后,新的流量会被转发到 Pod-3。

潜在问题

kubelet 和 Endpoints 控制器接收到通知消息后,会进入并行执行阶段,因此可能出现的情况是: 关闭 Pod 应用进程的完成时间要早于 kube-proxy 更新转发规则的完成时间,此时 Pod 已经终止,但是转发规则还没有更新,又正好新的请求连接过来了, 于是 kube-proxy 将新的请求转发到了已终止的 Pod, 抛出客户端连接错误。

上面这个问题的解决方案,我们将在下文中的 “细节问题” 小节详细描述,接下来先来看看信号捕获和处理的问题。


信号捕获方法

主流编程语言标准库都提供了信号捕获和处理方法,直接使用即可,例如在 Go 语言中可以这样捕获 SIGTERM 信号 (伪代码):

func main() {
	// 创建一个 channel 接收信号
	signalChan := make(chan os.Signal, 1)

	// 捕获 SIGTERM 信号
	signal.Notify(signalChan, syscall.SIGTERM)

	// 无限循环
	for {
		// 收到 SIGTERM 信号之后退出程序
		<-signalChan
		
		fmt.Println("收到 SIGTERM 信号")
		return
	}
}

信号处理

在下面的代码中,我们创建了一个 TCP 服务并监听请求连接 (伪代码,不考虑并发读写等问题),并在接收到 SIGTERM 信号之后进行以下工作:

  1. goroutine 收到信号之后打印一条日志
  2. 遍历所有已建立的连接,逐个关闭
  3. 关闭 TCP 服务监听,不再接收新的连接
package main

// 全局连接池
var connections = make(map[net.Conn]struct{})

func main() {
    // 创建 TCP 服务
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
		log.Fatal(err)
        os.Exit(1)
    }
    defer listener.Close()

	// 创建一个 channel 接收信号
	signalChan := make(chan os.Signal, 1)

	// 捕获 SIGTERM 信号
	signal.Notify(signalChan, syscall.SIGTERM)

    // 启动一个 goroutine 用来监听信号
    go func() {
        <-signalChan
		fmt.Println("收到 SIGTERM 信号,开始关闭连接")

        // 遍历所有已建立的连接,依次关闭
		// 注意: 这里可能存在并发读写延迟,更好的实现方式为 channel
        for conn := range connections {
            conn.Close()
        }

		// 关闭 TCP 服务器,停止接受新连接
		listener.Close()

        // 退出程序
        os.Exit(0)
    }()

    // 无限循环,接受客户端新连接
    for {
        conn, err := listener.Accept()
        if err != nil {
			log.Fatal(err)
            continue
        }

        // 将新连接添加到连接池
		// 注意: 这里可能存在并发读写延迟,更好的实现方式为 channel
        connections[conn] = struct{}{}

        // 在新协程中处理客户端请求
        go handleConnection(conn)
    }
}

// 处理客户端请求
func handleConnection(conn net.Conn) {
    // ...
}

细节问题

为什么收到 SIGTERM 信号之后,对于新到的连接请求,应用程序还要继续处理,而不是直接拒绝呢?

因为 Kubernetes 处理 kube-proxy 的转发规则和负载均衡需要时间, 如果应用程序直接拒绝新的请求,会导致客户端错误。Pod 在删除期间虽然处于 Terminating 状态,但是依然可以正常接收和处理请求。

最佳实践是: 在收到 SIGTERM 信号之后,使 就绪探针 停止检查 (探针直接返回失败即可),这样 Pod 将不再接收任何流量,应用程序不需要再处理新的请求连接。

Pre-Stop 钩子函数

除了使用应用程序来处理 Pod 的终止到重启之间的过渡阶段,还可以使用 Pre-Stop 钩子函数来处理,常见的策略是在钩子函数内休眠一段时间, 这样可以为 Pod 的删除过程提供额外的缓冲时间。

Pre-Stop 钩子函数执行完成后向容器应用进程发送 SIGTERM 信号,默认情况下,Pod 删除的超时 (terminationGracePeriodSeconds) 时间为 30 秒, 超时后 Pod 会被 Kill, 这个时间指的就是 Pre-Stop 钩子函数的执行时间加应用程序处理 SIGTERM 信号的时间

例如 terminationGracePeriodSeconds 的值为 30 秒, Pre-Stop 钩子函数执行了 25 秒,应用程序处理 SIGTERM 信号需要 10 秒,那么应用程序等不到执行完成, 就会被 kill, 因为 25 + 10 > 30。

下面是是一个 Nginx Pod 使用 Pre-Stop 钩子函数的示例:

# 指定超时为 60 秒
terminationGracePeriodSeconds: 60
containers:
- image: nginx
  lifecycle:
    preStop:
      exec:
        command: [
          "sh", "-c",
          # 休眠 10 秒,然后优雅关闭 Nginx
          "sleep 10 && /usr/sbin/nginx -s quit",
        ]

注意: Kubernetes 集群自动伸缩的执行超时为 10 分钟,所以 terminationGracePeriodSeconds 参数的值应该小于 10 分钟,默认情况下的 30 秒其实是良好的实践。


小结

Pod 的删除首先是 逻辑删除过程,具体来说就是 etcd 中删除了对应的 Pod 记录,其次是 物理删除过程,也就是节点的 Pod 在执行完 Pre-Stop 钩子函数和 SIGTERM 信号捕获之后, 关闭 Pod 内的所有容器,最后 Pod 被节点删除。所有想实现 “优雅关闭、升级” 的应用程序,都要围绕着这两个过程展开具体的业务逻辑,最终实现服务端的高可用加客户端的优秀体验。

扩展阅读

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。