在 Kubernetes 容器集群,微服务项目最佳实践

本文主要介绍我个人在使用 Kubernetes 的过程中,总结出的一套「Kubernetes 配置」,是我个人的「最佳实践」。其中大部分内容都经历过线上环境的考验,但是也有少部分还只在我脑子里模拟过,请谨慎参考。

阅读前的几个注意事项:

这份文档比较长,囊括了很多内容,建议当成参考手册使用,先参照目录简单读一读,有需要再细读相关内容。

这份文档需要一定的 Kubernetes 基础才能理解,而且如果没有过实践经验的话,看上去可能会比较枯燥。而有过实践经验的大佬,可能会跟我有不同的见解,欢迎各路大佬评论

首先,这里给出一些本文遵守的前提,这些前提只是契合我遇到的场景,可灵活变通:

  • 这里只讨论无状态服务,有状态服务不在讨论范围内
  • 我们不使用 Deployment 的滚动更新能力,而是为每个服务的每个版本,都创建不同的 Deployment + HPA + PodDisruptionBudget,这是为了方便做金丝雀/灰度发布
  • 我们的服务可能会使用 IngressController / Service Mesh 来进行服务的负载均衡、流量切分

下面先给出一个 Deployment + HPA + PodDisruptionBudget 的 demo,后面再拆开详细说下:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app-v3
  namespace: prod  # 建议按业务逻辑划分名字空间,prod 仅为示例
  labels:
    app: my-app
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    # 因为服务的每个版本都使用各自的 Deployment,服务更新时其实是用不上这里的滚动更新策略的
    # 这个配置应该只在 SRE 手动修改 Deployment 配置时才会生效(通常不应该发生这种事)
    rollingUpdate:
      maxSurge: 10%  # 滚动更新时,每次最多更新 10% 的 Pods
      maxUnavailable: 0  # 滚动更新时,不允许出现不可用的 Pods,也就是说始终要维持 3 个可用副本
  selector:
    matchLabels:
      app: my-app
      version: v3
  template:
    metadata:
      labels:
        app: my-app
        version: v3
    spec:
      affinity:
        # 注意,podAffinity/podAntiAffinity 可能不是最佳方案,这部分配置待更新
        # topologySpreadConstraints 可能是更好的选择
        podAffinity:
          preferredDuringSchedulingIgnoredDuringExecution: # 非强制性条件
          - weight: 100  # weight 用于为节点评分,会优先选择评分最高的节点(只有一条规则的情况下,这个值没啥意义)
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: app
                  operator: In
                  values:
                  - my-app
                - key: version
                  operator: In
                  values:
                  - v3
              # pod 尽量使用同一种节点类型,也就是尽量保证节点的性能一致
              topologyKey: node.kubernetes.io/instance-type
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution: # 非强制性条件
          - weight: 100  # weight 用于为节点评分,会优先选择评分最高的节点(只有一条规则的情况下,这个值没啥意义)
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: app
                  operator: In
                  values:
                  - my-app
                - key: version
                  operator: In
                  values:
                  - v3
              # 将 pod 尽量打散在多个可用区
              topologyKey: topology.kubernetes.io/zone
          requiredDuringSchedulingIgnoredDuringExecution:  # 强制性要求(这个建议按需添加)
          # 注意这个没有 weights,必须满足列表中的所有条件
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - my-app
              - key: version
                operator: In
                values:
                - v3
            # Pod 必须运行在不同的节点上
            topologyKey: kubernetes.io/hostname
      securityContext:
        # runAsUser: 1000  # 设定用户
        # runAsGroup: 1000  # 设定用户组
        runAsNonRoot: true  # Pod 必须以非 root 用户运行
        seccompProfile:  # security compute mode
          type: RuntimeDefault
      nodeSelector:
        nodegroup: common  # 使用专用节点组,如果希望使用多个节点组,可改用节点亲和性
      volumes:
      - name: tmp-dir
        emptyDir: {}
      containers:
      - name: my-app-v3
        image: my-app:v3  # 建议使用私有镜像仓库,规避 docker.io 的镜像拉取限制
        imagePullPolicy: IfNotPresent
        volumeMounts:
        - mountPath: /tmp
          name: tmp-dir
        lifecycle:
          preStop:  # 在容器被 kill 之前执行
            exec:
              command:
              - /bin/sh
              - -c
              - "while [ $(netstat -plunt | grep tcp | wc -l | xargs) -ne 0 ]; do sleep 1; done"
        resources:  # 资源请求与限制
          # 对于核心服务,建议设置 requests = limits,避免资源竞争
          requests:
            # HPA 会使用 requests 计算资源利用率
            # 建议将 requests 设为服务正常状态下的 CPU 使用率,HPA 的目前指标设为 80%
            # 所有容器的 requests 总量不建议为 2c/4G 4c/8G 等常见值,因为节点通常也是这个配置,这会导致 Pod 只能调度到更大的节点上,适当调小 requests 等扩充可用的节点类型,从而扩充节点池。
            cpu: 1000m
            memory: 1Gi
          limits:
            # limits - requests 为允许超卖的资源量,建议为 requests 的 1 到 2 倍,酌情配置。
            cpu: 1000m
            memory: 1Gi
        securityContext:
          # 将容器层设为只读,防止容器文件被篡改
          ## 如果需要写入临时文件,建议额外挂载 emptyDir 来提供可读写的数据卷
          readOnlyRootFilesystem: true
          # 禁止 Pod 做任何权限提升
          allowPrivilegeEscalation: false
          capabilities:
            # drop ALL 的权限比较严格,可按需修改
            drop:
            - ALL
        startupProbe:  # 要求 kubernetes 1.18+
          httpGet:
            path: /actuator/health  # 直接使用健康检查接口即可
            port: 8080
          periodSeconds: 5
          timeoutSeconds: 1
          failureThreshold: 20  # 最多提供给服务 5s * 20 的启动时间
          successThreshold: 1
        livenessProbe:
          httpGet:
            path: /actuator/health  # spring 的通用健康检查路径
            port: 8080
          periodSeconds: 5
          timeoutSeconds: 1
          failureThreshold: 5
          successThreshold: 1
        # Readiness probes are very important for a RollingUpdate to work properly,
        readinessProbe:
          httpGet:
            path: /actuator/health  # 简单起见可直接使用 livenessProbe 相同的接口,当然也可额外定义
            port: 8080
          periodSeconds: 5
          timeoutSeconds: 1
          failureThreshold: 5
          successThreshold: 1
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: my-app-v3
  namespace: prod
  labels:
    app: my-app
spec:
  minAvailable: 75%
  selector:
    matchLabels:
      app: my-app
      version: v3

如果 Pod 正在处理大量请求(比如 1000 QPS+)时,因为节点故障或「竞价节点」被回收等原因被重新调度, 你可能会观察到在容器被 terminate 的一段时间内出现少量 502/504。

为了搞清楚这个问题,需要先理解清楚 terminate 一个 Pod 的流程:

1.Pod 的状态被设为 Terminating,(几乎)同时该 Pod 被从所有关联的 Service Endpoints 中移除
2.preStop 钩子被执行
a.它的执行阶段很好理解:在容器被 stop 之前执行
b.它可以是一个命令,或者一个对 Pod 中容器的 http 调用
c.如果在收到 SIGTERM 信号时,无法优雅退出,要支持优雅退出比较麻烦的话,用 preStop 实现优雅退出是一个非常好的方式
d.preStop 的定义位置:https://github.com/kubernetes/api/blob/master/core/v1/types.go#L2515
3.preStop 执行完毕后,SIGTERM 信号被发送给 Pod 中的所有容器
4.继续等待,直到容器停止,或者超时 spec.terminationGracePeriodSeconds,这个值默认为 30s
a.需要注意的是,这个优雅退出的等待计时是与 preStop 同步开始的!而且它也不会等待 preStop 结束!
5.如果超过了 spec.terminationGracePeriodSeconds 容器仍然没有停止,k8s 将会发送 SIGKILL 信号给容器
6.进程全部终止后,整个 Pod 完全被清理掉

注意:1 跟 2 两个工作是异步发生的,所以在未设置 preStop 时,可能会出现「Pod 还在 Service Endpoints 中,但是 SIGTERM 已经被发送给 Pod 导致容器都挂掉」的情况,我们需要考虑到这种状况的发生。

了解了上面的流程后,我们就能分析出两种错误码出现的原因:

  • 502:应用程序在收到 SIGTERM 信号后直接终止了运行,导致部分还没有被处理完的请求直接中断,代理层返回 502 表示这种情况
  • 504:Service Endpoints 移除不够及时,在 Pod 已经被终止后,仍然有个别请求被路由到了该 Pod,得不到响应导致 504

通常的解决方案是,在 Pod 的 preStop 步骤加一个 15s 的等待时间。其原理是:在 Pod 处理 terminating 状态的时候,就会被从 Service Endpoints 中移除,也就不会再有新的请求过来了。在 preStop 等待 15s,基本就能保证所有的请求都在容器死掉之前被处理完成(一般来说,绝大部分请求的处理时间都在 300ms 以内吧)。

一个简单的示例如下,它使 Pod 被 Terminate 时,总是在 stop 前先等待 15s,再发送 SIGTERM 信号给容器:

    containers:
    - name: my-app
      # 添加下面这部分
      lifecycle:
        preStop:
          exec:
            command:
            - /bin/sleep
            - "15"

更好的解决办法,是直接等待所有 tcp 连接都关闭(需要镜像中有 netstat):

    containers:
    - name: my-app
      # 添加下面这部分
      lifecycle:
      preStop:
          exec:
            command:
            - /bin/sh
            - -c
            - "while [ $(netstat -plunt | grep tcp | wc -l | xargs) -ne 0 ]; do sleep 1; done"

如果我的服务还使用了 Sidecar 代理网络请求,该怎么处理?

以服务网格 Istio 为例,在 Envoy 代理了 Pod 流量的情况下,502/504 的问题会变得更复杂一点——还需要考虑 Sidecar 与主容器的关闭顺序:

  • 如果在 Envoy 已关闭后,有新的请求再进来,将会导致 504(没人响应这个请求了)
  • 所以 Envoy 最好在 Terminating 至少 3s 后才能关,确保 Istio 网格配置已完全更新
  • 如果在 Envoy 还没停止时,主容器先关闭,然后又有新的请求再进来,Envoy 将因为无法连接到 upstream 导致 503
  • 所以主容器也最好在 Terminating 至少 3s 后,才能关闭。
  • 如果主容器处理还未处理完遗留请求时,Envoy 或者主容器的其中一个停止了,会因为 tcp 连接直接断开连接导致 502
  • 因此 Envoy 必须在主容器处理完遗留请求后(即没有 tcp 连接时),才能关闭

所以总结下:Envoy 及主容器的 preStop 都至少得设成 3s,并且在「没有 tcp 连接」时,才能关闭,避免出现 502/503/504.

主容器的修改方法在前文中已经写过了,下面介绍下 Envoy 的修改方法。

和主容器一样,Envoy 也能直接加 preStop,修改 istio-sidecar-injector 这个 configmap,在 sidecar 里添加 preStop sleep 命令:

    containers:
    - name: istio-proxy
      # 添加下面这部分
      lifecycle:
      preStop:
          exec:
            command:
            - /bin/sh
            - -c
            - "while [ $(netstat -plunt | grep tcp | grep -v envoy | wc -l | xargs) -ne 0 ]; do sleep 1; done"

Kubernetes 官方主要支持基于 Pod CPU 的伸缩,这是应用最为广泛的伸缩指标,需要部署 metrics-server 才可使用。

先回顾下前面给出的,基于 Pod CPU 使用率进行伸缩的示例:

apiVersion: autoscaling/v2beta2  # k8s 1.23+ 此 API 已经 GA
kind: HorizontalPodAutoscaler
metadata:
  labels:
    app: my-app
  name: my-app-v3
  namespace: prod
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-app-v3
  maxReplicas: 50
  minReplicas: 3
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

1.当前指标值的计算方式

提前总结:每个 Pod 的指标是其中所有容器指标之和,如果计算百分比,就再除以 Pod 的 requests.

HPA 默认使用 Pod 的当前指标进行计算,以 CPU 使用率为例,其计算公式为:

「Pod 的 CPU 使用率」= 100% * 「所有 Container 的 CPU 用量之和」/「所有 Container 的 CPU requests 之和」

注意分母是总的 requests 量,而不是 limits.

1.1存在的问题与解决方法

在 Pod 只有一个容器时这没啥问题,但是当 Pod 注入了 envoy 等 sidecar 时,这就会有问题了。

因为 Istio 的 Sidecar requests 默认为 100m 也就是 0.1 核。在未 tuning 的情况下,服务负载一高,sidecar 的实际用量很容易就能涨到 0.2-0.4 核。把这两个值代入前面的公式,会发现 对于 QPS 较高的服务,添加 Sidecar 后,「Pod 的 CPU 利用率」可能会高于「应用容器的 CPU 利用率」,造成不必要的扩容。

即使改用「Pod 的 CPU 用量」而非百分比来进行扩缩容,也解决不了这个问题。

解决方法:

最佳解决方案:使用绝对度量指标,而非百分比。
方法一:针对每个服务的 CPU 使用情况,为每个服务的 sidecar 设置不同的 requests/limits.(感觉这个方案太麻烦了)
方法二:使用 KEDA 等第三方组件,获取到应用程序的 CPU 利用率(排除掉 Sidecar),使用它进行扩缩容
方法三:使用 k8s 1.20 提供的 alpha 特性:Container Resourse Metrics.

2.HPA 的扩缩容算法

HPA 什么时候会扩容,这一点是很好理解的。但是 HPA 的缩容策略,会有些迷惑,下面简单分析下。

HPA 的「目标指标」可以使用两种形式:绝对度量指标和资源利用率。

  • 绝对度量指标:比如 CPU,就是指 CPU 的使用量
  • 资源利用率(资源使用量/资源请求 * 100%):在 Pod 设置了资源请求时,可以使用资源利用率进行 Pod 伸缩
    HPA 的「当前指标」是一段时间内所有 Pods 的平均值,不是峰值。

HPA 的扩缩容算法为:

期望副本数 = ceil[当前副本数 * ( 当前指标 / 目标指标 )]

从上面的参数可以看到:

1.只要「当前指标」超过了目标指标,就一定会发生扩容。
2.当前指标 / 目标指标要小到一定的程度,才会触发缩容。
a.比如双副本的情况下,上述比值要小于等于 1/2,才会缩容到单副本。
b.三副本的情况下,上述比值的临界点是 2/3。
c.五副本时临界值是 4/5,100 副本时临界值是 99/100,依此类推。
d.如果 当前指标 / 目标指标 从 1 降到 0.5,副本的数量将会减半。(虽然说副本数越多,发生这么大变化的可能性就越小。)
3.当前副本数 / 目标指标的值越大,「当前指标」的波动对「期望副本数」的影响就越大。

为了防止扩缩容过于敏感,HPA 有几个相关参数:

1.Hardcoded 参数
a.HPA Loop 延时:默认 15 秒,每 15 秒钟进行一次 HPA 扫描。
b.缩容冷却时间:默认 5 分钟。
2.对于 K8s 1.18+,HPA 通过 spec.behavior 提供了多种控制扩缩容行为的参数,后面会具体介绍。

3.HPA 的期望值设成多少合适

这个需要针对每个服务的具体情况,具体分析。

以最常用的按 CPU 值伸缩为例,

  • 核心服务
  • 需要注意 CPU 跟 Memory 的 limits 限制策略是不同的,CPU 是真正地限制了上限,而 Memory 是用超了就干掉容器(OOMKilled)
  • k8s 一直使用 cgroups v1 (cpu_shares/memory.limit_in_bytes)来限制 cpu/memory,但是对于 Guaranteed 的 Pods 而言,内存并不能完全预留,资源竞争总是有可能发生的。1.22 有 alpha 特性改用 cgroups v2,可以关注下。
  • requests/limits 值: 建议设成相等的,保证服务质量等级为 Guaranteed
  • HPA: 一般来说,期望值设为 60% 到 70% 可能是比较合适的,最小副本数建议设为 2 – 5. (仅供参考)
  • PodDisruptionBudget: 建议按服务的健壮性与 HPA 期望值,来设置 PDB,后面会详细介绍,这里就先略过了
  • 非核心服务
  • 也就是超卖了资源,这样做主要的考量点是,很多非核心服务负载都很低,根本跑不到 limits 这么高,降低 requests 可以提高集群资源利用率,也不会损害服务稳定性。
  • requests/limits 值: 建议 requests 设为 limits 的 0.6 – 0.9 倍(仅供参考),对应的服务质量等级为 Burstable
  • HPA: 因为 requests 降低了,而 HPA 是以 requests 为 100% 计算使用率的,我们可以提高 HPA 的期望值(如果使用百分比为期望值的话),比如 80% ~ 90%,最小副本数建议设为 1 – 3. (仅供参考)
  • PodDisruptionBudget: 非核心服务嘛,保证最少副本数为 1 就行了。

4.HPA 的常见问题

4.1. Pod 扩容 – 预热陷阱

预热:Java/C# 这类运行在虚拟机上的语言,第一次使用到某些功能时,往往需要初始化一些资源,例如「JIT 即时编译」。如果代码里还应用了动态类加载之类的功能,就很可能导致微服务某些 API 第一次被调用时,响应特别慢(要动态编译 class)。因此 Pod 在提供服务前,需要提前「预热(slow_start)」一次这些接口,将需要用到的资源提前初始化好。

在负载很高的情况下,HPA 会自动扩容。但是如果扩容的 Pod 需要预热,就可能会遇到「预热陷阱」。

在有大量用户访问的时候,不论使用何种负载均衡策略,只要请求被转发到新建的 Pod 上,这个请求就会「卡住」。如果请求速度太快,Pod 启动的瞬间「卡住」的请求就越多,这将会导致新建 Pod 因为压力过大而垮掉。然后 Pod 一重启就被压垮,进入 CrashLoopBackoff 循环。

如果是在使用多线程做负载测试时,效果更明显:50 个线程在不间断地请求, 别的 Pod 响应时间是「毫秒级」,而新建的 Pod 的首次响应是「秒级」。几乎是一瞬间,50 个线程就会全部陷在新建的 Pod 这里。而新建的 Pod 在启动的瞬间可能特别脆弱,瞬间的 50 个并发请求就可以将它压垮。然后 Pod 一重启就被压垮,进入 CrashLoopBackoff 循环。

解决方法:

可以在「应用层面」解决:
1.在启动探针 API 的后端控制器里面,依次调用所有需要预热的接口或者其他方式,提前初始化好所有资源。
a.启动探针的控制器中,可以通过 localhost 回环地址调用它自身的接口。
2.使用「AOT 预编译」技术:预热,通常都是因为「JIT 即时编译」导致的问题,在需要用到时它才编译。而 AOT 是预先编译,在使用前完成编译,因此 AOT 能解决预热的问题。

也可以在「基础设施层面」解决:
1.像 AWS ALB TargetGroup 以及其他云服务商的 ALB 服务,通常都可以设置 slow_start 时长,即对新加入的实例,使用一定时间慢慢地把流量切过去,最终达到预期的负载均衡状态。这个可以解决服务预热问题。
2.Envoy 也已经支持 slow_start 模式,支持在一个设置好的时间窗口内,把流量慢慢负载到新加入的实例上,达成预热效果。

4.2.HPA 扩缩容过于敏感,导致 Pod 数量震荡

通常来讲,K8s 上绝大部分负载都应该选择使用 CPU 进行扩缩容。因为 CPU 通常能很好的反映服务的负载情况

但是有些服务会存在其他影响 CPU 使用率的因素,导致使用 CPU 扩缩容变得不那么可靠,比如:

  • 有些 Java 服务堆内存设得很大,GC pause 也设得比较长,因此内存 GC 会造成 CPU 间歇性飙升,CPU 监控会有大量的尖峰。
  • 有些服务有定时任务,定时任务一运行 CPU 就涨,但是这跟服务的 QPS 是无关的
  • 有些服务可能一运行 CPU 就会立即处于一个高位状态,它可能希望使用别的业务侧指标来进行扩容,而不是 CPU.

因为上述问题存在,使用 CPU 扩缩容,就可能会造成服务频繁的扩容然后缩容,或者无限扩容。而有些服务(如我们的「推荐服务」),对「扩容」和「缩容」都是比较敏感的,每次扩缩都会造成服务可用率抖动。

对这类服务而言,HPA 有这几种调整策略:

  • 选择使用 QPS 等相对比较平滑,没有 GC 这类干扰的指标来进行扩缩容,这需要借助 KEDA 等社区组件。
  • 对 kubernetes 1.18+,可以直接使用 HPA 的 behavior.scaleDown 和 behavior.scaleUp 两个参数,控制每次扩缩容的最多 pod 数量或者比例。示例如下:

`

Original: https://www.cnblogs.com/hahaha111122222/p/16446196.html
Author: 哈喽哈喽111111
Title: 在 Kubernetes 容器集群,微服务项目最佳实践

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/547163/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球