1. 先从选型说起:Deployment 和 StatefulSet 的分界线
K8s 系列写到第九篇,前面把集群搭建、Pod、Service、网络、存储这些基础轮完一遍之后,终于该碰生产环境里最让人头疼的问题了:高可用到底怎么落?很多人一开始会把高可用等同于“多副本 + 负载均衡”,这个理解没错,但太粗了。同样一套业务,用 Deployment 能扛住流量,换成 StatefulSet 可能就是为了保住数据;反过来,有些服务压根不适合有状态部署,硬上 StatefulSet 只会给自己找麻烦。高可用首先要回答的问题,不是“怎么部署”,而是“这个服务能不能接受重启、能不能接受漂移、它的数据到底谁来管”。
这一篇我打算把 Deployment 的高可用配置深度拆开,再把 StatefulSet 从存储、网络身份到有序扩缩容完整捋一遍,最后附上一堆生产环境里真正影响用户的事故案例和排查路径。适合刚把 K8s 集群跑起来、正准备把核心业务从测试环境往生产迁移的同学,也适合那些已经上了生产、但三天两头被“Pod 起不来”“数据丢了”“升级就挂”折磨的运维和研发。很多细节不是你看一遍官方文档就能会的东西,踩过坑和没踩过坑的区别,往往就体现在这些“为什么这么配”的思路上。
先说几个和本系列开篇相关的背景。这一整套文章的场景默认是你已经用 kubeadm 或者其他方式把集群装好了,比如在 Rocky 上装 K8s 1.36 这种常见组合,控制平面、工作节点都就绪了,kubectl 也能正常访问集群。对 Docker 和 K8s 的区别还不太清楚的,可以往回翻一下前几篇,这里不再复述基础概念。我们直接从工作负载的高可用设计开始。
1.1 高可用的核心问题:你的服务真的能“缺席”吗
高可用,不是一个绝对状态,而是一组权衡。你可以在副本数量、节点分布、滚动更新的速率、存储冗余这些维度上不断加码,但每加一层都会带来成本和复杂度。真正的思路应该是:先定义“不可用多久算事故”,再反推需要什么样的架构。
举个最简单的例子,一个用户登录服务,后端是纯计算逻辑,登录态放在 Redis 里,服务本身不保存任何数据。这种服务挂了直接重启就行,重启过程中哪怕断流 30 秒,影响也有限。那它就适合用 Deployment 管理,配合多副本和滚动更新,能做到几乎无损发布。
但如果是 Redis 本身、MySQL、Elasticsearch、ZooKeeper 这类服务,它们保存了业务的“现场”,重启意味着数据恢复、主从重新同步、甚至可能丢失最近几秒的写入。这类服务的“缺席”是不能接受的,所以需要 StatefulSet 的稳定网络标识和持久化存储来兜底。先把这个分界线划清楚,后面所有配置都围绕它展开。
另一个容易被忽略的点是,高可用不只是“多副本”,还包括“副本要分布在不同的故障域里”。如果三个 Pod 全在同一台物理机上,那节点宕机就是集群级事故;如果三个 Pod 分布在三个不同机架上,交换机挂了一个还能剩两个。这一层调度策略我会在第四章专门展开。
1.2 有状态业务怎么判断:Redis、MySQL、ES、GPU 推理各有各的活法
很多人在刚学 K8s 的时候,会把 StatefulSet 理解成“数据库专用控制器”,这其实窄了。判断一个工作负载适不适合 StatefulSet,核心要看三点:它是否需要稳定的网络标识、是否需要独立的持久化存储、成员之间是否有启动顺序或发现关系。
第一类典型是 Redis Cluster。每个 Redis 实例都得有自己的数据目录,Pod 重建后还得找回原来的磁盘,实例之间要发现彼此并完成握手,这就必须靠 Headless Service 加 StatefulSet。第二类是 MySQL 主从,主库和一个从库,从库要等主库 Ready 才能开始同步,这种明确的先后顺序也只有 StatefulSet 能原生表达。第三类是 ES 集群,它有 master 节点、数据节点、协调节点的角色划分,节点数一变就得重新做分片分配,同样依赖稳定身份。
GPU 推理服务则有点特别。比如一个在线推理服务,模型文件在共享存储或者每个节点上都有一份,推理进程本身是无状态的,但它必须调度到有 GPU 的节点上,而且要保证同型号 GPU 的调度策略一致。这种服务通常用 Deployment 加资源配额就够,但如果推理服务内部需要维护会话状态、或者需要模型分片协作,那就得上 StatefulSet。所以别按“数据服务还是计算服务”来一刀切,还是按那三条标准来判断,最靠谱。
2. Deployment 进阶:把无状态服务的可用性“卷”到极致
Deployment 在很多人眼里就是个“能滚动更新的 ReplicaSet”,确实功能上就是这样,但生产环境的差距全在配置细节上。我见过不少集群,Deployment 用默认参数跑了一年没出事,一升级就故障,原因就是 maxUnavailable 默认值把可用副本数打穿了。也见过更惨的,探针配错了,Pod 显示 Running,流量一进来就 502,排了大半天才想起来就绪探针写的是“进程存在”,而不是“服务可用”。
这一章,我按生产发布时最容易出问题的几个环节挨个拆解。每个参数背后,我都会把计算逻辑和取舍说清楚,你拿去可以直接照着调。
2.1 滚动更新两个黄金参数:maxUnavailable 和 maxSurge 怎么配
滚动更新是 Deployment 默认的更新策略,但默认值不一定适合高可用场景。它的行为由两个参数控制:
| 参数 | 默认值 | 含义 |
|---|---|---|
| maxUnavailable | 25% | 滚动更新期间允许最多几个 Pod 处于不可用状态 |
| maxSurge | 25% | 滚动更新期间允许最多超出期望副本数多少个 Pod |
以 3 副本为例,默认配置下,更新时 K8s 可以先删除 1 个 Pod(25% 向上取整就是 1),等新 Pod Ready 后再往下走;maxSurge 则允许先额外拉起 1 个新 Pod,即使总数短暂变成 4 也没问题。两个参数配合起来,K8s 会在“先删旧”和“先起新”之间找一个最优节奏。
高可用场景下,我强烈建议用maxUnavailable: 0+maxSurge: 1(或者小百分比)。意思很简单:一个旧 Pod 都不能少,要更新就先多拉起一个新的,等新 Pod 通过探针确认可用,再删掉一个旧 Pod。这样可以保证整个更新过程中,服务始终有至少完整副本数的 Pod 在扛流量。
这个配置有个前提:你的副本数至少 3 个,而且每个 Pod 的启动时间不能太长。如果 Pod 启动要 5 分钟,那maxUnavailable: 0会导致更新过程非常慢,期间新 Pod 一直堵塞,旧的也删不掉。这时候就要权衡,是把 maxUnavailable 调到 1 个,还是接受更长的发布耗时。有些业务宁可让发布慢一点也不愿意掉可用性,那就是零停机发布;有些内部系统没那么敏感,就可以用maxUnavailable: 1换取更快的发布速度。
另外注意一点:这两个参数的百分比会自动向上取整,所以 2 副本的 25% 实际等于 1,4 副本的 25% 也是 1,8 副本才是 2。你别拿 4 副本配 25% 以为能容忍 1 个不可用,实际也算 1,但 2 副本配 50% 就直接等于 1,很容易计算出最小副本数的下限。经验是,核心服务就别用百分比了,直接写整数。
strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1这个配置写进 Deployment 之后,滚动更新的节奏就会变成:“先建新 Pod -> 等 Ready -> 删一个旧 Pod -> 再建下一个新 Pod”,整个过程非常稳。代价是更新期间同一时间可能有两个不同版本的 Pod 同时在服务,如果你的新旧版本完全不兼容、数据库表结构对不上,那就要考虑用蓝绿发布或者分批灰度,而不是单纯靠这两个参数。
2.2 探针与优雅终止:从“看起来正常”到“真正可用”
滚动更新做得再好,如果 Pod 内部已经坏了而 K8s 不知道,一切等于白搭。这里的“知道”靠的就是三类探针:就绪探针(readinessProbe)、存活探针(livenessProbe)和启动探针(startupProbe)。
我遇到太多人把就绪探针写成了“TCP 端口通就行”。TCP 端口能通,说明进程在监听,但不代表服务真的可以处理请求。更合理的做法是暴露一个独立的健康检查接口,比如/health/ready,在这个接口里做依赖检查——数据库连接池能不能拿到连接、缓存是否可达、必要的配置是否加载完成。只有这些全 OK,探针才返回 200,Pod 才被标记为 Ready,才会被 Endpoints 纳入流量转发。
存活探针的目的是把“活死人”杀掉重启。它不应该做太重的依赖检查,否则数据库抖动 5 秒就被误杀一批 Pod,反而制造雪崩。存活探针一般只检查自己这个进程“活着没”,比如进程内部的一个轻量锁。启动探针则是给那些冷启动特别慢的服务准备的,它会暂时接管存活探针的判断,避免“还没起来就被杀”的尴尬循环。
三个探针配合起来,我常用的示例配置是:
readinessProbe: httpGet: path: /health/ready port: 8080 initialDelaySeconds: 5 periodSeconds: 10 livenessProbe: httpGet: path: /health/live port: 8080 initialDelaySeconds: 15 periodSeconds: 15再说优雅终止。K8s 删除一个 Pod 时,会先向容器发送 SIGTERM,然后等待一个宽限期(默认 30 秒),超时后就发 SIGKILL。如果我们的服务根本不处理 SIGTERM,进程直接退出,那正处于处理中的请求就会断掉。正确做法是在应用里监听 SIGTERM,停止接收新请求、处理完存量请求再退出。可这种方式不是一天能改完的,很多历史应用也改不动。这时可以用 preStop 钩子做缓冲,让 Pod 在真正收到 SIGTERM 之前先“装死”一会儿,给负载均衡器摘除节点留出时间:
lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 10"]terminationGracePeriodSeconds也要跟着调,比如 preStop 睡了 10 秒,宽限期至少得 30 秒,不然 10 秒没睡完就被 SIGKILL 了。有了探针和优雅终止打底,滚动更新才能真正做到“用户无感、流量无损”。
2.3 版本回滚:把事故现场还原成事发前状态
再稳的发布也有翻车的时候。一旦新版本 CPU 飙升、日志疯狂报错、用户开始投诉,最要紧的事儿不是排查根因,而是先把流量切回上一个版本。K8s 的 Deployment 天然支持这个能力,但要保证回滚“回得去”,有几个前置条件你得提前埋好。
第一个条件:revisionHistoryLimit别设成 0。默认值是 10,也就是保留最近 10 次修订历史,放心用默认就行。如果你手贱把它改成 0,回滚菜单里就永远只有“当前版本”一个选项。第二个条件:发布时尽量改镜像版本,而不是改latest标签。用latest意味着新老 Pod 拉到的镜像可能是一样的,回滚根本分不清版本。第三个条件:发布后立刻用kubectl rollout status等发布结束,再观察一段时间确认稳定。如果发布过程被中断,回滚的时候可能会和未完成的滚动产生奇怪的交叉。
基本操作就三条命令:
# 查看发布历史 kubectl rollout history deployment/order-service # 回滚到上一个版本 kubectl rollout undo deployment/order-service # 回滚到指定版本 kubectl rollout undo deployment/order-service --to-revision=3回滚的底层逻辑也是触发一次滚动更新,所以 maxUnavailable、maxSurge、探针这些参数同样生效。这点很容易被忽略——有些人以为回滚是“瞬间恢复”,实际它是“再发布一次旧版本”,该走的流程一个都不会少。如果旧版本镜像本地缓存丢了,回滚过程中还需要重新拉取,网络一慢照样会卡住。
实战里的教训是:不要把回滚当成银弹。有些事故根本是配置变更引起的,比如环境变量、挂载路径、资源限制,这些倒是跟随 Deployment 的 spec 变化被保留了历史记录,可以一起回滚;但如果是数据库结构已经变更、新版本写了不兼容的数据,那回滚应用代码不等于回滚数据,流水数据是回不去的。能在发布前做好兼容性设计,永远胜于事后补救。
2.4 副本数与 PDB 的搭配,让每一次升级都“无损”
Deployment 的副本数还有一个双击陷阱:你以为设了 3 副本就安全了,但有没有想过,节点维护时 Pod 被驱逐、加上滚动更新同时在跑,可能同一时刻可用的小于 2,整个服务就扛不住流量了。解决这个问题有两个手段,一是提高副本数并合理分布,二是引入 PodDisruptionBudget(PDB)。
PDB 解决的是“自愿中断”场景,比如你执行kubectl drain给节点做维护,或者通过 cluster-autoscaler 缩容节点,K8s 会先检查 PDB 是否允许驱逐当前 Pod。如果驱逐会导致可用副本数低于阈值,驱逐就会被拒绝,直到你手动干预或者找到其他方式。
apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: order-service-pdb namespace: production spec: minAvailable: 2 selector: matchLabels: app: order-service这个配置的意思很直接:不管出于什么自愿原因,order-service 至少得有 2 个 Pod 保持可用。配合前面maxUnavailable: 0的滚动更新策略,你可以把发布和运维动作都约束在“无损”的范围内。PDB 也可以写成maxUnavailable: 1,两种写法的语义其实等价,但minAvailable对小数量的服务更直观。
补充一点:PDB 不保护非自愿中断,比如节点宕机、磁盘损坏、OOM 杀进程。它管不到这些,所以你不能只靠 PDB 做高可用,它只是“配合人做维护”的工具。真正扛住意外故障的,还是副本数、反亲和和探针这一整套体系。
副本数本身也有门道。3 副本和 2 副本的差别不只是 1 个 Pod 的数量问题:2 副本意味着任何一个节点故障,可用副本数直接掉到 1,某些极端情况下会触发资源争抢;3 副本在多数故障模型下都能保住过半可用,而且能满足 PDBminAvailable: 2的要求。多副本也多不了太多成本,核心生产服务直接 3 起步。
3. StatefulSet 全解析:网络身份、存储与顺序的艺术
Deployment 讲完,进入 StatefulSet。它是 K8s 里最复杂的工作负载控制器,没有之一。它之所以复杂,是因为它把服务编排从“一堆可互换的豆子”变成了“一组有身份的成员”。每个成员不但有自己的名字、自己的磁盘,还有自己明确的启动顺序和退出顺序。这套机制对数据库、中间件这类有状态服务来说是救命的,但对新手来说也是最容易踩坑的地方。
3.1 Headless Service:为什么必须有,它解决了什么问题
StatefulSet 创建出的 Pod 名字是固定套路:$(StatefulSet名称)-$(序号),比如redis-node-0、redis-node-1、redis-node-2。Pod 重建后名字不变,这是“稳定网络身份”的第一层含义。但光有名字还不够,应用之间要互相访问,得有一条稳定的寻址通道,这就需要 Headless Service。
普通 Service 有一个 ClusterIP,作为负载均衡入口,请求会被随机转发到某个后端 Pod。StatefulSet 的场景恰恰不需要这种随机转发——Redis 集群里,客户端要明确知道每个实例的地址和角色,而不是让 Service 随机路由,因为随机路由会破坏集群的节点发现。Headless Service 就是不分配 ClusterIP 的 Service,创建后你直接用 Pod 名访问:redis-node-0.redis-headless.namespace.svc.cluster.local。这个域名会解析到对应 Pod 的具体 IP,稳定不变。
这个设计还有个深层含义:即使 Pod 被删了、重建了、IP 变了,应用通过域名访问时,仍然会解析到新的 Pod IP。有状态服务的各成员之间通过这种域名互相发现,就能在节点迁移之后接上之前的拓扑关系。
实践中,Service 要设置clusterIP: None才叫 Headless:
apiVersion: v1 kind: Service metadata: name: redis-headless namespace: redis spec: clusterIP: None selector: app: redis ports: - port: 6379 name: redis有人会问:那我可不可以直接创建 Pod,然后自己维护端口和 IP 映射?理论上可以,但那就绕回了手工运维时代。StatefulSet 加 Headless Service 的价值在于,Pod 的创建、销毁、域名注册都是自动完成的,你不用再写脚本去同步 IP 变化。
3.2 PVC 模板与持久化:数据别丢是底线
有状态服务最大的诉求是:Pod 可以换,数据必须留。这就引出 StatefulSet 的核心特性之一——volumeClaimTemplates。它像一个 PVC 的模版,StatefulSet 每创建一个 Pod,就会自动根据这个模版创建一个独立的 PVC,然后绑定到 Pod 上。
举个例子,3 副本的 Redis 集群,StatefulSet 会自动创建 3 个 PVC,分别名为>volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 50Gi storageClassName: csi-rbd
这里几个关键点要重点提醒。第一,accessModes 用 ReadWriteOnce(RWO),表示一个 PV 同一时间只能被一个节点挂载,这是绝大多数有状态服务的正确选择。除非你的存储插件支持 ReadWriteMany(RWX),否则别乱改。第二,storageClassName 要选对。有的集群默认 StorageClass 是本地路径,不支持跨节点,Pod 漂移后数据就读不回来了。生产环境强烈建议用分布式存储,比如 Ceph RBD、云厂商的云盘、或者 NFS(NFS 性能弱一些但胜在简单)。第三,PVC 模板一旦创建,删除 StatefulSet 不会自动删除 PVC,这其实是保护机制,防止你误删数据。需要删除数据时,得手动删 PVC。
还有个细节:StatefulSet 更新时,如果镜像和存储模板都变了,PVC 一般不会重建,因为数据迁移是禁区。你要是想动态调整存储大小,得看存储插件是否支持扩容,不支持就老老实实规划好容量,别把“以后不够再加”当成常规操作。我见过太多人把 10Gi 的存储模板用到 90%,然后想扩容发现插件不支持,只能手动备份迁移,非常痛苦。
3.3 有序调度:部署、扩缩容、升级的节奏感
StatefulSet 的 Pod 不是一瞬间全部创建的,而是按序号依次创建,而且下一个 Pod 必须等到当前 PodRunning 且 Ready之后才会被创建。这个机制源自一个朴素的道理:数据库这类服务,第二个节点往往要等第一个节点就绪后才能做初始化、加入集群。比如 MySQL 从库要等主库 Ready 后开始同步,如果没有这个顺序约束,从库启动时可能根本找不到主库,直接崩溃。
删除 Pod 时,顺序是反过来的:序号最大的先删,然后依次往小删。扩缩容同样遵循这个规则。缩容前你最好确认一下要摘掉的节点是否承担了特殊角色,比如 Redis 的 master 节点是否转移了槽位、ES 的数据分片是否已经迁移完毕,不然就是真实的生产事故。
升级(rollingUpdate)时也有个很有用的参数叫partition,它代表“从哪个序号开始更新新版本”。比如partition: 2,意味着只有序号 >= 2 的 Pod 会更新到新版本,序号小于 2 的保持旧版本。这是灰度发布和 canary 的绝佳工具:你想让 Redis 集群先升级一个节点验证稳定性,其他节点按兵不动,那就设置 partition,验证通过后再逐步往 0 的方向推进。
updateStrategy: type: RollingUpdate rollingUpdate: partition: 2这个参数在日常运维中的价值被严重低估了。很多团队搞大规模数据库升级,靠的其实是不断改 partition 实现“一次一个节点”,而不是把数据库一次性全部重启。后者一旦新版本与旧协议不兼容,整个集群都会原地爆炸。
3.4 实战场景:跑起一个高可用的 Redis 集群
把前面的理论串起来,用一个 3 节点 Redis 集群的示例收尾这一章。这个示例虽然只做演示,但结构可以直接抄到生产里改一改就能用。
整体结构是:Headless Service 负责集群节点域名发现,StatefulSet 管 3 个 Redis 实例,每个实例一块 PVC 持久化数据,Pod 之间通过反亲和尽量打散到不同节点。简单版本可以这样写:
apiVersion: apps/v1 kind: StatefulSet metadata: name: redis-node namespace: redis spec: serviceName: redis-headless replicas: 3 selector: matchLabels: app: redis template: metadata: labels: app: redis spec: terminationGracePeriodSeconds: 30 affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchExpressions: - key: app operator: In values: ["redis"] topologyKey: kubernetes.io/hostname containers: - name: redis image: redis:7.2-alpine command: ["redis-server"] args: ["--appendonly", "yes", "--cluster-enabled", "yes"] ports: - containerPort: 6379 name: redis volumeMounts: - name: data mountPath: /data volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 10Gi storageClassName: standard创建完成后,你会看到三个 Pod 依次启动:redis-node-0先 Ready,再创建redis-node-1,最后redis-node-2。用kubectl exec进任意一个 Pod,执行 Redis 集群初始化命令,让它通过域名发现其余节点。整个过程,Pod 之间的身份是稳定的,数据是持久的,顺序是可控的,这才能叫高可用。
实际生产里你还要考虑密码认证、持久化策略、内存 maxmemory 设置、节点数规划等一堆参数,这些已经超出控制器本身的范畴,但掌握 StatefulSet 的基本逻辑之后,往里面填充这些配置都会很顺手。
4. 高可用设计的最后一公里:把 Pod 安排对
控制器层面的配置做得再完善,如果 Pod 的物理分布一塌糊涂,高可用依然是一句空话。这一章专门聊调度,包括反亲和性、拓扑分布、资源隔离,以及 GPU 这类特殊资源的高可用思路。
4.1 反亲和与拓扑分布:别把鸡蛋放一个篮子里
亲和性的本质是“给调度器提供约束和偏好”。正亲和(affinity)表达的是“尽量/必须调度到某些节点”,反亲和(anti-affinity)表达的是“尽量/必须避开某些 Pod”。高可用场景里,反亲和用的频率远高于正亲和——我们要的就是让同一服务的副本尽量散开,别互相挤在一台机器上。
打法有两种:第一,声明式的 Pod 反亲和,如上节 Redis 示例中的podAntiAffinity。它告诉调度器,带有app=redis这个标签的 Pod,尽量别和另一个带有相同标签的 Pod 调度到同一个hostname上。topologyKey是灵魂,你可以按节点(hostname)、按可用区(zone)、按机架(rack)来控制散开的粒度。
第二,用topologySpreadConstraints(拓扑分布约束)做更精细的“名额分配”。它能让 Pod 在不同拓扑域之间实现更均衡的分布。比如你有 3 个可用区,每个区 1 台节点,要部署 6 个副本,它可以做到每个节点 2 个 Pod,而不是某个节点 4 个、另一个节点 0 个。
topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: order-service这里的maxSkew: 1表示各节点上 Pod 数量之差最多为 1,DoNotSchedule表示不满足就不调度。如果你的环境节点不均匀、某台机器资源太满,强制DoNotSchedule可能导致 Pod 长期 Pending。这时可以改成ScheduleAnyway放软约束,或者在反亲和里用preferred而不是required,给自己留点灵活性。
4.2 Namespace 与资源隔离:防止“邻居”互相伤害
多团队共用集群是一个很常见的现状,比如你在一个集群里同时跑订单、用户、日志等多个业务。这种共享模式会带来一个致命问题:资源争抢。某个业务突然流量暴涨,如果没做限制,它会拖垮整个集群的其他业务。这也是很多生产事故的根源。
解决方案就是 Namespace 加 ResourceQuota。Namespace 不只是逻辑隔离,它还可以承载资源配额策略。每个业务一个 Namespace,每个 Namespace 设置 CPU、内存、PVC 数量和对象数量的上限。这样单个业务再怎么失控,也无法超过配额,集群其他部分不会跟着遭殃。
ResourceQuota 的配置例子:
apiVersion: v1 kind: ResourceQuota metadata: name: order-quota namespace: production spec: hard: requests.cpu: "8" requests.memory: 16Gi limits.cpu: "16" limits.memory: 32Gi persistentvolumeclaims: "10"配合 LimitRange 给单个 Pod 的默认请求和限制兜底,防止有人把 Pod 不声明资源就扔上来。这些措施听上去基础,但绝大多数事故复盘到最后,都能看到“谁都可以创建不限额的工作负载”这个小洞。
多 Namespace 还有一层价值:网关策略、网络策略(NetworkPolicy)、RBAC 权限都可以按 Namespace 做细分。生产环境建议把团队权限控制到 Namespace 级别,别给全局权限,否则误操作面太大。很多安全整改要求“最小权限”,在 K8s 里,从 Namespace 权限限制开始是最容易落地的第一步。
4.3 GPU 等特殊资源调度的高可用思路
GPU 调用在 K8s 里是个专门话题。标题里的热词提到了“k8s调用gpu”,实际生产里常见的场景包括模型推理、训练任务、视频处理。GPU 调度有几个特点:GPU 节点数量少、GPU 不能像 CPU 那样随意分片、GPU 型号驱动直接影响可用性。
要在 K8s 里用 GPU,先得装上设备插件,比如 NVIDIA 的 device plugin。装好之后,节点就有了nvidia.com/gpu这个可分配资源,你才能在 Pod 里声明:
resources: limits: nvidia.com/gpu: 1 requests: nvidia.com/gpu: 1GPU 的高可用区别于 CPU 服务的地方在于:GPU 节点通常只有几台,你没法盲目堆副本数。如果只有一个 GPU 节点,那你的推理服务的可用性天然受限于这个节点。所以生产级方案一般会做几件事:一是用节点亲和把推理 Pod 绑定到带 GPU 的节点组;二是给节点组配置 GPU 驱动和镜像的标准化,防止换节点后环境不一致;三是在多 GPU 节点场景用 Pod 反亲和让推理副本散开。
还有一种常见组合是“模型文件放共享存储 + 推理实例用 Deployment”。这样副本可以随时扩缩容,任何一节点崩溃,新 Pod 只要调度到另一台带相同 GPU 型号的机器就行。如果推理服务需要“模型分片”或者“多卡并行”,那就和其他有状态服务一样考虑 StatefulSet。判断逻辑还是回到第一章那三条标准:稳定身份、持久化、成员发现。
5. 常见故障排查实录:从初始化到运行期的坑
最后这一章我打算多花点笔墨,因为很多读者问我的问题都不是“原理”层面的,而是“命令跑完报错了怎么办”。这里挑几个真实的高频故障,逐个拆解。
5.1 初始化报错:the api server is not healthy after 4m0.00747357s
这个话题非常高频,基本搜“k8s 安装”就能看到一堆提问:用 kubeadm 初始化控制节点,卡了几分钟之后,报错类似the api server is not healthy after 4m0.00747357s。这个错误的字面意思是 kubeadm 在等待 apiserver 就绪,但等待了约 4 分钟还没等到。
为什么 apiserver 起不来?我按出现频率从高到低整理排查顺序:
第一,容器运行时和 Kubelet 的 cgroup 驱动不一致。这是最常见的原因。containerd 默认或者配置成 systemd,而 kubelet 用 systemd,两边一致才稳。如果你用 Docker 作为运行时,也要检查 Docker 的 cgroup driver 是不是 cgroupfs,跟 Kubelet 的 systemd 不匹配,节点就会一直不健康。解决办法是修改 containerd 配置里的SystemdCgroup = true,然后重启 containerd。
第二,核心镜像拉不下来。apiserver 启动依赖registry.k8s.io上的一堆镜像,国内网络环境下经常超时。检查方法:
kubeadm config images list kubectl get pods -n kube-system如果 Pod 卡在 ImagePullBackOff,说明就是镜像拉取问题,去配置镜像源或者手动预拉镜像。
第三,kubelet 本身异常。先看 kubelet 日志:
journalctl -u kubelet -f如果是“failed to get sandbox image”或者和 DNS 相关的错误,多半是沙箱镜像有问题,或者系统 DNS 配置(比如 systemd-resolved 的/etc/resolv.conf)干扰了集群内 DNS。把 kubelet 的--resolv-conf指到正确的配置文件,能解掉一大批古怪问题。
第四,etcd 端口或证书不通。apiserver 要连 etcd 才能完成初始化,如果 2379 端口被防火墙拦了,或者证书过期,也会一直是 unhealthy。检查:
kubectl logs -n kube-system etcd-<node-name>总的来说,这个报错的本质是 apiserver 没能在规定时间进入健康状态,不要被 4 分钟这个数字吓到,耐心看 kube-system 里的 Pod 状态和日志,90% 的答案都在那里。踩过坑之后,我现在在初始化前都会先做一轮环境预检:Kubelet 是否运行、镜像是否拉全、cgroup 配置是否一致、DNS 和防火墙是否正常。预检做到位,基本能把这个错误挡在门外。
5.2 生产环境常见的用户影响故障:Evicted、OOM、ImagePull 失败
进入运行期,真正让用户感到故障的事件往往集中在几类。第一类是 Evicted(驱逐)。节点磁盘空间不足、内存压力过高时,kubelet 会按优先级驱逐 Pod。被驱逐的 Pod 进入“Evicted”状态,然后被 Deployment/StatefulSet 重新拉起。表面上看服务能恢复,但频繁驱逐说明节点资源严重过载,用户体验依然很差。
排查 Evicted,先看节点的压力指标:
kubectl describe node <node-name>关注MemoryPressure、DiskPressure、PIDPressure这几个 Condition。如果节点 DiskPressure,多半是镜像占满磁盘、日志没有清理、或者 PVC 空间爆了。解决思路是设置合理的日志轮转、及时清理无用镜像、给存储卷做容量规划。Pod 级别则要确保 requests 和 limits 都写了,别让节点内存被打爆。
第二类是 OOMKilled。Pod 反复出现OOMKilled,八成是 limits.memory 设得比实际需求低,或者是 Java、Python 这类应用堆内存配置和容器 limit 不匹配。处理方式不是只调大 limit,还要分析这个服务真实需要多少内存,比如通过监控看到 Rss 一直在涨,优先优化应用,其次是调整 limits。盲改 limit 的结果,可能就是节点内存压力更大,回头又引发 Evicted。
第三类 ImagePull 失败。常见的表现是 Pod 卡在ImagePullBackOff。原因无非几种:镜像 tag 不存在、镜像仓库需要认证(没配置 imagePullSecret)、网络通不到仓库、或者仓库限流。排查命令:
kubectl describe pod <pod-name> | grep -A10 Events如果是因为仓库认证,记得在 Namespace 里创建imagePullSecret并挂到 ServiceAccount 上,省得每个 Pod 手动指定。
还有一个隐蔽问题:Pod 重启了但整体没异常,同时用户反馈“刚才断了几秒”。这往往是因为存活探针误杀导致的。探针配置太严格,一次超时就杀掉重启,整个 Pod 消失再建,期间流量全断。碰上这种,先看kubectl get events里是不是有「Liveness probe failed」,然后把阈值放宽,或者换成启动探针来容错。
5.3 排查思路速查:事件、日志、状态三件套
最后分享一个我一直在用的排查路径,基本能覆盖 80% 的疑难杂症。按顺序执行,别跳步。
第一步,看事件。无论是 Deployment 还是 Pod,只要状态不对,先用 describe 看 Events:
kubectl describe pod <pod-name> kubectl describe deployment <deployment-name>Events 里会直接告诉你“为什么没调度”“为什么拉不到镜像”“为什么探针失败”,这是最快的定位入口。
第二步,看日志。确认 Pod 在跑但行为异常时,就需要进容器看日志:
kubectl logs -f <pod-name> -n <namespace>Pod 崩溃多次的,可以看上一个实例的日志:
kubectl logs -f <pod-name> -n <namespace> -p多个容器的情况记得加容器名参数:-c container-name。
第三步,看状态。如果 Pod 显示Running,但Ready是 0/1,问题基本在就绪探针。这时回到 describe,重点看 Liveness、Readiness 那一节的配置和当前状态。如果显示Pending,基本都是调度或存储问题,看 Events 里的FailedScheduling、FailedAttachVolume、PVC not found这几个关键字。
第四步,上实时事件流。如果故障是偶发的,用下面的命令持续观察一段时间:
kubectl get events -n <namespace> --watch再配合 Prometheus 监控看资源水位和 QPS,很多玄学问题最后都能落到一个具体的告警曲线转折点。
这四步走完,如果还没定位,就要往底层挖:查看 kubelet 日志、容器运行时日志、存储插件日志。到这一层往往已经不是 K8s 配置问题,而是节点或存储后端的问题。《K8s 权威指南》这类书读起来能补原理,但真到生产上,最靠得住的还是这套事件、日志、状态三板斧,加上你对自己的业务特征足够熟悉。
这一篇从 Deployment 的高可用参数一路讲到了 StatefulSet 的存储身份,再到调度和故障排查,信息量确实不小。我个人这几年最大的感受是:高可用不是某一个参数灵光一闪就能实现的,它是一整套“预期管理”的组合拳。每次配置改动之前,都先问一句“某个节点挂了会怎样、版本升级会怎样、存储坏了会怎样”,多想几层,生产环境会少很多深夜的告警电话。