从单体到高可用:基于 Kubernetes 的中型业务集群架构设计与故障切换实战
中型业务做架构升级时,最容易卡住的不是“怎么把应用跑起来”,而是“出了故障以后,系统到底会怎么反应”。很多团队从单体迁到 Kubernetes 后,表面上有了副本、有了 Service、有了 Ingress,看起来已经“云原生”了,但一到节点宕机、探针误杀、数据库连接池打满、流量突增时,业务照样抖得厉害。
这篇文章我换一个更偏 troubleshooting 的角度来写:不只讲怎么搭,还讲为什么会挂、挂了怎么止血、怎么把故障切换设计成可预期行为。目标读者是已经接触过 Kubernetes、准备承接中型线上业务的同学。
背景与问题
典型演进路径
很多中型业务的演进都长这样:
- 单体应用 + 单机数据库
- 单体应用多实例 + Nginx/LB
- 拆出部分服务 + 容器化
- 迁入 Kubernetes
- 开始追求高可用与自动故障切换
问题在于,前 4 步完成后,团队往往会产生一种错觉:
只要 Pod 副本数 >= 2,就已经高可用了。
实际上不是。
Kubernetes 解决的是调度与编排问题,并不自动等于:
- 应用无状态
- 启动就绪合理
- 连接池设置正确
- 依赖服务具备容灾能力
- 流量切换无损
- 故障恢复时间满足 SLA
中型业务最常见的故障场景
我在实际项目里见过最多的是下面几类:
- 节点故障:某个 Node 宕机,Pod 重建变慢,服务短时不可用
- 应用假活:进程没挂,但线程池阻塞、连接池耗尽,对外已不可服务
- 探针配置错误:Liveness 过严导致频繁重启,反而扩大故障
- 滚动发布抖动:新版本未完全 Ready 就接流量,老版本又被提前摘掉
- 跨可用区分布不均:看似三副本,实际都跑在同一节点池
- 数据库单点:应用层做了高可用,数据层还是单点,切换形同虚设
目标:不是“永不故障”,而是“故障可控”
做高可用的核心目标,不是让系统永远不出事,而是:
- 出事时影响范围可控
- 切换路径清晰
- 恢复时间可预测
- 排查手段标准化
核心原理
这一节先把中型业务在 Kubernetes 上实现高可用的关键原理串起来。
1. 高可用不是单点能力,而是分层能力
一个完整的业务链路,至少要看五层:
- 入口层:SLB / Ingress Controller
- 服务层:Service / Deployment / Pod
- 调度层:Node / Scheduler / PDB / Affinity
- 依赖层:DB / Redis / MQ
- 观测层:日志、指标、事件、告警
只优化其中一层,很容易出现“局部高可用,整体不可用”。
flowchart TD
U[用户请求] --> LB[SLB / Ingress]
LB --> SVC[Service]
SVC --> POD1[Pod A]
SVC --> POD2[Pod B]
SVC --> POD3[Pod C]
POD1 --> DB[(MySQL 主从/高可用)]
POD2 --> REDIS[(Redis Sentinel/Cluster)]
POD3 --> MQ[(Message Queue)]
MON[Prometheus / Loki / Alertmanager] --> LB
MON --> SVC
MON --> POD1
MON --> DB
2. Kubernetes 故障切换的本质
Kubernetes 的故障切换,本质上是下面几件事组合起来:
- 探测故障:探针、Node 心跳、应用指标
- 摘流量:从 Endpoints/EndpointSlice 中移除异常 Pod
- 重建实例:Deployment/ReplicaSet 拉起新 Pod
- 重新调度:Scheduler 将 Pod 放到健康 Node
- 恢复接入:Ready 后重新加入 Service
这里有个很关键的认知:
Kubernetes 不直接“修复”你的应用,它只是把不健康实例移走,再尝试创建新的健康实例。
3. Readiness 与 Liveness 的职责不要混
这是高可用场景里最容易踩坑的点之一。
- Readiness Probe:决定是否接流量
- Liveness Probe:决定是否重启容器
- Startup Probe:解决慢启动应用在冷启动时被误杀
错误做法经常是:
- 用一个
/health接口同时给 liveness 和 readiness - 这个接口还顺带检查数据库、Redis、MQ 全依赖
- 某个下游抖一下,容器就被 Liveness 重启
这会把“依赖抖动”放大成“应用雪崩”。
4. 中型业务的高可用重点是“约束调度”
如果你只写:
replicas: 3
那 3 个 Pod 可能都被调度到同一个节点,甚至同一可用区。
所以必须引入以下约束:
podAntiAffinity:避免副本扎堆topologySpreadConstraints:按 zone / hostname 均匀分布PodDisruptionBudget:防止维护操作同时干掉太多 PodmaxUnavailable/maxSurge:控制滚动发布节奏
5. 真正的故障切换链路
下面这个时序图更接近线上真实情况:
sequenceDiagram
participant User as 用户
participant Ingress as Ingress/SLB
participant Service as Service
participant Pod as 旧 Pod
participant Kubelet as Kubelet
participant Controller as Deployment Controller
participant NewPod as 新 Pod
User->>Ingress: 发起请求
Ingress->>Service: 转发
Service->>Pod: 访问业务接口
Pod-->>Kubelet: Readiness 检查失败
Kubelet-->>Service: 从 Endpoints 移除 Pod
User->>Ingress: 新请求继续进入
Ingress->>Service: 路由健康实例
Controller->>NewPod: 创建新副本
NewPod-->>Kubelet: 启动完成
Kubelet-->>Service: Ready 后加入 Endpoints
Service-->>Ingress: 可用实例恢复
方案设计:从单体迁移到中型高可用集群
这里给出一个适合中型业务的基线架构。不是“最豪华”,但足够实用。
推荐架构
- Kubernetes 控制面:托管或 3 节点高可用
- 工作节点:至少 3 个,分布在 2~3 个可用区
- 入口:云 LB + Nginx Ingress Controller
- 应用:Deployment,多副本
- 配置管理:ConfigMap + Secret
- 数据层:数据库主从/高可用托管版,Redis 哨兵或集群
- 观测:Prometheus + Grafana + Loki/ELK
- 发布策略:RollingUpdate,关键服务支持金丝雀或蓝绿
取舍分析
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单体多副本上 K8s | 迁移成本低 | 服务边界仍重,扩展粒度粗 | 迁移初期 |
| 微服务全面拆分 | 独立扩展好 | 运维复杂度高 | 团队成熟后 |
| K8s + 托管数据库 | 降低数据层运维风险 | 成本略高 | 中型线上业务推荐 |
| 数据库也自建在 K8s | 统一平台 | 运维难度高 | 对数据库团队要求高 |
我的建议很直接:
中型业务优先把应用层高可用做好,数据库尽量用成熟托管方案。
别一上来就追求“所有东西都跑在 K8s 里”。
现象复现:一个常见的“看起来有 3 副本,实际上还是抖”的案例
先构造一个常见问题:
- 应用有 3 个 Pod
- 每个 Pod 启动需要 40 秒
- Readiness 没配
- 滚动发布时
maxUnavailable: 1 - 节点偶发重启
结果会怎样?
- 新 Pod 还没准备好就开始接流量
- 老 Pod 已被摘除
- 请求出现 502/504
- 用户感知明显
这个问题在线上很常见,因为很多团队默认认为“容器 Running 就能接流量”,但 Running != Ready。
实战代码(可运行)
下面给一个可运行的最小示例:一个 Flask 应用,带健康检查,并配套 Kubernetes 部署文件。
1)应用代码
from flask import Flask, jsonify
import os
import time
import threading
app = Flask(__name__)
ready = False
start_time = time.time()
def warmup():
global ready
time.sleep(15)
ready = True
threading.Thread(target=warmup, daemon=True).start()
@app.route("/")
def index():
return jsonify({
"message": "hello from k8s ha demo",
"hostname": os.getenv("HOSTNAME", "unknown"),
"uptime": int(time.time() - start_time)
})
@app.route("/live")
def live():
return jsonify({"status": "alive"}), 200
@app.route("/ready")
def readiness():
if ready:
return jsonify({"status": "ready"}), 200
return jsonify({"status": "starting"}), 503
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
2)依赖文件
flask==3.0.0
gunicorn==21.2.0
3)Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8080
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8080", "app:app"]
4)Kubernetes Deployment
这个 YAML 里包含了中型业务高可用常用配置:副本、探针、反亲和、拓扑分布、滚动策略。
apiVersion: apps/v1
kind: Deployment
metadata:
name: ha-demo
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: ha-demo
template:
metadata:
labels:
app: ha-demo
spec:
terminationGracePeriodSeconds: 30
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: ha-demo
topologyKey: kubernetes.io/hostname
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: ha-demo
containers:
- name: app
image: ha-demo:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
resources:
requests:
cpu: "200m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
startupProbe:
httpGet:
path: /ready
port: 8080
failureThreshold: 20
periodSeconds: 2
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 2
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet:
path: /live
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
---
apiVersion: v1
kind: Service
metadata:
name: ha-demo
spec:
selector:
app: ha-demo
ports:
- port: 80
targetPort: 8080
5)PodDisruptionBudget
防止运维操作或节点升级时把实例一次性驱逐太多。
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ha-demo-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: ha-demo
6)Ingress 示例
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ha-demo-ingress
spec:
ingressClassName: nginx
rules:
- host: ha-demo.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ha-demo
port:
number: 80
7)部署命令
docker build -t ha-demo:latest .
kubectl apply -f deployment.yaml
kubectl apply -f pdb.yaml
kubectl apply -f ingress.yaml
kubectl get pods -o wide
kubectl get endpoints ha-demo
故障切换实战:怎么验证它真的能切
设计得再漂亮,不演练都不算数。
场景一:Pod 故障切换
操作
kubectl get pods -l app=ha-demo
kubectl delete pod <其中一个pod名>
kubectl get pods -w
预期
- 被删的 Pod 终止
- Service 端点减少 1 个
- 新 Pod 被拉起
- 新 Pod Ready 后重新加入流量池
- 整体服务无明显中断
检查点
kubectl get endpoints ha-demo -w
kubectl describe pod <新pod名>
kubectl logs <新pod名>
场景二:节点故障切换
如果是测试环境,可以模拟 drain 某个节点:
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data
预期
- 原节点上的 Pod 被驱逐
- 因为有 PDB,不会一次驱逐过多
- Pod 被重新调度到其他节点
- 业务保持可用
恢复节点
kubectl uncordon <node-name>
场景三:应用假活
这是最值得演练的。
比如我们故意让 /ready 返回 503,但 /live 正常。
预期
- Pod 不会被重启
- 但会被从 Service 端点中移除
- 请求将转发给其他 Ready Pod
这正是合理的故障隔离:
先摘流量,而不是先重启。
定位路径:线上故障时怎么排
我建议按照这个顺序排,别一上来就 SSH 节点。
flowchart TD
A[用户报错/告警触发] --> B{入口层是否异常}
B -->|是| C[检查 LB/Ingress 日志与 5xx]
B -->|否| D{Service Endpoints 是否足够}
D -->|否| E[检查 Pod Ready 状态与探针]
D -->|是| F{Pod 是否资源不足}
F -->|是| G[检查 CPU/内存/重启/OOM]
F -->|否| H{依赖是否异常}
H -->|是| I[检查 DB/Redis/MQ 延迟与连接数]
H -->|否| J[查看节点事件/调度失败/网络策略]
第一层:先看 Service 有没有健康端点
kubectl get svc ha-demo
kubectl get endpoints ha-demo
kubectl get pods -l app=ha-demo -o wide
如果 Endpoints 数量少于预期,问题基本就在 Pod Ready 或 selector 上。
第二层:看 Pod 状态和事件
kubectl describe pod <pod-name>
kubectl logs <pod-name> --previous
重点看:
- 探针失败
- OOMKilled
- CrashLoopBackOff
- ImagePullBackOff
- FailedScheduling
第三层:看 Deployment 是否发布策略有问题
kubectl describe deployment ha-demo
kubectl rollout status deployment/ha-demo
kubectl rollout history deployment/ha-demo
第四层:看节点与调度
kubectl get nodes
kubectl describe node <node-name>
kubectl top node
kubectl top pod
第五层:看依赖服务
Kubernetes 里的应用很大一部分“假故障”其实来自依赖层:
- MySQL 连接数耗尽
- Redis 超时
- MQ 堆积
- DNS 解析慢
这时候你如果只盯着 Pod 重启,会越排越偏。
常见坑与排查
下面这些坑,我几乎每个项目都见过。
1. 把数据库检查放进 Liveness Probe
现象
数据库短时抖动时,应用 Pod 大量重启。
原因
Liveness 本来用于判断“进程是否不可恢复”,结果被你绑定成“依赖是否健康”。
正确做法
- Liveness 只检查进程主循环是否还活着
- Readiness 决定是否摘流量
- 依赖异常优先降级、限流、熔断
2. 没有 Startup Probe,慢启动应用被误杀
现象
Java 应用或加载缓存较慢的服务一直重启。
排查
kubectl describe pod <pod-name>
看事件里是否持续出现 probe failed。
解决
加 startupProbe,把冷启动窗口留出来。
3. 三副本都落在同一节点
现象
一个节点挂掉,整个服务几乎全灭。
排查
kubectl get pods -o wide -l app=ha-demo
看 NODE 列是否集中。
解决
配置:
podAntiAffinitytopologySpreadConstraints
4. 发布时流量抖动
现象
滚动发布过程中,接口 RT 飙升、5xx 增多。
原因
- readiness 太乐观
- preStop 没处理
- 应用没优雅关闭
maxUnavailable设置过大
解决
maxUnavailable: 0- 配
preStop - 服务端支持优雅退出
- Ingress/网关设置合理的 upstream fail timeout
5. PDB 配太死,导致无法驱逐
现象
节点升级或维护时,drain 卡住。
原因
minAvailable 设置太高,而集群资源又不足。
排查
kubectl get pdb
kubectl describe pdb ha-demo-pdb
建议
PDB 不是越严越好,要和副本数、容量冗余一起设计。
6. 资源 Requests 配太低,导致节点超卖
现象
平时没事,一有流量波动,Pod 被频繁驱逐或响应变慢。
原因
调度器以 requests 为依据,你写太低,实际上是在骗调度器。
建议
- requests 参考常态负载
- limits 不要乱设过小
- 基于监控做 VPA/HPA 调优
止血方案:线上已经抖了,先怎么救
排障时,顺序很重要。先止血,再修因。
方案一:先扩副本
如果是流量突增或部分实例异常,先做最直接的:
kubectl scale deployment ha-demo --replicas=5
前提是:
- 依赖层还能扛住
- 节点资源足够
方案二:回滚版本
如果故障与发布强相关,别犹豫:
kubectl rollout undo deployment/ha-demo
方案三:临时摘掉问题节点
kubectl cordon <node-name>
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data
方案四:放宽探针或关闭误杀逻辑
如果明确是探针配置问题导致雪崩,可以先热修 YAML:
kubectl edit deployment ha-demo
把过严的 liveness 放宽,优先保住实例数量。
方案五:应用层限流与降级
如果依赖层顶不住,单纯扩 Pod 没用,反而会把数据库打穿。
这时应该:
- 熔断非核心接口
- 对慢查询做缓存兜底
- 限制突发流量
- 把写操作转异步
安全/性能最佳实践
高可用如果不考虑安全和性能,最后会变成“只是堆副本”。
安全最佳实践
1. 不要用默认 ServiceAccount 跑核心服务
为业务单独创建最小权限账号,避免横向权限过大。
2. Secret 不要明文进 Git
使用:
- External Secrets
- KMS
- Vault
- 云厂商 Secret Manager
3. 配 NetworkPolicy
限制只有必要的服务可以互访,尤其是数据库与缓存。
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-app
spec:
podSelector:
matchLabels:
app: ha-demo
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector: {}
ports:
- protocol: TCP
port: 8080
4. 镜像最小化
尽量用 slim/alpine 或 distroless,减少攻击面。
性能最佳实践
1. HPA 不是万能的
HPA 能解决的是横向扩容速度,解决不了:
- 冷启动慢
- 数据库瓶颈
- 错误探针
- 应用内存泄漏
2. 请求与连接要做池化上限
如果 10 个 Pod,每个 Pod 默认开 100 个数据库连接,那就是 1000 个连接。中型业务很容易在这里把数据库打爆。
3. 优雅终止要和业务配合
Kubernetes 发出 SIGTERM 后,应用要:
- 停止接新请求
- 等待存量请求完成
- 释放连接和资源
4. 为关键路径建立 SLI/SLO
至少监控:
- 可用率
- P95/P99 延迟
- 错误率
- Ready Pod 数
- 重启次数
- 节点压力
- 数据库连接池使用率
一个实用的排障清单
线上有告警时,我通常按这个 checklist 走:
5 分钟内确认
- 是否与刚刚发布有关
- 是否只影响单个服务
- Endpoints 是否下降
- 是否只有某个节点/可用区异常
- 是否依赖服务也在告警
15 分钟内确认
- 探针是否误判
- 是否出现 OOM / CPU Throttle
- PDB 是否阻碍调度
- HPA 是否扩不起来
- Ingress 是否有大量 502/504
30 分钟内决策
- 回滚还是扩容
- 摘节点还是调探针
- 是否需要临时降级
- 是否需要人工接管数据库流量
总结
从单体到 Kubernetes,不难;从 Kubernetes 到“可预期的高可用”,才是真正的门槛。
如果你只记住几件事,我建议是这几条:
- 副本数不等于高可用
- Readiness 决定摘流量,Liveness 决定重启,别混用
- 调度约束比单纯多开 Pod 更重要
- 故障切换必须演练,不能靠想象
- 先保应用层,数据层优先用成熟高可用方案
- 排障时先看 Endpoints,再看 Pod,再看 Node,最后看依赖
最后给一个适合中型业务的落地建议:
- 至少 3 副本
- 至少 2 个可用区
- 配好 readiness/liveness/startup probe
- 加 PDB、反亲和、拓扑分布
- 发布时
maxUnavailable: 0 - 做一次 Pod 删除演练、一次节点驱逐演练、一次依赖超时演练
高可用不是某个 YAML 字段,而是一套“故障发生时系统仍然按预期工作”的工程能力。
把这件事做扎实,Kubernetes 才不是新的复杂度来源,而是你业务稳定性的放大器。