如何在 Kubernetes 中实现应用的无损上线和下线

转载:https://mp.weixin.qq.com/s/LdquOPS34mLFqYjfI4J6fQ

 

在日常工作中,经常会接收到开发团队这样的反馈:为什么应用发布或重启的期间会出现少量的 5xx 异常,应该如何解决?

在深入分析后,我们发现导致流量有损的原因有很多,比如:

  • 上线时,应用在就绪前收到流量,导致请求无法被处理;
  • 下线时,应用没有做优雅退出导致请求中断,应用没有正确监听到终止信号导致优雅退出无效,平台路由规则更新不及时导致流量转发到已经销毁的副本等;

因此,本期我将针对这些场景来详细讲解如何在 Kubernetes 中实现应用的无损上线和下线,以解决在发布或重启应用时出现5xx异常的问题。

 

流量转发过程

想要知道流量有损的原因,就必须先搞明白流量转发的过程,我们通过一张图来看看企业内部中流量转发的过程:

其中涉及到负载均衡器、流量网关、业务网关、Ingress、Service 这类产品和对象,虽然它们都具有负载均衡和流量转发的能力,但是他们的功能和定位各不相同:

  1. 云厂商/自建 LB:无论是云厂商提供的还是自建的负载均衡器都需要一个可以供外部访问的 IP 地址来作为流量的入口,将流量分发到后端的服务或集群中。云厂商的负载均衡器有很多,比如阿里云的 SLB、华为云的 ELB 等;而自建负载均衡器则可以采用云厂商的主机,也可以使用公共 IP 地址等;
  2. 流量网关:云厂商或自建的负载均衡器已经具备流量网关的基本功能,能满足一些简单的应用场景,但面对于更高级的流量管理和控制功能,以及特定的安全需求,企业往往会引入更为复杂的流量网关产品,用于实现访问控制(基于客户端IP、用户身份、请求内容等)、安全认证和授权(身份认证、授权、单点登录等)、流量过滤和转发、监控和报警等功能;
  3. 业务网关/Ingress:虽然业务网关和流量网关在功能上有些类似,但侧重点不同:流量网关更侧重于全局的网络通信、性能和安全方案,业务网关则更加贴近特定的业务要求。业务组的定制化需求应该优先选择放在业务网关而不是流量网关里。Ingress 是 k8s 中的一种资源对象,它充当着在 Kubernetes 集群中公开服务的入口,能提供部分业务网关的功能,比如路由转发、SSL、负载均衡等;
  4. Service:为 Pod 提供统一的访问入口,使用的是基于传输层(L4)的负载均衡技术,通过 ipvs 或 iptables 等工具配置和管理流量的转发规则,将流量转发到后端 Pod 中,但 Service 不擅长处理 HTTP/2、gRPC这类长连接的请求,也无法支持高级的路由需求,比如说针对 Host、Path等 L7 层协议的动态路由;

当流量访问我们的域名时,它会首先通过云厂商的 DNS 服务器获取域名对应的负载均衡器地址。然后,流量会通过 VPN 或专线传输至企业内部的流量网关,接着到达项目的业务网关或者 Kubernetes 的 Ingress 对象,最终流量通过 Service 的名称,被 ipvs 或 iptables 配置的路由规则转发到后端的 Pod 中。

当应用发布或重启时,流量转发过程可能会中断,导致流量达不到目的地,造成流量有损。好在 Kubernetes 提供了滚动更新的机制,可以在不中断服务的情况下更新现有的服务。

 

滚动更新机制

在应用发布时,Kubernetes 会将已就绪的 Pod 添加到与 Service 同名的 Endpoint 对象中,并在 Endpoint 中移除处于 Terminating 状态的 Pod。CCM(cloud-controller-manager)和 kube-proxy 组件都会监听 Service 和 Endpoint 对象的变化。在他们发生变化时,kube-proxy 组件会通过 ipvs 或 iptables 更新节点的流量转发规则,CCM 组件则会更新下游的后端。

Kubernetes 常用的三种工作负载:Deployment(无状态应用)、StatefulSet(有状态应用)、DaemonSet(守护进程) 对象都支持滚动更新。在滚动更新的过程中,通过 maxSurge 字段来控制允许超出期望副本数的副本个数,maxUnavailable 字段来控制更新过程中不可用的副本个数。

以 Deployment 为例,它的 maxSurge 和 maxUnavailable 默认值分别是25%。在滚动更新时,Kubernetes 会创建一个新的 ReplicaSet 对象来启动新的副本,而旧的 ReplicaSet 对象会逐步减少副本数量。

假设有某应用有 5 个副本,这就意味着在滚动更新时,它最多只有 1 个副本(5 * 25% = 1.25,向下取整即为1)处于不可用的状态,最多存在 7 个副本(新增了 2 个副本,5 * 25% = 1.25,向上取整即为2),示例图如下:

在服务上线和下线的过程中,如果配置不当,就很容易会导致业务网关到 Service、Service 到 Pod 这两个环节的流量出现有损。

 

我们先来看看服务上线时流量有损的问题。

 

服务上线有损分析

Kubernetes 为容器提供了三种健康检测的能力:

  • 启动检测:用于检测容器是否正常启动,若检测不通过,kubelet 将会杀死容器并根据容器的重启策略来决定是否重启;
  • 存活检测:用于定期检测容器是否正常运行,若检测不通过,后续行为同启动检测;
  • 就绪检测:用于定期检测容器是否可以接收流量,若检测不通过,Kubernetes 系统的控制器将会将 PodIP 从 Service 对象的 Endpoint 中移除,来确保不会有客户端的流量进入该容器;

  我们可以从 Pod 的创建流程来看看健康探测对服务上线时流量的影响:

在滚动更新时, ReplicaSet 控制器会向 kube-apiserver 组件发送创建 Pod 的请求,kube-scheduler 组件负责选择合适的节点的节点来运行新的 Pod,并将选择的节点写入 Pod 对象的 spec.nodeName 字段中。

部署在每个节点中的 kubelet 组件会监听 Pod 对象的变化,当新的 Pod 被调度到所在的节点时,kubelet 组件会使用 CRI 来启动容器,并在启动后对 Pod 内每个容器执行启动探测。

在启动探测通过后,再分别对每个容器周期性地执行存活探测和就绪探测,在 Pod 内所有容器都通过就绪探测之后,kubelet 组件会把 Pod 标记为 Ready 状态,同时上报给 kube-apiserver 组件。

而 Endpoint 控制器会监听 Pod 的变化,当 Pod 的状态变为已就绪时,会将 Pod 的 IP 和端口添加到 Endpoint 对象中, kube-proxy 组件在监听 Endpoint 对象的变化后会使用 ipvs 或 iptables 在节点中生成流量转发的规则。

在这个过程中,没有配置就绪探测或者就绪探测配置不当,则是服务上线时流量有损的主要原因。

 

没有配置就绪探测导致服务上线有损

如果没有配置就绪探测,Pod 在启动完成后会立即被视为就绪状态,然后开始接收流量,如果此时容器内的进程还在初始化资源的状态就会造成流量有损,因此在生产环境中,强烈建议大家配置上就绪探测。

 

就绪探测配置不当导致服务上线有损

在使用就绪探测时需要注意一些细节问题,比如说就绪探测提供了三种探测方式:HTTP 探测、命令行探测、TCP 探测,对于常规的 Web 服务,我们应该首选 HTTP 探测、备选命令行探测,尽量避免使用 TCP 探测。

在使用 TCP 探测时,kubelet 组件会向指定端口发送 TCP SYN 包,如果 Pod 内核响应 TCP ACK包则表明监听的端口处于打开状态,则探测成功。但TCP 探测只能检测网络连接是否建立、服务端口是否打开,无法反应服务的真实健康状态,比如程序的端口虽然已经打开,但如果内部正在初始化资源或者出现了死锁等问题的话,也就无法正常处理流量,即流量打到表面健康但实际不健康的 Pod 上,造成流量有损。

 

服务下线有损分析

服务下线时,流量有损的原因可能出现在业务方面,也可能出现 Kubernetes 平台方面。我们先来看看 Pod 退出时的销毁过程,再来说业务侧和平台侧的问题:

在滚动更新时, ReplicaSet 控制器会向 kube-apiserver 组件发送删除 Pod 的请求,kube-apiserver 不会立即删除 Pod,而是在 Pod 的metadata 中添加 deletionTimestamp 字段,将 Pod 的状态标记为 Terminating。

当 kubelet 组件监听到 Pod 对象的 deletionTimestamp 被设置时,就会调用 CRI 向容器发起停止容器的请求,如果容器设置了 PreStop 钩子,kubelet 会在发送 SIGTERM 信号之前先执行 PreStop 钩子,等待 PreStop 钩子执行完成后再发送 SIGTERM 信号,接着在 terminationGracePeriodSeconds 后发送 SIGKILL 信号去杀死所有容器进程,完成容器的停止过程。

在 kubelet 停止容器的同时,Endpoint 控制器监听到 Pod 的状态变化,会将 Pod 的 IP 从相应的 Endpoint 对象中移除,kube-proxy 组件监听到 Endpoint 对象的变化后,会移除 Pod IP 的转发规则。kube-proxy 在不同的模式下移除转发规则的方式会有所不同,比如 ipvs 模式下会把规则的权重修改为0,iptable 模式下则是直接删除转发规则。

 

接下来,我们来看在看服务下线时,流量在业务侧容易出现的问题。

 

没有实现优雅退出导致服务下线有损

业务侧第一个可能会忽略的问题就是没有做优雅退出,优雅退出可以让程序在退出前有机会等待尚未完成的事务处理、清理资源(比如关闭文件描述符、关闭socket)、持久化内存数据(比如将内存中的数据落盘到文件中)等,我们来看一个最简单的优雅退出示例:

var (
   httpPort = ":9081"
   grpcPort = ":9082"
)

func main() {
   // 监听SIGINT、SIGTERM等信号
   signals := []os.Signal{syscall.SIGINT, syscall.SIGTERM}
   signalChan := make(chan os.Signal, 1)
   signal.Notify(signalChan, signals...)

   grpcSrv := grpc.NewServer()
   httpSrv := &http.Server{Addr: httpPort}

   ctx, stop := signal.NotifyContext(context.Background(), signals...)
   defer stop()

   g, gctx := errgroup.WithContext(ctx)
   g.Go(func() error {
      log.Println("启动http服务")
      return httpSrv.ListenAndServe()
   })
   g.Go(func() error {
      grpcListener, err := net.Listen("tcp", grpcPort)
      if err != nil {
         return err
      }
      log.Println("启动grpc服务")
      return grpcSrv.Serve(grpcListener)
   })
   g.Go(func() error {
      // 优雅退出
      <-gctx.Done()
      c := <-signalChan
      log.Println("开始优雅退出,退出信号为", c.String())

      grpcSrv.Stop()
      // 创建一个超时上下文
      sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
      defer cancel()
      return httpSrv.Shutdown(sctx)
   })
   if err := g.Wait(); err != nil {
      println("服务退出:", err.Error())
   }
}

在这份代码中,程序在监听到 SIGINT、SIGTERM 信号后开始关闭 gRPC、HTTP 等服务。为了防止服务长时间无法退出,我们创建了一个超时上下文,httpSrv.Shutdown(c) 会关闭 HTTP 服务,该方法会等待所有活动连接都关闭,或超时时间到达时立即返回。

不过如果 HTTP 连接在超时时间内无法关闭的话,依然会产生 50x 的错误,因此生产环境中,优雅退出的实现要复杂得多,这里不做过多展开。

 

程序启动方式不正确导致服务下线有损

业务侧第二个可能会忽略的问题就是启动方式不对导致程序无法接收到信号,进而无法实现优雅退出。

我们发现部分同学在启用程序时会使用启动脚本,示例如下:

#!/bin/sh

# start.sh
# do something
/demo/app

在 Kubernetes 环境下,这种启动方式会使程序无法正常接收 SIGINT、SIGTERM 等信号以进行优雅退出,最终只能被 SIGKILL 强制终止。

我们可以复现这个过程,先准备 Dockerfile,示例如下:

# 构建
FROM golang:alpine as build

WORKDIR /demo

ADD . .

RUN GOOS=linux CGO_ENABLED=0 GOARCH=amd64 go build -o app main.go

# 运行
FROM ubuntu:22.04 as prod

COPY --from=build /demo/app /demo/
COPY --from=build /demo/start.sh /demo/

# 启动服务
CMD ["/demo/start.sh"]

Kubernetes 的资源文件示例如下:

# pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: graceful
spec:
  containers:
    - name: graceful
      image: graceful-app:latest
      imagePullPolicy: IfNotPresent

  接着,执行以下命令:

# 构建镜像
$ docker build -t graceful-app:latest -f Dockerfile  .

# 启动服务
$ kubectl apply -f pod.yaml

# 查看日志
$ kubectl logs graceful -f
2023/09/06 04:34:35 启动http服务
2023/09/06 04:34:35 启动grpc服务

  然后,我们打开新的终端执行删除操作。按照预期的设想,程序会输出『优雅退出』相关的字样:

$ kubectl delete -f pod.yaml

继续观察日志,发现程序并没有按照预期执行,而是在宽限期 terminationGracePeriodSeconds(默认是30s)后直接退出。

这个问题的根源在于业务程序在容器中没有作为1号进程启动,而是作为启动脚本start.sh的子进程启动。我们可以使用 strace 命令监听启动脚本和业务程序所在的进程,操作如下:

# 在容器中查看进程
$ ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   2484   520 ?        Ss   06:47   0:00 /bin/sh ./start.sh
root         8  0.0  0.0 711492  6648 ?        Sl   06:47   0:00 /demo/app

# 在宿主机中查看进程
$ ps aux
USER     PID   %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root     44377  0.0  0.0   2484   520 ?        Ss   14:47   0:00 /bin/sh ./start.sh
root     44403  0.0  0.0 711492  6648 ?        Sl   14:47   0:00 /demo/app

# 在宿主机中监听start.sh的进程
$ strace -p 44377
strace: Process 44377 attached
wait4(-1,
0x7ffd60d9dd1c, 0, NULL)      = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0} ---
wait4(-1,  <unfinished ...>)            = ?
+++ killed by SIGKILL +++

# 在宿主机中监听业务程序的进程
$ strace -p 44403
strace: Process 44403 attached
futex(0xd19ca8, FUTEX_WAIT_PRIVATE, 0, NULL) = ?
+++ killed by SIGKILL +++

可以发现,在执行删除操作的时候,容器中的 1 号进程 start.sh 收到了 SIGTERM 信号,但并没有转发给它的子进程,导致业务程序没有办法做优雅退出,最后因为 SIGKILL 信号被强制杀掉。

解决这个问题,有两种比较简单的方法:

  1. 在 Dockerfile 中移除启动脚本 start.sh,让业务程序作为1号进程被启动:
# 构建

FROM golang:alpine as build
WORKDIR /demo
ADD . .
RUN GOOS=linux CGO_ENABLED=0 GOARCH=amd64 go build -o app main.go

# 运行
FROM ubuntu:22.04 as prod
COPY --from=build /demo/app /demo/

# 让业务程序作为1号进程被启动
CMD ["/demo/app"]

  1. 保留启动脚本start.sh,使用 exec 命令启动业务程序,exec 用于执行新的命令,同时替换掉当前进程,即覆盖掉原来启动脚本 start.sh 的进程:
#!/bin/sh

# do something
exec /demo/app

当然,还有其他方法,比如使用 tini 等轻量级的 init 系统,它专门为 Docker 容器设计,主要用于解决容器环境中的信号处理问题,这里就不做展开,有兴趣的同学可以自行了解。

 

路由转发规则更新不及时导致服务下线有损

服务下线的过程中,Kubernetes 的 kubelet 和 kube-proxy 组件会同时监听 Pod 的变化,然后分别去清理容器和网络的路由转发规则,这个过程是同时进行的。在某些情况下,kubelet 有可能在 kube-proxy 更新完路由转发规则前就已经销毁了容器,这时新的流量被转发进来时,就会出现异常。

为了避免这种情况,我们可以使用 Kubernetes 提供的 preStop 钩子,让 kubelet 组件在等待一段时间后在执行回收容器的操作,给 kube-proxy 留够清理的时间,示例如下:

lifecycle:
  preStop:
    exec:
      command:
        - /bin/sleep
        - '15'

除此之外,还有其他导致路由转发规则更新不及时的场景,比如有些同学在网关中通过 NodeIP+NodePort 的形式转发流量,如果节点出现异常,运维需要对节点进行下线,这时在网关的 backend 没有及时移除该节点,也会造成流量有损:

这里我们一般建议研发同学使用平台开发的 ccm 组件,监听节点变化,及时更新网关的 backend 列表。

 

总结

在本期文章中,我给大家分析了服务在上下线时常见的流量有损原因以及对应的解决方案:通过配置就绪探测、做好优雅退出、选择正确的启动方式、适当配置 preStop 钩子等方式,我们就可以解决大部分的流量有损问题。

但是,光靠这些手段还不能避免所有的流量有损问题,而更多的生产环境经验和教训.

 

posted @ 2024-08-07 21:50  小家电维修  阅读(64)  评论(0编辑  收藏  举报