过去三年我们运维着一个跑在自建 Kubernetes 集群上的中型业务:4 个集群、1200 多个节点、运行着 3000 多个 Pod。踩过节点资源被打爆导致雪崩、踩过滚动更新把所有副本同时干掉、也踩过没有健康检查导致"假活"Pod 持续接流量。这些事故最后都沉淀成了同一份东西——一份每次上线都要逐项核对的部署检查清单。
这篇文章把这份清单拆成六个维度:资源治理、调度策略、健康检查、滚动更新、可用性兜底、安全上下文。每一项都配上我们在生产里真实使用过的配置和踩坑原因。如果你正在准备把一个服务搬到 K8s,或者想给现有工作负载做一次体检,可以直接照着核对。
一、资源治理:requests 与 limits 必须成对出现
新手上 K8s 最常见的错误就是不设 resources,或者只设 limits 不设 requests。后果很严重:调度器在没有 requests 的情况下会以为 Pod 不占资源,把它塞到已经满载的节点上;而只设 limits 不设 requests 时,Pod 的 QoS 等级会直接降到 Burstable,在节点资源紧张时第一个被驱逐。
生产环境的铁律是:CPU 和 memory 的 requests、limits 都要显式声明,并且 requests 要能代表稳态真实消耗。我们用过去 7 天的 P99 容器指标来定 requests,用 P99.9 再上浮 30% 作为 limits,这样既不会因为 requests 虚高浪费资源,也能扛住偶发峰值。
resources:
requests:
cpu: "500m" # 0.5 核,按 P99 设定
memory: "512Mi"
limits:
cpu: "1500m" # 1.5 核,留 3 倍突发空间
memory: "1Gi" # 内存 limit 不留太多余量,OOM 比雪崩好定位
这里有几个细节值得注意:
- CPU 是可压缩资源(compressible),超限只会被限流(throttle),不会杀进程;内存是不可压缩资源,超限会直接 OOMKilled。所以内存 limit 要给得保守一些,宁可让它早点 OOM 触发重启,也别让它吃光节点内存把整个节点拖垮。
- 单位用
m(millicore)表示千分之一核,用Mi/Gi(二进制)而不是M/G(十进制),后者在内存计算上会有几 MB 的偏差。 - 有 Java/JVM 服务时,务必让
-Xmx严格小于 memory limit 的 70%,给堆外内存、元空间、线程栈预留空间,否则会频繁 OOMKilled。
QoS 等级:Guaranteed / Burstable / BestEffort
Kubelet 会根据 resources 配置自动给每个 Pod 打上一个 QoS(Quality of Service)等级。这个等级直接决定了节点内存不足时谁先被驱逐(OOM 评分 /proc/<pid>/oom_score_adj 就是按 QoS 算的)。
| QoS 等级 | 判定条件 | 驱逐优先级 | 适用场景 |
|---|---|---|---|
| Guaranteed | 每个容器的 requests == limits(CPU 和 memory 都要满足) | 最低(最后被驱逐) | 核心在线服务、数据库、有状态应用 |
| Burstable | 至少有一个容器声明了 request,但不满足 Guaranteed | 中等 | 批处理任务、有突发流量的 Web 服务 |
| BestEffort | 完全没有声明 resources | 最高(最先被驱逐) | 几乎不应该出现在生产环境 |
我们的策略是:在线入口服务(网关、API)一律做成 Guaranteed;后台消费型服务(消费者、worker)做 Burstable,给足突发空间;BestEffort 在生产环境是明令禁止的。用这条命令可以快速查看集群里所有 Pod 的 QoS 分布:
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{.status.qosClass}{"\n"}{end}'
节点资源驱逐:留出系统缓冲
光设好 requests 还不够。Kubelet 通过 --eviction-hard 在节点内存/磁盘吃紧时主动驱逐 Pod 来保护节点。默认配置是 memory.available<100Mi,对大内存节点来说这个阈值太晚触发,往往已经出现内核 OOM 才开始驱逐。我们在每个节点上都调高了水位线:
# KubeletConfiguration 片段(通过 kubeadm 或 /etc/kubernetes/kubelet.conf 下发)
evictionHard:
memory.available: "500Mi" # 可用内存低于 500Mi 开始驱逐
nodefs.available: "10%"
nodefs.inodesFree: "5%"
imagefs.available: "15%"
evictionSoft:
memory.available: "800Mi" # 软阈值,给一段优雅期
evictionSoftGracePeriod:
memory.available: "1m30s"
evictionMaxPodGracePeriod: 30
另外还有一个容易忽略的参数 kube-reserved 和 system-reserved,它们会把 Kubelet 自身和系统守护进程占用的资源从可分配额度里扣掉,避免 kubelet 和 sshd 跟业务 Pod 抢内存。
二、调度策略:让副本散开,别堆在一个节点
默认调度器只看"资源够不够",但生产环境里我们更关心"副本之间会不会互相影响"。一个典型事故:某个服务的 3 个副本恰好全调度到同一个节点上,该节点硬件故障后服务直接 0 实例可用。下面的几项配置就是用来回答这个问题的。
nodeSelector 与 nodeAffinity:圈定目标节点
nodeSelector 是最简单的硬约束,匹配 node label。我们通常用 requiredDuringSchedulingIgnoredDuringExecution(硬约束)和 preferredDuringSchedulingIgnoredDuringExecution(软约束,带权重)组合使用:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-pool
operator: In
values: ["compute-optimized"]
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values: ["cn-east-1a"]
podAntiAffinity 与 topologySpreadConstraints:打散副本
真正用来"打散副本"的是 podAntiAffinity 和 topologySpreadConstraints。前者强制或尽量让同服务的 Pod 不落在同一拓扑域,后者控制各域之间的 Pod 数量偏差。
我们对所有多副本服务都强制加 topologySpreadConstraints,按 kubernetes.io/hostname 打散到不同节点,按 topology.kubernetes.io/zone 打散到不同可用区:
topologySpreadConstraints:
- maxSkew: 1 # 各拓扑域之间 Pod 数差值不超过 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule # 硬约束:打散不了就调度失败
labelSelector:
matchLabels:
app: payment-service
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway # 软约束:跨可用区尽量均衡
labelSelector:
matchLabels:
app: payment-service
提示:跨可用区打散能让服务在单可用区整体故障时仍存活,但要注意跨可用区调用会引入额外网络延迟,对延迟敏感的服务可以只做节点级打散(hostname),不做可用区级打散。
tolerations 与 taints:独占专用节点
对于 GPU 节点、内存优化型节点这类特殊资源池,我们会给节点打 taint,只有显式声明 tolerations 的 Pod 才能调度上去,避免普通业务误占:
# 给 GPU 节点打 taint
kubectl taint nodes gpu-node-01 nvidia.com/gpu=:NoSchedule
# 工作负载声明 toleration 才能上去
tolerations:
- key: "nvidia.com/gpu"
operator: "Exists"
effect: "NoSchedule"
三、健康检查:三种探针各司其职
健康检查是被忽略最多的项。没配探针的服务,进程假死(比如线程死锁、连接池耗尽)后还会一直接流量,故障扩散。K8s 提供三种探针,用途完全不同,配错就会引发新问题。
| 探针 | 作用 | 失败后果 | 何时配置 |
|---|---|---|---|
| startupProbe | 判断容器是否已完成启动 | 启动期间不会触发 liveness/readiness | 启动慢的服务(JVM、大型框架预热)必配 |
| readinessProbe | 判断容器是否准备好接流量 | 从 Service Endpoints 中摘除,不接新请求 | 几乎所有在线服务必配 |
| livenessProbe | 判断容器是否还在正常运行 | 重启容器(restartPolicy 生效) | 需要自愈的服务配,但要谨慎 |
这里有个反复被踩的坑:把 livenessProbe 和 readinessProbe 配成完全一样的端点和参数。一旦下游依赖(比如数据库)短暂抖动,健康检查接口返回非 200,livenessProbe 立刻判定失败触发重启,结果是大规模连环重启,把偶发故障放大成雪崩。正确做法是:
- readinessProbe 可以检查下游依赖(数据库、缓存连通性),失败只摘流量,不重启。
- livenessProbe 只检查进程本身是否僵死,不应该检查外部依赖,失败阈值要设得宽松(比如
failureThreshold: 6)。 - 启动慢的服务用
startupProbe,让它在启动阶段豁免 liveness 检查,避免还没起来就被重启。
livenessProbe:
httpGet:
path: /healthz/live # 只检查进程存活
port: 8080
periodSeconds: 10
failureThreshold: 6 # 连续失败 60s 才重启,给足恢复时间
timeoutSeconds: 3
readinessProbe:
httpGet:
path: /healthz/ready # 可检查依赖连通性
port: 8080
periodSeconds: 5
failureThreshold: 3 # 失败 15s 就摘流量,快速止损
timeoutSeconds: 2
startupProbe:
httpGet:
path: /healthz/live
port: 8080
periodSeconds: 10
failureThreshold: 30 # 最长容忍 5 分钟启动时间
四、滚动更新:先稳后切,控制爆炸半径
Deployment 的默认滚动更新策略 RollingUpdate 之外,真正决定爆炸半径的是 maxSurge 和 maxUnavailable 两个参数。
maxSurge:滚动过程中允许超出 replicas 的最大 Pod 数(可为百分比)。maxUnavailable:滚动过程中允许不可用的 Pod 数。
对于核心服务,我们永远把 maxUnavailable: 0,意思是更新过程中始终保持满副本可用,只能通过先扩再缩的方式滚动;maxSurge 设为 25% 控制突发扩容量。这样即使新版本镜像有问题,也不会让可用副本数下降。
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0 # 核心服务绝不允许减少可用副本
revisionHistoryLimit: 10 # 保留 10 个历史版本,便于回滚
progressDeadlineSeconds: 600 # 10 分钟没就绪判定为发布失败
对于有外部健康检查需求的服务(比如需要在 LB 层做健康检查通过后再让 K8s 摘流量),可以配合 readinessGates 注入自定义的就绪条件。我们接入自研的金丝雀分析平台时就是靠它把外部探针结果反馈给 K8s 调度器:
readinessGates:
- conditionType: "canary.platform.example/ready"
# 外部 controller 持续更新 Pod 的 readinessGates status
五、可用性兜底:PodDisruptionBudget
前面讲的都是"节点宕机、Pod 被驱逐"这种非自愿中断(Involuntary Disruption)的防护。还有一类是自愿中断(Voluntary Disruption):管理员 kubectl drain 节点、集群自动缩容、节点升级。这类操作如果一次性驱逐太多副本,同样会造成服务中断。
PodDisruptionBudget(PDB)就是给自愿中断设的护栏。下面这个 PDB 保证 payment-service 在任何时候至少保留 2 个可用副本:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: payment-service-pdb
namespace: prod
spec:
minAvailable: 2 # 或者用 maxUnavailable: 1
selector:
matchLabels:
app: payment-service
配置后,kubectl drain 时如果驱逐会导致可用副本低于 2,K8s 会拒绝驱逐并提示 Cannot evict pod as it would violate the pod's disruption budget,drain 操作会等待直到有新副本就绪。这是节点维护阶段最容易忽略的一道防线。
六、安全上下文:默认非 root,收紧权限
最后是安全加固。容器默认以 root 运行,一旦镜像有 RCE 漏洞,攻击者直接拿到 root 权限。生产环境应默认开启 runAsNonRoot,并配合 Pod Security Standards 的 restricted 级别:
securityContext:
runAsNonRoot: true # 禁止以 root 运行
runAsUser: 1000 # 指定非 0 的 UID
runAsGroup: 2000
fsGroup: 2000 # 挂载卷的文件属组
seccompProfile:
type: RuntimeDefault # 启用默认 seccomp profile,限制系统调用
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false # 禁止提权
readOnlyRootFilesystem: true # 根文件系统只读
capabilities:
drop: ["ALL"] # 丢弃所有 Linux capabilities
提示:
readOnlyRootFilesystem: true之后,所有写入操作(日志、临时文件、上传)都必须挂载到 emptyDir 或 PVC,否则容器一启动就崩溃。我们用 init 容器统一创建好/tmp、/app/logs这类目录。
七、一份完整的工作负载 YAML
把前面六节的内容合到一起,下面是我们线上一个核心服务的 Deployment 模板(已脱敏),每次新建服务都基于它做裁剪:
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-service
namespace: prod
labels:
app: payment-service
spec:
replicas: 3
revisionHistoryLimit: 10
progressDeadlineSeconds: 600
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0
selector:
matchLabels:
app: payment-service
template:
metadata:
labels:
app: payment-service
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 2000
fsGroup: 2000
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: payment-service
containers:
- name: app
image: registry.example.com/payment-service:v1.8.2
ports:
- containerPort: 8080
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "1500m"
memory: "1Gi"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
livenessProbe:
httpGet: { path: /healthz/live, port: 8080 }
periodSeconds: 10
failureThreshold: 6
timeoutSeconds: 3
readinessProbe:
httpGet: { path: /healthz/ready, port: 8080 }
periodSeconds: 5
failureThreshold: 3
timeoutSeconds: 2
startupProbe:
httpGet: { path: /healthz/live, port: 8080 }
periodSeconds: 10
failureThreshold: 30
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: payment-service-pdb
namespace: prod
spec:
minAvailable: 2
selector:
matchLabels:
app: payment-service
核对清单(建议存档)
把全文浓缩成一份每次发版前可以勾选的清单:
- 每个容器是否都声明了 requests 和 limits?
- 核心服务的 QoS 是否为 Guaranteed?requests 是否按真实 P99 设置?
- 节点的 eviction-hard 和 kube-reserved 是否已按机型调整?
- 多副本服务是否配置了 topologySpreadConstraints 跨节点/跨可用区打散?
- 专用节点是否已打 taint,对应工作负载是否声明了 tolerations?
- 是否配置了 readinessProbe 和 livenessProbe,且两者检查内容不同?启动慢的服务是否加了 startupProbe?
- 核心服务的滚动更新是否设了 maxUnavailable: 0?
- 是否为每个服务配置了 PodDisruptionBudget?
- securityContext 是否开启了 runAsNonRoot、丢弃了 capabilities、根文件系统只读?
- revisionHistoryLimit 是否保留足够历史版本以便快速回滚?
这份清单不是一次配完就束之高阁。我们每季度会基于近期的故障复盘更新它——新加的项往往是上一季度某个线上事故的直接成因。把这些用血的教训换来的配置固化成模板和准入校验(我们用 Kyverno 在 admission 阶段强制拦截不合规的工作负载),才能让"生产可用"这件事不至于只依赖某个工程师的记性。下一篇我们会聊聊怎么给这套部署体系加上可观测性,让运行中的工作负载真正"看得见"。