高性能服务之优雅终止

「优雅终止」指的是当服务需要下线或者重启时,通过一些措施和手段,一方面能够让其他服务尽快的感知到当前服务的下线,另一方面也尽量减小对当前正在处理请求的影响。优雅终止可提升服务的高可用,减少下线造成的服务抖动,提升服务稳定性和用户体验。

下线服务不仅仅是运维层面的工作,需要整个 RPC 实现、服务架构以及运维体系的配合,才能完美的实现服务的优雅下线。本文将基于服务下线的整个流程,分析如何实现微服务的优雅终止。主要包含以下方面:

  • 服务注册中心的主动下线
  • 基于 gRPC-Go 的源码,分析 gRPC 如何实现优雅终止
  • 探讨 k8s 的优雅终止

服务注册中心的主动下线

如果服务使用了服务注册中心(例如 Consul、etcd 等),那第一步就是首先将服务从注册中心下线。这样可以尽快保证新的请求不会打到这台节点上。

虽然绝大部分的服务注册中心都有节点的心跳和超时自动清理的机制,但是心跳也是有固定间隔的,注册中心需要等到预设的心跳超时后才能发现节点的下线。因此,主动下线可以极大缩短这个异常发现的过程。

如果服务是基于 k8s 进行管理和调度,那这件事情就做起来非常方便了。

首先,k8s 本身自带了一个可靠的 服务发现,在 k8s 上进行 pod 的上下线,k8s 自然都会第一时间感知到。

如果使用的是外置的名字服务,则可以使用 k8s 的 preStop 功能。k8s 原生支持了 容器生命周期回调, 我们可以定义 pod 的 preStop 钩子,来实现服务下线前的清理操作。如下:

例如:

containers:
- name: my-app-container
  image: my-app-image
  lifecycle:
    preStop:
      exec:
        command: ["/bin/sh","-c","/app/pre_stop.sh"]

pod在下线之前,首先会执行 /app/pre_stop.sh 命令,在这个命令中,我们可以做很多预清理策略。

RPC 的优雅终止

将服务节点从名字服务中摘除,可以阻挡新流量进入到该节点,这是优雅终止的第一步。但是,对于该节点上已建立的客户端连接,如果贸然下线,将会造成正在的业务逻辑的突然中止。因此,我们需要实现RPC级别的,对连接和请求处理进行优雅终止,以保证业务逻辑尽量少的受到影响。

以 gRPC-Go 为例,gRPC 实现了两个停止接口 GracefulStopStop,分别代表服务的优雅终止和非优雅终止。我们来看下 gRPC 是如何优雅终止的。

func (s *Server) GracefulStop() {
	s.quit.Fire()
	defer s.done.Fire()

  ...
	s.mu.Lock()

	// 首先关闭监听 socket,保证不会有新的连接到来
	for lis := range s.lis {
		lis.Close()
	}

	s.lis = nil
	if !s.drain {
		for st := range s.conns {
			st.Drain()
		}
		s.drain = true
	}

	// Wait for serving threads to be ready to exit.  Only then can we be sure no
	// new conns will be created.
	s.mu.Unlock()
	s.serveWG.Wait()
	s.mu.Lock()

	for len(s.conns) != 0 {
		s.cv.Wait()
	}
	...
	s.mu.Unlock()
}
  • 第一步: 调用 s.quit.Fire()。当该语句执行后,gRPC对于所有新 Accept 到来的连接,都会直接丢弃。
  • 第二步: 逐个调用 lis.Close()。关闭监听 Socket,这样将不会再有新连接到达。
  • 第三步: 对已建立的连接,逐个调用 st.Drain()。由于 gRPC 是基于 HTTP2 实现,因此这里将会应用到 HTTP2 的 goAway帧。
    goAway 帧相当于服务器端主动给客户端发送的连接关闭的信号,客户端收到这个信号后,将会关闭该连接上所有的 HTTP2 的流。这样客户端侧可以主动感知到连接关闭,同时不会继续发送新的请求过来。
  • 第四步: 调用 s.serveWG.Wait()。保证 gRPC 的 Serve 函数已正常退出。
  • 第五步: 调用 s.cv.Wait()。这个逻辑用于等待所有已建立连接的业务处理逻辑的正常结束。这样就不会因为服务的突然关闭,造成业务逻辑的异常。

以上就是 gRPC 的优雅终止过程。简单来说,gRPC 需要从外至内的保证了各层逻辑的正常关闭。

但是,这里有个问题可能容易忽视。最后一步调用 s.cv.Wait(),用来等待业务处理逻辑的正常结束。但这里可能有异常情况是,如果业务逻辑由于代码 bug,发生了死锁或者死循环,那么业务逻辑将永远无法结束,s.cv.Wait() 也将会一直卡住。这样,GracefulStop 也将永远无法结束。

针对于这个问题,需要配合外置的部署系统,对服务进行强行的超时终止。接下来,我们看下 k8s 是如何实现这一点的。

k8s 的优雅终止

在 k8s 下线 pod 之前,集群并不会强制的杀死 pod,而是需要执行一系列步骤才会让 pod 体面的下线。

  1. 检查 pod 的生命周期,如果配置有 preStop,则先执行 preStop 钩子。我们可以做一些预先清理和服务注册中心下线等工作。
  2. 向 pod 发送 SIGTERM 信号。SIGTERM 其实就对应于 linux 命令 kill -15。这就需要 RPC 自行监听 SIGTERM 信号,一旦收到信号,即可执行优雅终止。
  3. 等待一段时间,如果 pod 依然没有自行停止,则向 pod 发送 SIGKILL 信号,相当于 linux 命令 kill -9,pod 将被强行终止。而等待的时长取决于 pod 配置的优雅终止时间 terminationGracePeriodSeconds 参数,默认为 30 秒。

这个时候,突然想到了《让子弹飞》里面的一句话:“黄老爷是个体面人,他要是体面,你就让他体面。他要是不体面,你就帮他体面。”

k8s 允许 pod 体面的下线,如果 pod 不体面,那么就强行让他体面的下线。

优雅终止的流程总结

以上内容分别讲了如何在各个方面实现服务的优雅终止,总结下整个优雅终止的流程:

  1. 首先将服务节点主动从服务注册中心下线,保证服务注册中心。如果服务基于 k8s 进行调度和管理,可使用 preStop 回调进行服务注册中心的下线。
  2. RPC需要实现一整套的优雅终止逻辑。保证现有业务逻辑尽量不受损。
  3. k8s等待pod优雅终止期过后,强制停止pod。

基于以上一整套流程,可以实现服务的优雅终止, 这对于无状态服务来说基本已经够用了。但对于有状态服务,优雅终止的挑战会更难一些。TiDB 这里有一篇文章,讲述了 有状态分布式应用的优雅终止挑战,有兴趣的同学可以扩展看一下。

本文主要讲了服务的优雅终止,那么既然有优雅终止,那同时也会对应服务的优雅启动。服务的优雅终止是从外至內的,首先关闭掉最外层的流量进入,再逐步向内停止逻辑。而优雅启动要从内至外地保证各层逻辑正常打开,才能完成最终的上线。

参考