过去三年我们运维着一个跑在自建 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-reservedsystem-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:打散副本

真正用来"打散副本"的是 podAntiAffinitytopologySpreadConstraints。前者强制或尽量让同服务的 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 之外,真正决定爆炸半径的是 maxSurgemaxUnavailable 两个参数。

  • 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

核对清单(建议存档)

把全文浓缩成一份每次发版前可以勾选的清单:

  1. 每个容器是否都声明了 requests 和 limits?
  2. 核心服务的 QoS 是否为 Guaranteed?requests 是否按真实 P99 设置?
  3. 节点的 eviction-hard 和 kube-reserved 是否已按机型调整?
  4. 多副本服务是否配置了 topologySpreadConstraints 跨节点/跨可用区打散?
  5. 专用节点是否已打 taint,对应工作负载是否声明了 tolerations?
  6. 是否配置了 readinessProbe 和 livenessProbe,且两者检查内容不同?启动慢的服务是否加了 startupProbe?
  7. 核心服务的滚动更新是否设了 maxUnavailable: 0?
  8. 是否为每个服务配置了 PodDisruptionBudget?
  9. securityContext 是否开启了 runAsNonRoot、丢弃了 capabilities、根文件系统只读?
  10. revisionHistoryLimit 是否保留足够历史版本以便快速回滚?

这份清单不是一次配完就束之高阁。我们每季度会基于近期的故障复盘更新它——新加的项往往是上一季度某个线上事故的直接成因。把这些用血的教训换来的配置固化成模板和准入校验(我们用 Kyverno 在 admission 阶段强制拦截不合规的工作负载),才能让"生产可用"这件事不至于只依赖某个工程师的记性。下一篇我们会聊聊怎么给这套部署体系加上可观测性,让运行中的工作负载真正"看得见"。