从单体到高可用:基于 Kubernetes 的中型业务集群架构设计与故障切换实战
很多团队从单体应用走向 Kubernetes,并不是因为“想上云原生”,而是因为业务已经被现实推着走了:发布窗口越来越短、单机扛不住流量、某个节点一挂整站受影响、运维值班越来越焦虑。
我见过不少中型业务的典型演进路径:
- 一开始是单体应用 + 单台 MySQL
- 然后变成 Nginx + 多台应用机器
- 再往后引入容器和 CI/CD
- 最后进入 Kubernetes,但发现“能跑”和“高可用”之间,差了不止一个 Deployment
这篇文章不讲大而全的平台建设,而是聚焦一个更实际的问题:中型业务如何基于 Kubernetes 设计一个可落地的高可用架构,并在故障发生时完成快速切换与排障。
背景与问题
典型业务场景
假设我们有一个中型业务系统,包含以下组件:
- Web/API 服务
- 订单服务
- 用户服务
- Redis 缓存
- MySQL 主从或云数据库
- Ingress 网关
- 日志与监控系统
业务特征一般是:
- 日常并发中等,活动期间会有 3~5 倍突增
- 允许秒级抖动,但不能长时间不可用
- 发布频率较高,希望做到滚动升级
- 有多服务依赖,某个组件故障可能引发连锁反应
从单体迁移后,为什么还是不稳定?
很多人把应用容器化后,就默认“已经高可用了”。实际并不是。
常见问题包括:
-
Pod 多副本了,但流量入口单点
- Ingress Controller 只有 1 个副本
- Service 正常,入口挂了还是全挂
-
副本数够了,但调度不分散
- 3 个 Pod 全跑在同一个节点
- 节点宕机后,业务还是整体不可用
-
有探针,但探针配置不合理
- 启动慢的服务被 livenessProbe 反复杀死
- readinessProbe 过早通过,导致请求打到未初始化完成的实例
-
数据库没问题,连接池先崩了
- 突发流量下应用线程堆积
- 连接数耗尽,看起来像“数据库挂了”
-
故障切换设计有了,但缺少演练
- 文档写着“自动切换”
- 真出故障时,恢复时间依然很长
所以这篇文章换个角度来讲:不是先讲 K8s 有哪些组件,而是从故障和排障路径反推架构设计。
核心原理
高可用不是单一能力,而是几层能力叠加出来的:
- 冗余:实例、节点、入口、依赖都不能单点
- 隔离:故障不能轻易扩散
- 探测:系统能知道“谁坏了”
- 切换:坏了之后能自动移走流量或重建实例
- 恢复:恢复过程足够快,且不会造成二次伤害
一张总览图:中型业务集群推荐形态
flowchart TB
U[用户请求] --> LB[四层负载均衡/LB]
LB --> ING1[Ingress Controller A]
LB --> ING2[Ingress Controller B]
ING1 --> SVC1[API Service]
ING2 --> SVC1
SVC1 --> POD1[api-pod-1]
SVC1 --> POD2[api-pod-2]
SVC1 --> POD3[api-pod-3]
POD1 --> REDIS[(Redis Sentinel/托管 Redis)]
POD2 --> REDIS
POD3 --> REDIS
POD1 --> MYSQL[(MySQL 主从/云数据库)]
POD2 --> MYSQL
POD3 --> MYSQL
subgraph K8S[Kubernetes Cluster]
NODE1[Node-1]
NODE2[Node-2]
NODE3[Node-3]
end
POD1 -.调度.-> NODE1
POD2 -.调度.-> NODE2
POD3 -.调度.-> NODE3
故障切换的关键链路
在 Kubernetes 里,应用从“发现故障”到“恢复服务”,大致经过这条链路:
- 容器异常或节点异常
- 健康检查失败
- Pod 从 Service Endpoints 中摘除
- 流量停止转发到异常实例
- Deployment/ReplicaSet 拉起新 Pod
- 新 Pod readiness 成功后重新接流量
这条链路里,readinessProbe 决定流量切换,livenessProbe 决定是否重启,调度策略决定副本是否真的抗故障。
故障切换时序图
sequenceDiagram
participant User as 用户
participant LB as 负载均衡
participant Ingress as Ingress
participant Service as Service
participant PodA as Pod A
participant Kubelet as Kubelet
participant RS as ReplicaSet
User->>LB: 发起请求
LB->>Ingress: 转发流量
Ingress->>Service: 路由到后端
Service->>PodA: 请求进入实例
Note over PodA: 实例卡死/依赖异常
Kubelet->>PodA: 健康检查
PodA-->>Kubelet: readiness/liveness 失败
Kubelet->>Service: 将 Pod A 从可用端点摘除
Service-->>Ingress: 更新后端列表
Ingress-->>LB: 后端减少
RS->>Kubelet: 创建新 Pod
Kubelet->>RS: 新 Pod 就绪
Service->>Ingress: 新 Pod 加入流量池
User->>LB: 后续请求命中新实例
中型业务的架构设计重点
对于中型业务,我更建议优先做好以下 5 件事:
1)入口高可用
至少两副本 Ingress Controller,并保证前面有可用的 LB。
如果是自建机房,要重点确认 VIP、Keepalived 或 BGP 方案是否存在隐性单点。
2)应用层多副本 + 反亲和
不是简单把 replicas 改成 3,而是让副本尽量分布到不同节点。
3)探针分层设计
startupProbe:解决慢启动readinessProbe:决定接不接流量livenessProbe:判断是否需要重启
这三个不要混用。我当时踩过一个坑:把数据库连通性直接放进 livenessProbe,结果 DB 短暂抖动时,整个应用层被连续重启,故障反而扩大。
4)PodDisruptionBudget 和滚动发布
避免运维操作或节点升级时,同时干掉过多副本。
5)外部依赖的高可用边界
Kubernetes 只能解决应用编排问题,不能自动让数据库变高可用。
如果 MySQL、Redis 还是单点,那么你只是把单点从应用层转移到了数据层。
现象复现:一个常见故障是怎么发生的
我们先复现一个中型业务中很典型的故障:某节点宕机,API 服务大量 502/超时。
触发条件
- API 服务 3 个副本
- 但没有配置反亲和
- 调度结果是 2 个副本在 Node-1,1 个副本在 Node-2
- Node-1 宕机后,瞬间损失 2/3 容量
- 剩余实例扛不住流量,出现排队和超时
状态图
stateDiagram-v2
[*] --> 正常运行
正常运行 --> 节点故障: Node-1 宕机
节点故障 --> 容量骤降: 丢失多个 Pod
容量骤降 --> 请求堆积
请求堆积 --> 超时与502
超时与502 --> 自动重建Pod
自动重建Pod --> 新实例预热中
新实例预热中 --> 服务恢复
服务恢复 --> [*]
这类故障为什么“看起来像网络问题”?
因为用户视角是:
- 页面转圈
- 接口偶发超时
- Nginx/Ingress 返回 502/504
但真正根因可能是:
- 调度不均衡
- CPU limit 过低
- readiness 检查不合理
- HPA 触发太慢
- 连接池配置不匹配
这也是为什么排障不能只盯着 Ingress 日志。
实战代码(可运行)
下面给一套适合中型业务起步的可运行示例,包含:
- 一个 API Deployment
- Service
- Ingress
- PodDisruptionBudget
- HPA
- 反亲和与拓扑分布
- 健康检查
说明:示例镜像使用
nginx模拟业务服务,真实环境请替换为你的应用镜像。
1)Deployment:高可用关键配置
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-demo
labels:
app: api-demo
spec:
replicas: 3
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
selector:
matchLabels:
app: api-demo
template:
metadata:
labels:
app: api-demo
spec:
terminationGracePeriodSeconds: 30
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- api-demo
topologyKey: kubernetes.io/hostname
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: api-demo
containers:
- name: api
image: nginx:1.25
ports:
- containerPort: 80
resources:
requests:
cpu: "200m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
startupProbe:
tcpSocket:
port: 80
failureThreshold: 30
periodSeconds: 2
readinessProbe:
tcpSocket:
port: 80
initialDelaySeconds: 3
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
tcpSocket:
port: 80
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
2)Service
apiVersion: v1
kind: Service
metadata:
name: api-demo
spec:
selector:
app: api-demo
ports:
- port: 80
targetPort: 80
type: ClusterIP
3)Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-demo
annotations:
nginx.ingress.kubernetes.io/proxy-connect-timeout: "3"
nginx.ingress.kubernetes.io/proxy-read-timeout: "15"
nginx.ingress.kubernetes.io/proxy-send-timeout: "15"
spec:
ingressClassName: nginx
rules:
- host: api-demo.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-demo
port:
number: 80
4)PodDisruptionBudget
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api-demo-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: api-demo
5)HorizontalPodAutoscaler
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-demo-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-demo
minReplicas: 3
maxReplicas: 6
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 65
6)一键部署
将以上内容保存为 k8s-ha-demo.yaml,执行:
kubectl apply -f k8s-ha-demo.yaml
kubectl get pod -o wide
kubectl get svc
kubectl get ingress
kubectl get pdb
kubectl get hpa
7)故障切换演练
模拟删除一个 Pod
kubectl delete pod -l app=api-demo
观察新 Pod 重建:
kubectl get pod -w
模拟某节点不可调度并驱逐
先查看 Pod 分布:
kubectl get pod -o wide -l app=api-demo
将某个节点 cordon:
kubectl cordon <node-name>
如果要排空节点:
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data
然后观察:
kubectl get events --sort-by=.lastTimestamp
kubectl describe deployment api-demo
kubectl get endpoints api-demo
8)本地压测观察切换效果
如果你已经把域名解析到 Ingress,可以用 hey 或 ab 压测:
hey -n 1000 -c 50 http://api-demo.local/
在压测时删除一个 Pod,观察是否只有少量抖动而非整体不可用。
定位路径:故障发生后应该怎么查
排障时最怕“每个人都在猜”。我更建议按链路一层层往下查。
第一步:看是不是入口层问题
kubectl get ingress
kubectl get pod -n ingress-nginx -o wide
kubectl logs -n ingress-nginx deploy/ingress-nginx-controller --tail=100
重点看:
- Ingress Controller 是否只有单副本
- 是否频繁 reload
- 是否有 upstream connect timeout / no live upstream
第二步:看 Service 后端是否正常
kubectl get svc api-demo
kubectl get endpoints api-demo
kubectl describe svc api-demo
如果 Endpoints 数量明显少于预期,问题通常在 Pod readiness。
第三步:看 Pod 状态和探针
kubectl get pod -l app=api-demo
kubectl describe pod <pod-name>
kubectl logs <pod-name> --previous
重点关注:
Readiness probe failedLiveness probe failedBack-off restarting failed containerOOMKilled
第四步:看调度和节点
kubectl get pod -o wide -l app=api-demo
kubectl get nodes
kubectl describe node <node-name>
kubectl top node
kubectl top pod
重点看:
- Pod 是否扎堆到同一节点
- 节点是否有内存压力
- CPU 是否被打满
- 是否存在
NotReady、DiskPressure、MemoryPressure
第五步:看依赖是否拖垮应用
比如应用日志里反复出现:
- 数据库连接超时
- Redis 连接被拒绝
- 下游服务 5xx 飙升
这时要确认是不是应用本身没坏,而是依赖先坏了。
常见坑与排查
下面这些坑,我基本都见过,而且都不算“很低级”,属于中型业务最容易中招的地方。
1. readinessProbe 通过太早
现象:
- Pod 显示 Ready
- 请求一打进去就报错
- 发布时前几秒错误率上升明显
原因:
应用进程起来了,但配置没加载完、缓存没预热完、连接池没建完。
排查:
kubectl describe pod <pod-name>
kubectl logs <pod-name>
kubectl get endpoints api-demo -w
建议:
- readiness 检测业务真正可服务的接口
- 不要只测端口存活
- 启动慢的服务配
startupProbe
2. livenessProbe 过于激进导致雪崩
现象:
- 故障时 Pod 疯狂重启
- 日志被截断
- 恢复时间比不重启还长
原因:
把临时性外部依赖失败当成“进程不可恢复”。
止血方案:
- 临时调大
failureThreshold - 去掉对外部依赖的 liveness 检查
- 只把“进程真的死锁/失活”纳入 liveness
3. 资源限制过紧,触发 OOMKilled
现象:
- Pod 反复重启
- 高峰期更明显
kubectl describe pod能看到 OOMKilled
排查:
kubectl describe pod <pod-name>
kubectl top pod
建议:
- requests 按常态流量估算
- limits 不要压得太死
- 对 Java/Go/Python 服务结合实际内存曲线设置
4. 只配了 HPA,没配基础副本冗余
现象:
- 日常副本只有 1~2 个
- 流量暴涨时扩容跟不上
- 扩容前已经超时
原因:
HPA 是“追着流量跑”,不是“提前保底”。
建议:
- 中型核心业务最少 3 副本起步
- HPA 负责弹性,不负责兜底高可用
5. 节点维护时一次性驱逐过多 Pod
现象:
- drain 节点后业务瞬间抖动
- 明明是正常维护,却像故障
原因:
缺失 PDB 或 PDB 配置过松。
排查:
kubectl get pdb
kubectl describe pdb api-demo-pdb
建议:
- 核心服务配置
minAvailable - 发布策略、PDB、节点维护策略要一起看
6. 连接池和线程池没按副本数重算
现象:
- 扩容后数据库连接突然打满
- 每个 Pod 自己都正常,整体却异常
原因:
单 Pod 连接池上限 * Pod 数量 > 数据库承载上限。
建议:
假设 MySQL 最大连接数 300,保留系统和运维余量 60,那么应用可用约 240。
如果业务有 6 个 Pod,那么单 Pod 连接池最好不要超过 40,实际还要给峰值波动留余地。
安全/性能最佳实践
高可用不是只谈“活着”,还要谈“活得稳”。
安全建议
1)最小权限运行
- 使用独立 ServiceAccount
- 配置 RBAC,避免默认权限过大
- 不要让业务容器拿到不必要的 Kubernetes API 权限
2)敏感信息进 Secret,不写死在镜像里
数据库密码、Token、证书不要直接打包进镜像。
如果条件允许,进一步接入外部密钥管理系统。
3)限制容器权限
尽量做到:
- 非 root 运行
- 只读根文件系统
- 禁止特权模式
- 丢弃多余 Linux capabilities
示例:
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
性能建议
1)requests 要基于监控,不要拍脑袋
建议至少观察一周:
- CPU P95/P99
- 内存峰值
- GC/对象分配
- 请求耗时分位数
再设置 requests/limits。
2)预留故障容量
如果 3 个副本才能刚好扛住日常流量,那么这不是高可用,而是“满负荷运行”。
更合理的状态是:少一个副本,系统还能撑住核心流量。
3)优先解决慢启动问题
故障切换速度不只取决于“拉起多快”,还取决于:
- 镜像是否过大
- 启动初始化是否太重
- 配置中心、数据库、缓存初始化是否串行阻塞
4)观测指标不要只看 CPU
高可用场景里,更有价值的是这些指标:
- 请求成功率
- P95/P99 延迟
- readiness 失败次数
- Pod 重启次数
- 节点 NotReady 次数
- Ingress 5xx 比例
- 数据库连接池使用率
- 队列堆积长度
一套更实用的排障清单
如果线上出问题,我一般按这个顺序做,比较稳:
先止血
- 确认是否需要临时扩副本
- 确认是否需要摘除异常节点
- 确认是否要回滚最近一次发布
- 确认入口层是否存在单点或异常配置
再定位
- 从用户报错时间点对齐监控
- 看入口 5xx 与延迟
- 看 Service Endpoints 变化
- 看 Pod 探针失败和重启
- 看节点资源与状态
- 看依赖服务可用性
最后固化
- 补探针
- 补反亲和或拓扑分布
- 补 PDB
- 调整 requests/limits
- 校正连接池和线程池
- 把这次故障做成演练脚本
方案边界与取舍
Kubernetes 很强,但也别把它神化。
Kubernetes 能帮你解决的
- 应用实例自动重建
- 流量摘除与重新接入
- 滚动发布
- 调度分散
- 基于指标自动扩缩容
Kubernetes 不能天然解决的
- 数据库自身高可用
- 跨地域容灾一致性
- 代码里的死锁、内存泄漏、慢 SQL
- 下游第三方服务不稳定
- 错误的线程池/连接池参数
中型业务应该优先投入在哪里?
如果资源有限,我建议优先顺序是:
- 核心服务 3 副本 + 反亲和
- Ingress 双副本 + 稳定 LB
- 合理探针
- PDB + 滚动发布
- 监控告警补齐
- 依赖层高可用治理
- 演练与自动化切换验证
总结
从单体到 Kubernetes,不是“把应用塞进容器”就结束了。
真正决定你能不能扛住故障的,往往是这些细节:
- 副本是不是分散到不同节点
- readiness / liveness / startupProbe 是否各司其职
- 发布和节点维护会不会误伤服务
- 依赖层是不是还有单点
- 故障切换有没有真实演练过
如果你现在正处于中型业务集群建设阶段,我给几个可执行建议:
- 核心服务默认 3 副本,不要从 1 副本起步
- 一定加反亲和或 topology spread
- 把 readiness 当成流量开关,而不是“端口通了就算好”
- liveness 保守一点,避免故障时自我放大
- PDB、滚动发布、节点维护策略一起设计
- 每月做一次故障演练,至少演练删 Pod、宕节点、入口异常三类场景
最后说个经验判断:
如果你的系统“删除一个 Pod 就明显抖一下”,那它大概率还没真正达到你想要的高可用。
高可用不是配置项,而是一次次排障、修正和演练堆出来的。