本文主要介绍我个人在使用 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/
转载文章受原作者版权保护。转载请注明原作者出处!