k8s实践指南之服务高可用

为了提高服务容错能力,我们通常会设置 replicas 给服务创建多个副本,但这并不意味着服务就

使用反亲和性避免单点故障

实现高可用了,下面来介绍服务高可用部署最佳实践。
k8s 的设计就是假设节点是不可靠的,节点越多,发生软硬件故障导致节点不可用的几率就越高,所以
我们通常需要给服务部署多个副本,根据实际情况调整 replicas 的值,如果值为 1 就必然存在
单点故障,如果大于 1 但所有副本都调度到同一个节点,那还是有单点故障,所以我们不仅要有合理
的副本数量,还需要让这些不同副本调度到不同的节点,打散开来避免单点故障,这个可以利用反亲和
性来实现,示例 :
 
1. affinity:
2.     podAntiAffinity:
3.         requiredDuringSchedulingIgnoredDuringExecution:
4.         - weight: 100
5.           labelSelector:
6.             matchExpressions:
7.             - key: k8s-app
8.             operator: In
9.             values:
10.            - kube-dns
11.         topologyKey: kubernetes.io/hostname
requiredDuringSchedulingIgnoredDuringExecution 调度时必须满足该反亲和性条件,如果
没有节点满足条件就不调度到任何节点 (Pending) 。如果不用这种硬性条件可以使用
preferredDuringSchedulingIgnoredDuringExecution 来指示调度器尽量满足反亲和性条
件,如果没有满足条件的也可以调度到某个节点。
labelSelector.matchExpressions 写该服务对应 pod labels key value
topologyKey 这里用 kubernetes.io/hostname 表示避免 pod 调度到同一节点,如果你
有更高的要求,比如避免调度到同一个可用区,实现异地多活,可以用 failure-
domain.beta.kubernetes.io/zone 。通常不会去避免调度到同一个地域,因为一般同一个集群
的节点都在一个地域,如果跨地域,即使用专线时延也会很大,所以 topologyKey 一般不至
于用 failure-domain.beta.kubernetes.io/region

使用PodDisruptionBudget 避免驱逐导致服务不可用

驱逐节点是一种有损操作,驱逐的原理:

1. 封锁节点 ( 设为不可调度,避免新的 Pod 调度上来 )
2. 将该节点上的 Pod 删除。
3. ReplicaSet 控制器检测到 Pod 减少,会重新创建一个 Pod ,调度到新的节点上。
这个过程是先删除,再创建,并非是滚动更新,因此更新过程中,如果一个服务的所有副本都在被驱逐
的节点上,则可能导致该服务不可用。

我们再来下什么情况下驱逐会导致服务不可用:

1. 服务存在单点故障,所有副本都在同一个节点,驱逐该节点时,就可能造成服务不可用。
2. 服务在多个节点,但这些节点都被同时驱逐,所以这个服务的所有服务同时被删,也可能造成服
务不可用。
针对第一点,我们可以 使用反亲和性避免单点故障
针对第二点,我们可以通过配置 PDB (PodDisruptionBudget) 来避免所有副本同时被删除,下面
给出示例。
示例一 ( 保证驱逐时 zookeeper 至少有两个副本可用 ):
 
1. apiVersion: policy/v1beta1
2. kind: PodDisruptionBudget
3. metadata:
4.     name: zk-pdb
5. spec:
6.     minAvailable: 2
7.     selector:
8.         matchLabels:
9.             app: zookeeper
示例二 ( 保证驱逐时 zookeeper 最多有一个副本不可用,相当于逐个删除并在其它节点重建 ):
1. apiVersion: policy/v1beta1
2. kind: PodDisruptionBudget
3. metadata:
4.     name: zk-pdb
5. spec:
6.     maxUnavailable: 1
7.     selector:
8.         matchLabels:
9.             app: zookeeper
更多请参考官方文档 : https://kubernetes.io/docs/tasks/run-application/configure-pdb/

使用 preStopHook 和 readinessProbe 保证服务平滑更新不中断

如果服务不做配置优化,默认情况下更新服务期间可能会导致部分流量异常,下面我们来分析并给出最佳实践。

服务更新场景:

我们先看下服务更新有哪些场景 :
  • 手动调整服务的副本数量
  • 手动删除 Pod 触发重新调度
  • 驱逐节点 (主动或被动驱逐,Pod会先删除再在其它节点重建)
  • 触发滚动更新 (比如修改镜像 tag 升级程序版本)
  • HPA (HorizontalPodAutoscaler) 自动对服务进行水平伸缩
  • VPA (VerticalPodAutoscaler) 自动对服务进行垂直伸缩

更新过程连接异常的原因:

滚动更新时, Service 对应的 Pod 会被创建或销毁, Service 对应的 Endpoint 也会新增或移
除相应的 Pod IP:Port ,然后 kube-proxy 会根据 Service Endpoint 里的 Pod
IP:Port 列表更新节点上的转发规则,而这里 kube-proxy 更新节点转发规则的动作并不是那么及
时,主要是由于 K8S 的设计理念,各个组件的逻辑是解耦的,各自使用 Controller 模式
listAndWatch 感兴趣的资源并做出相应的行为,所以从 Pod 创建或销毁到 Endpoint 更新再到
节点上的转发规则更新,这个过程是异步的,所以会造成转发规则更新不及时,从而导致服务更新期间部分连接异常。
 

我们分别分析下 Pod 创建和销毁到规则更新期间的过程:

1. Pod 被创建,但启动速度没那么快,还没等到 Pod 完全启动就被 Endpoint Controller
加入到 Service 对应 Endpoint Pod IP:Port 列表,然后 kube-proxy watch
更新也同步更新了节点上的 Service 转发规则 (iptables/ipvs) ,如果这个时候有请求过
来就可能被转发到还没完全启动完全的 Pod ,这时 Pod 还不能正常处理请求,就会导致连接被
拒绝。
2. Pod 被销毁,但是从 Endpoint Controller watch 到变化并更新 Service 对应

Endpoint 再到 kube-proxy 更新节点转发规则这期间是异步的,有个时间差,Pod 可能已经完全被销毁了,但是转发规则还没来得及更新,就会造成新来的请求依旧还能被转发到已经被销毁的Pod,导致连接被拒绝。

平滑更新最佳实践

  • 针对第一种情况,可以给 Pod 里的 container readinessProbe (就绪检查),通常是容器完全启动后监听一个 HTTP 端口,kubelet 发就绪检查探测包,正常响应说明容器已经就绪,然后修改容器状态为 Ready,当 Pod 中所有容器都 Ready 了这个 Pod 才会被Endpoint Controller 加进 Service 对应 Endpoint IP:Port 列表,然后 kube-proxy 再更新节点转发规则,更新完了即便立即有请求被转发到的新的 Pod 也能保证能够正常处理连接,避免了连接异常。
  • 针对第二种情况,可以给 Pod 里的 container preStop hook,让 Pod 真正销毁前先sleep 等待一段时间,留点时间给 Endpoint controller kube-proxy 更新Endpoint 和转发规则,这段时间 Pod 处于 Terminating 状态,即便在转发规则更新完全之前有请求被转发到这个 Terminating Pod,依然可以被正常处理,因为它还在 sleep没有被真正销毁。

最佳实践 yaml 示例:

1. apiVersion: extensions/v1beta1
2. kind: Deployment
3. metadata:
4.   name: nginx
5. spec:
6.   replicas: 1
7.   selector:
8.     matchLabels:
9.       component: nginx
10.  template:
11.    metadata:
12.      labels:
13.        component: nginx
14.    spec:
15.      containers:
16.        - name: nginx
17.          image: "nginx"
18.          ports:
19.            - name: http
20.              hostPort: 80
21.              containerPort: 80
22.              protocol: TCP
23.          readinessProbe:
24.            httpGet:
25.              path: /healthz
26.              port: 80
27.              httpHeaders:
28.              - name: X-Custom-Header
29.                value: Awesome
30.            initialDelaySeconds: 15
31.            timeoutSeconds: 1
32.          lifecycle:
33.            preStop:
34.              exec:
35.                command: ["/bin/bash", "-c", "sleep 30"]
 

参考资料

  • Container probes: https://kubernetes.io/docs/concepts/workloads/pods/pod- lifecycle/#container-probes

  • Container Lifecycle Hooks:https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/

解决长连接服务扩容失效

在现网运营中,有很多场景为了提高效率,一般都采用建立长连接的方式来请求。我们发现在客户端以
长连接请求服务端的场景下, K8S 的自动扩容会失效。原因是客户端长连接一直保留在老的 Pod 容器
中,新扩容的 Pod 没有新的连接过来,导致 K8S 按照步长扩容第一批 Pod 之后就停止了扩容操作,而且新
扩容的 Pod 没能承载请求,进而出现服务过载的情况,自动扩容失去了意义。
对长连接扩容失效的问题,我们的解决方法是将长连接转换为短连接。我们参考了 nginx
keepalive 的设计, nginx keepalive_requests 这个配置项设定了一个 TCP 连接能处理的最
大请求数,达到设定值 ( 比如 1000) 之后服务端会在 http Header 头标记
Connection:close ,通知客户端处理完当前的请求后关闭连接,新的请求需要重新建立 TCP
接,所以这个过程中不会出现请求失败,同时又达到了将长连接按需转换为短连接的目的。通过这个办
法客户端和云 K8S 服务端处理完一批请求后不断的更新 TCP 连接,自动扩容的新 Pod 能接收到新的连接请
求,从而解决了自动扩容失效的问题。
由于 Golang 并没有提供方法可以获取到每个连接处理过的请求数,我们重写了 net.Listener
net.Conn ,注入请求计数器,对每个连接处理的请求做计数,并通过 net.Conn.LocalAddr()
获得计数值,判断达到阈值 1000 后在返回的 Header 中插入 Connection:close 通知客户
端关闭连接,重新建立连接来发起请求。以上处理逻辑用 Golang 实现示例代码如下:
 
1. package main
2.
3. import (
4.     "net"
5.     "github.com/gin-gonic/gin"
6.     "net/http"
7. )
8.
9. // 重新定义net.Listener
10. type counterListener struct {
11.     net.Listener
12. }
13.
14. // 重写net.Listener.Accept(),对接收到的连接注入请求计数器
15. func (c *counterListener) Accept() (net.Conn, error) {
16.     conn, err := c.Listener.Accept()
17.     if err != nil {
18.         return nil, err
19. }
20.     return &counterConn{Conn: conn}, nil
21. }
22.
23. // 定义计数器counter和计数方法Increment()
24. type counter int
25.
26. func (c *counter) Increment() int {
27.     *c++
28.     return int(*c)
29. }
30.
31. // 重新定义net.Conn,注入计数器ct
32. type counterConn struct {
33.     net.Conn
34.     ct counter
35. }
36.
37. // 重写net.Conn.LocalAddr(),返回本地网络地址的同时返回该连接累计处理过的请求数
38. func (c *counterConn) LocalAddr() net.Addr {
39.     return &counterAddr{c.Conn.LocalAddr(), &c.ct}
40. }
41.
42. // 定义TCP连接计数器,指向连接累计请求的计数器
43. type counterAddr struct {
44.     net.Addr
45.     *counter
46. }
47.
48. func main() {
49.     r := gin.New()
50.     r.Use(func(c *gin.Context) {
51.     localAddr := c.Request.Context().Value(http.LocalAddrContextKey)
52.     if ct, ok := localAddr.(interface{ Increment() int }); ok {
53.     if ct.Increment() >= 1000 {
54.         c.Header("Connection", "close")
55.         }
56.     }
57.     c.Next()
58.     })
59.     r.GET("/", func(c *gin.Context) {
60.     c.String(200, "plain/text", "hello")
61.     })
62.     l, err := net.Listen("tcp", ":8080")
63.     if err != nil {
64.         panic(err)
65.     }
66.     err = http.Serve(&counterListener{l}, r)
67.     if err != nil {
68.         panic(err)
69.     }
70. }

记载于书本:《k8s实践指南》

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值