背景与问题
很多团队从单体系统走向容器化时,第一步往往不是“高可用”,而是“先跑起来”。这很正常。我见过不少中型业务在迁移到 Kubernetes 后,表面上已经有了多副本、Service、Ingress,看起来像是“云原生”了,但一出故障,还是会暴露出典型问题:
- Pod 明明有多个副本,但发布时仍然出现瞬断
- 某个节点宕机后,请求超时飙升,恢复很慢
- 数据库连接池打满,应用虽然活着但服务不可用
- 探针配置不合理,Kubernetes 把“慢”当成“死”,反复重启
- Ingress、Service、Deployment 都配了,但流量切换不符合预期
- 真正出问题时,团队不知道先看哪一层
这篇文章不想只讲“架构应该怎样”,而是从故障切换与排障这个角度,带你看一套适合中型业务的 Kubernetes 高可用集群设计:它不追求最贵、最大,而追求稳定、可排查、可演进。
我们假设业务特点如下:
- 日均流量中等,存在波峰波谷
- 核心服务 10~30 个,部分有状态,部分无状态
- 需要支持节点故障、应用故障、滚动发布、基础自动扩缩
- 团队有一定 K8s 基础,但还没建立完整的高可用治理体系
典型故障现象
在进入设计前,先看几个常见“事故现场”:
- 单节点故障后,业务恢复超过 5 分钟
- 滚动发布期间出现 502/504
- Pod 被频繁重启,但应用日志没明显报错
- 服务副本数足够,吞吐却突然腰斩
- 集群看起来健康,但上游持续报连接失败
如果你也遇到过这些情况,通常说明问题不是单点配置错了,而是高可用链路没有闭环。
核心原理
高可用不是“多加几个 Pod”这么简单,它至少包含 4 层:
- 入口高可用:流量如何进入集群,入口节点是否冗余
- 应用高可用:服务副本、探针、滚动发布、亲和性是否合理
- 节点高可用:节点宕机后是否能快速迁移
- 依赖高可用:数据库、缓存、消息队列是否成为新的单点
对于中型业务,我更推荐把架构设计成“入口冗余 + 服务多副本 + 跨节点分散 + 明确故障切换策略”。
一张全局图看清楚
flowchart TD
U[用户请求] --> LB[负载均衡器/L4 VIP]
LB --> I1[Ingress Controller Pod A]
LB --> I2[Ingress Controller Pod B]
I1 --> S1[Service: app-service]
I2 --> S1
S1 --> P1[Pod app-1 on node-a]
S1 --> P2[Pod app-2 on node-b]
S1 --> P3[Pod app-3 on node-c]
P1 --> R[Redis]
P2 --> R
P3 --> DB[(MySQL Primary/Replica)]
这里真正决定可用性的,不是某一个组件,而是链路上每层是否都有冗余与可观测性。
1. 为什么多副本不等于高可用
Deployment 配了 replicas: 3,不代表就万无一失。常见误区有:
- 3 个 Pod 全被调度到同一个节点
- Pod 都是
Running,但应用没准备好 - 就绪探针失败,Service 不转发流量
- 节点故障时,Pod 重建太慢
- 发布策略导致旧 Pod 先下线,新 Pod 又未就绪
换句话说,高可用 = 副本 + 正确调度 + 正确探针 + 正确发布 + 正确故障切换。
2. Kubernetes 故障切换的关键机制
Kubernetes 内部和高可用最相关的几个机制是:
- Service:对外暴露稳定访问入口,自动维护后端 Pod 列表
- Readiness Probe:决定 Pod 是否能接流量
- Liveness Probe:决定 Pod 是否该被重启
- PodDisruptionBudget:限制主动驱逐时可同时中断的实例数
- Topology Spread Constraints / Pod Anti-Affinity:避免副本扎堆
- RollingUpdate:发布时控制新旧 Pod 切换节奏
- HPA:根据指标自动扩缩容
- Node Controller:节点失联后触发 Pod 重建
这些机制单独看都不复杂,但组合起来时最容易踩坑。
3. 中型业务推荐架构思路
我通常会建议按“业务关键性”分层:
| 层级 | 示例 | 推荐策略 |
|---|---|---|
| 入口层 | Ingress Controller | 至少 2 副本,跨节点部署 |
| 无状态服务层 | API、Web、BFF | Deployment + 多副本 + HPA |
| 状态依赖层 | MySQL、Redis、MQ | 独立高可用方案,避免和应用共用故障域 |
| 平台保障层 | 日志、监控、告警 | 必须有,否则故障切换不可验证 |
应用故障切换时序
sequenceDiagram
participant Client as Client
participant Ingress as Ingress
participant Service as Service
participant PodA as Pod A
participant PodB as Pod B
participant K8s as Kubernetes
Client->>Ingress: 发起请求
Ingress->>Service: 转发到 app-service
Service->>PodA: 选中 Pod A
PodA-->>Service: 响应失败/超时
K8s->>PodA: Readiness 检测失败
K8s->>Service: 从 Endpoints 中移除 Pod A
Client->>Ingress: 重试请求
Ingress->>Service: 再次路由
Service->>PodB: 选中健康实例
PodB-->>Client: 返回正常响应
这个时序里最关键的一点是:Pod A 必须尽快从流量池摘除,但不要被误杀得太频繁。
现象复现
为了让 troubleshooting 更有抓手,我们先人为制造一个“看似高可用、实际上不稳”的场景:
- 一个简单 HTTP 服务
- 3 个副本
- 配置了 liveness/readiness
- 但 readiness 太严格,启动稍慢就失败
- 同时没有做反亲和,Pod 可能落在同一节点
下面给一个可运行示例。
实战代码(可运行)
1. 一个简单的示例应用
这个应用提供:
/healthz:存活检查/readyz:就绪检查/:模拟业务请求- 启动后前 20 秒内,
/readyz返回失败,用来模拟预热期
from flask import Flask, jsonify
import os
import time
app = Flask(__name__)
start_time = time.time()
hostname = os.getenv("HOSTNAME", "unknown")
@app.route("/")
def index():
return jsonify({
"message": "hello from kubernetes",
"host": hostname
})
@app.route("/healthz")
def healthz():
return "ok", 200
@app.route("/readyz")
def readyz():
uptime = time.time() - start_time
if uptime < 20:
return "warming up", 503
return "ready", 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
依赖文件:
flask==2.3.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 ["python", "app.py"]
2. 构建并推送镜像
把镜像地址改成你自己的仓库:
docker build -t registry.example.com/demo/ha-app:v1 .
docker push registry.example.com/demo/ha-app:v1
3. Kubernetes 部署清单
这里我放一份偏“生产可用”的版本,重点是:
- 3 个副本
- 滚动更新
readinessProbe与livenessProbe分离startupProbe避免启动慢时误杀podAntiAffinity尽量跨节点PodDisruptionBudget保证最少可用实例
apiVersion: apps/v1
kind: Deployment
metadata:
name: ha-app
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: ha-app
template:
metadata:
labels:
app: ha-app
spec:
terminationGracePeriodSeconds: 30
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- ha-app
topologyKey: kubernetes.io/hostname
containers:
- name: ha-app
image: registry.example.com/demo/ha-app:v1
ports:
- containerPort: 8080
startupProbe:
httpGet:
path: /readyz
port: 8080
periodSeconds: 5
failureThreshold: 10
readinessProbe:
httpGet:
path: /readyz
port: 8080
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
---
apiVersion: v1
kind: Service
metadata:
name: ha-app
spec:
selector:
app: ha-app
ports:
- port: 80
targetPort: 8080
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ha-app-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: ha-app
应用部署:
kubectl apply -f ha-app.yaml
kubectl rollout status deployment/ha-app
kubectl get pods -o wide
kubectl get svc ha-app
4. 暴露访问并验证
如果你在本地测试,可以做端口转发:
kubectl port-forward svc/ha-app 8080:80
访问:
curl http://127.0.0.1:8080/
多次请求可以看到不同 Pod 的返回:
for i in {1..10}; do curl -s http://127.0.0.1:8080/; echo; done
5. 故障切换演练
场景 A:杀掉一个 Pod
kubectl get pods
kubectl delete pod <pod-name>
kubectl get pods -w
预期现象:
- 旧 Pod 被删
- Deployment 拉起新 Pod
- Service 继续把流量分发给其余健康实例
- 如果 readiness 合理,用户侧基本无感
场景 B:模拟节点不可用
先找到某个 Pod 所在节点:
kubectl get pods -o wide
将节点设为不可调度并驱逐:
kubectl cordon <node-name>
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data
观察:
kubectl get pods -o wide -w
kubectl get events --sort-by=.lastTimestamp
如果你的 PDB、副本数、调度空间都合理,业务应该还能维持最少服务能力。
架构中的状态变化
stateDiagram-v2
[*] --> Starting
Starting --> NotReady
NotReady --> Ready: readinessProbe success
Ready --> Unready: readinessProbe fail
Ready --> Terminating: rollout/drain/delete
Unready --> Terminating
Terminating --> [*]
这个状态图很重要。很多人把 Pod 只看成“活着/死了”两种状态,实际上故障切换最关键的是 Ready 与 Unready 的变化。
定位路径
出了故障,别上来就 kubectl delete pod。那只是“重启式排障”,不是定位。我的建议是按下面顺序走。
第 1 步:先看是不是流量没有正确摘除
检查 endpoints:
kubectl get endpoints ha-app -o yaml
或者新版本看 EndpointSlice:
kubectl get endpointslices
kubectl describe endpointslice
如果一个探针失败的 Pod 还在 endpoints 里,说明问题可能在:
- readiness 没生效
- 探针路径不对
- 应用把失败隐藏成了 200
第 2 步:看 Pod 事件与探针历史
kubectl describe pod <pod-name>
重点看:
Readiness probe failedLiveness probe failedBack-off restarting failed containerKilling container
如果你看到的是连续 liveness 失败,而应用日志没挂,多半是:
- 探针超时时间太短
- GC、JIT、IO 抖动造成瞬时卡顿
- 应用启动慢,但没配 startupProbe
这是我自己踩过的大坑之一:Java 服务启动要 40 秒,liveness 10 秒就开始探测,结果刚上线就被自己重启循环。
第 3 步:确认副本是否被调度到同一故障域
kubectl get pods -o wide
如果 3 个 Pod 都在同一个节点,节点一挂,副本数等于 0。
这时候应该检查:
- 是否设置了
podAntiAffinity - 集群节点是否足够
- 资源请求是否过高,导致调度器没得选
第 4 步:看 Service 正常但应用依赖是否已崩
很多时候“应用不可用”并不是 Pod 自身坏了,而是下游依赖出问题,比如:
- MySQL 主从切换期间连接失败
- Redis 打满连接数
- MQ 堆积导致接口超时
- DNS 解析慢导致请求卡死
你可以进入 Pod 测试依赖连通性:
kubectl exec -it <pod-name> -- sh
然后执行:
nc -zv mysql.default.svc.cluster.local 3306
nc -zv redis.default.svc.cluster.local 6379
如果应用容器太精简,建议单独起一个 debug Pod:
kubectl run netshoot --rm -it --image=nicolaka/netshoot -- /bin/bash
第 5 步:看是不是发布策略导致瞬断
查看 Deployment:
kubectl describe deployment ha-app
kubectl rollout history deployment/ha-app
如果你的配置是:
maxUnavailable: 1- 副本只有 2
- readiness 时间又长
那发布期间很容易短暂只剩 1 个实例,稍有抖动就超时。
中型业务里,核心服务更稳妥的方式通常是:
- 关键 API:
maxUnavailable: 0 - 预留资源给 surge Pod
- 让 readiness 真正代表“可接业务流量”
常见坑与排查
坑 1:把 liveness 和 readiness 指向同一个复杂检查
很多团队喜欢把 /health 写成一个“大而全”的检查:
- 连数据库
- 连 Redis
- 连 MQ
- 查磁盘
- 查第三方接口
然后 liveness、readiness 都用它。
问题在于:
liveness 的目标是判断进程是否该重启,readiness 的目标是判断是否该接流量。
建议:
livenessProbe只检查进程基础存活readinessProbe可以检查关键依赖,但要控制超时与复杂度
坑 2:探针过于激进,导致雪崩式重启
常见错误配置:
livenessProbe:
httpGet:
path: /healthz
port: 8080
timeoutSeconds: 1
periodSeconds: 3
failureThreshold: 1
这种配置在压测、GC、磁盘抖动时非常危险。
如果服务偶发卡顿 2 秒,就会被直接重启。
更稳妥的原则:
- 核心服务超时别低于 2 秒
- failureThreshold 留出缓冲
- 启动慢的服务必须配
startupProbe
坑 3:只做应用多副本,不做入口多副本
如果 Ingress Controller 只有 1 个副本,那么后面应用做得再漂亮,入口挂了还是整体不可用。
检查方式:
kubectl get deploy -A | grep ingress
kubectl get pods -A -o wide | grep ingress
至少保证:
- 2 个及以上副本
- 分散在不同节点
- 外部 LB 或 VIP 能探测并摘除异常入口
坑 4:PDB 配了,但集群容量不足
比如你设置:
minAvailable: 2
但总共就 2 个副本,节点维护时 drain 根本做不动。
这不是 Kubernetes 的问题,而是可用性目标与容量规划冲突。
经验建议:
- 核心服务至少 3 副本
- PDB 与副本数联动设计
- 节点冗余要能承受至少 1 个节点故障
坑 5:节点恢复慢,以为是 K8s 不可靠
很多人发现节点断了之后,Pod 没有“秒级”切走,于是觉得 Kubernetes 故障切换不行。其实这里要区分:
- 应用进程挂掉:探针触发,切换较快
- 节点完全失联:要经过节点状态判定、Pod 重建、镜像拉取、应用启动
这条链路天然比单 Pod 重启慢。
所以真正可用的做法是:
- 保证副本分布在多个节点
- 保证剩余副本能扛住短时流量
- 缩短应用启动时间
- 关键镜像提前预热
安全/性能最佳实践
高可用不是只谈“活着”,还要考虑安全和性能,不然系统虽然不挂,但会慢、会漏、会不可控。
1. 给容器设置资源请求与上限
没有 requests,调度器无法做合理分布;没有 limits,容易互相抢占。
中型业务里,建议先根据实际监控设一个保守值,再逐步调优。
resources:
requests:
cpu: "200m"
memory: "256Mi"
limits:
cpu: "1"
memory: "512Mi"
2. 用 HPA 扛流量波峰,但别迷信自动扩容
HPA 适合解决“负载增加”的问题,不适合解决“应用卡死”或“依赖崩溃”。
示例:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: ha-app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: ha-app
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
如果数据库已经打满,HPA 继续加应用副本,往往只会把下游压得更惨。
3. 优雅终止,避免发布和缩容时丢请求
应用收到 SIGTERM 后,应该:
- 先停止接新流量
- 等待在途请求处理完成
- 再退出进程
如果应用支持,可以加 preStop:
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
这不是万能药,但能给 Ingress、Service、连接池回收留出缓冲时间。
4. 敏感配置用 Secret,访问权限最小化
别把数据库密码直接写进镜像或 ConfigMap。
同时建议给业务 Pod 配独立的 ServiceAccount,并通过 RBAC 最小授权。
5. 监控要围绕“故障切换成功率”建设
不要只看 CPU、内存。真正有价值的指标包括:
- Pod readiness 变化次数
- Pod 重启次数
- 节点 NotReady 次数
- 服务 5xx 比例
- 接口 P95/P99 延迟
- Endpoint 数量变化
- 发布期间错误率
- drain/故障演练时恢复耗时
如果没有这些指标,你就很难回答一个关键问题:
故障切换到底是成功了,还是只是“看起来恢复了”?
一套中型业务可落地的最小闭环
如果你现在还没有完整体系,我建议优先落地下面这组“最小闭环”:
flowchart LR
A[多副本 Deployment] --> B[正确的 startup/readiness/liveness]
B --> C[跨节点分散调度]
C --> D[PDB 保障最少可用数]
D --> E[滚动发布零中断]
E --> F[监控告警与故障演练]
这 6 件事做完,已经能解决大部分中型业务在 Kubernetes 上的高可用问题。
止血方案
如果线上已经在出问题,来不及全面改造,可以先做这些止血动作:
场景 1:Pod 频繁重启
先临时放宽探针:
timeoutSeconds: 3
failureThreshold: 5
并补上 startupProbe。
目标不是“永不失败”,而是避免误判。
场景 2:发布必抖
把关键服务改成:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
同时确认 readiness 真正可用后才返回 200。
场景 3:节点维护影响业务
先检查是否满足这三个条件:
- 副本数 >= 3
- PDB 合理
- Pod 跨节点分布
如果不满足,先补副本与调度策略,再谈节点维护窗口。
场景 4:依赖故障拖垮应用
在 readiness 中对关键依赖做轻量检查;
同时给应用设置超时、熔断、连接池上限,避免请求堆死。
总结
从单体走到 Kubernetes,并不等于天然高可用。真正决定系统是否稳的,是你是否把下面这条链路打通了:
- 有足够副本
- 副本分散在不同节点
- readiness/liveness/startup 分工明确
- 发布策略不会主动制造故障
- PDB 与容量规划一致
- 依赖层不成为新的单点
- 故障切换过程能被监控、能被演练、能被解释
如果你让我给中型业务一个最实用的建议,我会说:
- 先把探针和滚动发布配对调好
- 再把副本分散与 PDB 补齐
- 最后做节点故障和发布故障演练
边界条件也要说清楚:
Kubernetes 能显著提升应用层与节点层的可用性,但它不能替你解决数据库架构、跨可用区网络、第三方依赖不稳定这些根问题。高可用从来不是某个 YAML 字段,而是端到端的工程体系。
如果你现在正准备把一套中型业务从“能跑”升级到“稳跑”,那就别只盯着副本数。真正该盯住的,是故障发生时,流量如何摘除、服务如何恢复、团队如何定位。这三件事做好了,你的 Kubernetes 集群才算真正进入高可用阶段。