从单体到高可用:先别急着“上云原生”,先把故障切换跑通
很多团队从单体应用迁移到 Kubernetes 时,第一反应往往是:上了 K8s,不就天然高可用了?
但我实际参与过几次中小规模集群改造后发现,事情没这么简单。
Kubernetes 提供的是“高可用能力的基础设施”,不是“自动帮你兜底的一切”。
如果你的应用还是单副本、数据库没有主从、探针配置不合理、Service 只做了四层转发,那故障一来,业务照样抖。
这篇文章我换一个更偏排障和落地的角度来讲:不是从“大而全架构图”出发,而是从“故障发生时你怎么定位、怎么止血、怎么设计得更稳”出发。适合已经会写 Deployment、Service、Ingress,但还没把故障切换真正做扎实的同学。
背景与问题
典型场景是这样的:
- 原来是单体应用,跑在一台或几台虚拟机上
- 现在迁到 Kubernetes,希望做到:
- 应用多副本
- 节点故障自动迁移
- 发布不中断
- 服务异常自动摘除
- 团队规模不大,预算有限,集群规模通常在 3~10 个工作节点
听起来不复杂,但中小规模集群最容易踩的坑恰恰在这里:
- 控制面不是高可用,只是“能用”
- 业务副本数看起来有多个,但其实都压在同一台节点
- 探针配置不合理,导致服务假死却不切流
- 数据库、缓存、消息队列仍是单点
- 网络策略、PodDisruptionBudget、拓扑分布压根没配
- 故障切换只存在于 PPT,没有做过演练
一句话总结:
从单体到高可用,不是“把应用放进 Pod”就结束,而是要让“节点挂了、Pod 崩了、版本发坏了、网络抖了”这些情况都能被系统接住。
先明确目标:中小规模集群到底要做到什么程度
对中小团队来说,我建议把目标定得现实一些:
业务可用性目标
- 应用层支持 2~3 副本
- 单节点故障不影响外部访问
- 常规发布支持 滚动更新
- 应用探针失效后能在分钟级甚至秒级切换
- 核心依赖(数据库/缓存)至少有明确的主备或托管方案
不要误解的边界
- Kubernetes 不能替代数据库高可用
- Kubernetes 不能修复应用本身的长事务、内存泄漏、连接池耗尽
- 如果只有 1 个 control plane 节点,那严格来说不能叫“控制面高可用”
- 如果只有 1 个可用区,那只能叫“集群内冗余”,不是跨机房容灾
核心原理
这部分我们不讲太多抽象概念,直接围绕“故障切换”来看。
1. Kubernetes 高可用的几个关键层次
flowchart TD
A[外部流量] --> B[Ingress / LoadBalancer]
B --> C[Service]
C --> D[Pod 副本1]
C --> E[Pod 副本2]
C --> F[Pod 副本3]
D --> G[Node A]
E --> H[Node B]
F --> I[Node C]
J[Deployment] --> D
J --> E
J --> F
K[Scheduler] --> G
K --> H
K --> I
L[etcd / API Server / Controller Manager] --> J
L --> K
可以把高可用拆成四层:
- 入口层:Ingress / LoadBalancer 是否还能接流量
- 服务层:Service 能不能把异常 Pod 自动摘掉
- 工作负载层:Deployment 能不能自动补 Pod、滚动升级、跨节点分布
- 控制面层:API Server、etcd、Scheduler 挂一个后是否还能继续调度和管理
2. Pod 为什么“看着活着,实际上不可用”
很多故障不是进程退出,而是:
- 线程池打满
- 数据库连接耗尽
- GC 卡顿
- 应用启动后依赖没准备好
- 健康检查接口写得太乐观,只返回 200
所以要区分三类探针:
- startupProbe:启动慢时防止被过早判死
- livenessProbe:进程卡死时重启容器
- readinessProbe:服务不可接流量时从 Service endpoint 中摘除
3. 故障切换的本质
Kubernetes 的“故障切换”并不是神秘机制,本质上是三步:
- 发现异常
- 摘除流量
- 补齐副本 / 调度到其他节点
sequenceDiagram
participant User as 用户请求
participant LB as Ingress/SLB
participant SVC as Service
participant PodA as Pod-A
participant K8s as K8s 控制器
participant PodB as Pod-B
User->>LB: 发起请求
LB->>SVC: 转发
SVC->>PodA: 请求进入
PodA-->>SVC: 超时/失败
K8s->>PodA: readiness 失败
K8s->>SVC: 从 endpoints 摘除 Pod-A
K8s->>PodB: 保持流量切到健康副本
K8s->>PodA: 重建或重启
4. 中小规模集群推荐架构
如果你不是做超大规模平台,下面这个模式通常更适合:
- 控制面
- 生产建议至少 3 个 control plane
- etcd 与 control plane 同机或独立,视资源而定
- 工作节点
- 3~6 个 worker 起步
- 业务部署
- 核心服务至少 2 副本
- 使用 anti-affinity 避免挤在同一节点
- 使用 PDB 防止维护时全部被驱逐
- 流量入口
- 云上优先托管 LB + Nginx Ingress
- 状态服务
- 小团队优先选云数据库/托管 Redis
- 自建高可用数据库的复杂度,常常比应用迁移本身还高
现象复现:为什么“明明 3 副本,节点一挂服务还是不可用”
这是非常经典的故障。
现象
- Deployment 配了 3 个副本
- 节点 A 宕机
- 业务仍然出现大量 5xx 或超时
kubectl get pod -o wide一看,3 个 Pod 里有 2 个都在节点 A 上
根因
Kubernetes 默认只保证“尽量调度”,不默认保证副本分散。
如果你没配反亲和性、拓扑约束,Pod 很可能扎堆。
实战代码(可运行)
下面用一套可运行示例,演示一个适合中小规模集群的高可用 Web 服务部署。
1. 一个最小可运行的 Flask 应用
文件:app.py
from flask import Flask, jsonify
import os
import socket
import time
app = Flask(__name__)
start_time = time.time()
@app.route("/")
def index():
return jsonify({
"message": "hello from kubernetes",
"hostname": socket.gethostname()
})
@app.route("/healthz")
def healthz():
return "ok", 200
@app.route("/ready")
def ready():
# 模拟依赖检查:环境变量缺失则不对外提供服务
if not os.getenv("APP_READY", "true").lower() == "true":
return "not ready", 503
return "ready", 200
@app.route("/live")
def live():
# 存活探针可以更宽松,避免误杀
uptime = time.time() - start_time
return jsonify({"status": "alive", "uptime": uptime}), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
文件:requirements.txt
flask==2.3.3
gunicorn==21.2.0
文件: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"]
构建镜像:
docker build -t your-registry/ha-demo:v1 .
docker push your-registry/ha-demo:v1
2. Kubernetes 部署清单
文件:ha-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ha-demo
labels:
app: ha-demo
spec:
replicas: 3
selector:
matchLabels:
app: ha-demo
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
template:
metadata:
labels:
app: ha-demo
spec:
terminationGracePeriodSeconds: 30
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: ha-demo
topologyKey: kubernetes.io/hostname
containers:
- name: app
image: your-registry/ha-demo:v1
imagePullPolicy: IfNotPresent
env:
- name: APP_READY
value: "true"
ports:
- containerPort: 8080
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
startupProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
livenessProbe:
httpGet:
path: /live
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
---
apiVersion: v1
kind: Service
metadata:
name: ha-demo
spec:
selector:
app: ha-demo
ports:
- port: 80
targetPort: 8080
type: ClusterIP
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ha-demo-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: ha-demo
应用配置:
kubectl apply -f ha-demo.yaml
kubectl get pod -o wide
kubectl get svc
3. 用 Ingress 暴露服务
文件:ha-demo-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ha-demo
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: ha-demo.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ha-demo
port:
number: 80
应用:
kubectl apply -f ha-demo-ingress.yaml
本地测试时可在 /etc/hosts 添加 Ingress IP 映射。
4. 故障切换演练
先观察副本分布:
kubectl get pod -l app=ha-demo -o wide
再持续发请求:
while true; do curl -s http://ha-demo.local/; echo; sleep 1; done
现在模拟 Pod 故障:
kubectl delete pod -l app=ha-demo --field-selector=status.phase=Running --grace-period=0 --force
或者更真实一点,模拟节点不可用(测试环境谨慎执行):
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data
观察现象:
- 请求是否持续可用
- Pod 是否在其他节点重建
- Endpoint 是否及时更新
查看 Service 后端:
kubectl get endpoints ha-demo -w
定位路径:出问题时到底先看哪里
真正排障时,我建议别一上来就翻日志。
先看“请求卡在哪一层”,效率会高很多。
一条实用的排查链路
flowchart LR
A[用户请求失败] --> B{Ingress 是否正常}
B -->|否| C[检查 Ingress Controller / LB]
B -->|是| D{Service endpoints 是否有健康 Pod}
D -->|否| E[检查 readinessProbe / selector]
D -->|是| F{Pod 是否稳定运行}
F -->|否| G[看 describe/events/logs]
F -->|是| H{节点是否异常}
H -->|是| I[检查 node 状态/驱逐/资源压力]
H -->|否| J[检查应用依赖: DB/Redis/外部 API]
1. 先看 Pod 是否真的 Ready
kubectl get pod -l app=ha-demo
kubectl describe pod <pod-name>
重点看:
Ready是否为True- Events 里有没有:
Readiness probe failedLiveness probe failedBack-off restarting failed container
2. 再看 Service 有没有后端
kubectl get svc ha-demo
kubectl get endpoints ha-demo
kubectl describe svc ha-demo
常见问题:
- Service selector 写错
- Pod label 对不上
- readiness 失败导致 endpoints 为空
3. 检查 Deployment 是否在补副本
kubectl get deploy ha-demo
kubectl describe deploy ha-demo
kubectl rollout status deploy/ha-demo
看这些字段:
AvailableUnavailableUpdatedReplicasReplicaSet切换情况
4. 节点是不是已经不健康
kubectl get nodes
kubectl describe node <node-name>
重点关注:
NotReadyMemoryPressureDiskPressurePIDPressure
如果节点资源压力大,Pod 可能不是“挂了”,而是被驱逐了。
5. 最后再看日志
kubectl logs <pod-name>
kubectl logs <pod-name> --previous
如果容器反复重启,--previous 很重要,我之前就因为忘了看这个,白白绕了半小时。
止血方案:服务已经抖了,先怎么救
排障不是考试,线上先止血最重要。
场景 1:新版本发布后大量 5xx
处理建议:
kubectl rollout undo deploy/ha-demo
kubectl rollout status deploy/ha-demo
前提是你使用 Deployment 的滚动更新,并保留了可回滚版本。
场景 2:探针过严导致 Pod 被频繁重启
临时止血思路:
- 放宽
livenessProbe - 增加
startupProbe - 如果是外部依赖波动,不要让 liveness 直接绑定下游可用性
场景 3:副本够,但都在一台节点上
短期:
- cordon 故障节点
- 手动 drain
- 扩容 Deployment
kubectl cordon <node-name>
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data
kubectl scale deploy/ha-demo --replicas=4
长期:
- 配 anti-affinity
- 配 topology spread constraints
场景 4:数据库才是真正单点
这类问题最常见,也最容易被忽略。
如果应用 Pod 再多,数据库一挂还是全挂。
止血方案通常只能是:
- 切只读
- 降级
- 切备用库
- 暂停部分功能
所以我一直建议:中小团队优先把状态服务交给托管产品,别把所有复杂度都自己扛。
常见坑与排查
下面列几个我在实际场景里见过最多的问题。
坑 1:把 readiness 和 liveness 写成同一个接口
很多人图省事,直接都写 /healthz。
问题是:
- 下游数据库短暂抖动时
- readiness 应该摘流量
- liveness 不一定要重启容器
如果两个探针绑死在一起,容器会被不停重启,雪上加霜。
建议:
- readiness 关注“能不能接流量”
- liveness 关注“进程是否失活”
- startup 关注“是否完成启动”
坑 2:没有 preStop,导致连接被硬切
滚动发布时,旧 Pod 可能还在处理请求就被杀掉。
上面示例里用了:
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
这不是最优雅的方式,但很实用。
配合 terminationGracePeriodSeconds,可以给 Ingress/Service 一点摘流量时间。
坑 3:PDB 配得太激进,维护时根本驱不动 Pod
比如只有 2 个副本,却设置了:
minAvailable: 2
这会导致节点维护时没法驱逐。
PDB 不是越严格越好,要结合副本数看。
坑 4:资源 requests/limits 完全不配
后果很典型:
- 调度时无参考
- 容易节点资源争抢
- OOMKilled 不断出现
- 延迟抖动难定位
排查命令:
kubectl top pod
kubectl top node
kubectl describe pod <pod-name>
坑 5:etcd 和控制面只做了“单机备份”,没做真正 HA
很多中小团队容易忽略这一层。
工作负载多副本不代表集群本身高可用。
如果只有一个 API Server 或一个 etcd 节点:
- 节点挂掉后
- 现有 Pod 也许还能跑一会儿
- 但新的调度、扩容、修复都会受影响
控制面状态关系
stateDiagram-v2
[*] --> Healthy
Healthy --> Degraded: 单 control plane 故障
Degraded --> Unavailable: etcd 不可用/API Server 不可达
Unavailable --> Recovering: 恢复节点/恢复数据
Recovering --> Healthy
安全/性能最佳实践
高可用不只是“别挂”,还包括“别因为安全和性能问题自己打挂自己”。
安全建议
1. 最小权限原则
为业务 Pod 配独立 ServiceAccount,不要默认用 default。
apiVersion: v1
kind: ServiceAccount
metadata:
name: ha-demo-sa
如果应用不需要访问 K8s API,就别给 RBAC 权限。
2. 限制容器权限
建议补这些安全项:
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
3. 用 NetworkPolicy 限制东西向流量
如果集群规模上来,默认全互通会让问题扩散很快。
性能建议
1. 不要为了“高可用”盲目加副本
副本增加会带来:
- 更多连接数
- 更多缓存不一致概率
- 更多资源消耗
建议先看单 Pod 的 CPU、内存、QPS,再决定横向扩容。
2. HPA 可以上,但别迷信
如果应用启动慢、依赖重、冷启动耗时长,HPA 未必能及时救场。
中小规模集群里,基础副本数 > 自动扩缩容的幻想。
3. 优先解决拓扑分散,而不是只加机器
3 个副本都在 1 台节点,不如 2 个副本分散在 2 台节点。
高可用首先是故障域隔离,不是简单堆数量。
4. 给关键服务留资源余量
我通常建议:
- 节点整体使用率不要长期跑到 90% 以上
- 至少预留一部分资源给故障迁移和滚动更新
- 否则节点一挂,其他节点根本接不住新 Pod
一个更稳的中小规模落地方案
如果你现在正准备从单体迁到 Kubernetes,我建议按这个顺序推进:
第一阶段:先把应用无状态化
目标:
- 配置外置
- 文件存储外置
- Session 外置
- Pod 可随时销毁重建
第二阶段:把“可用”跑通
目标:
- 至少 2 副本
- 正确探针
- 滚动发布
- Service + Ingress 正常工作
第三阶段:把“单节点故障”跑通
目标:
- anti-affinity
- PDB
- drain 演练
- 节点故障后业务不掉
第四阶段:补齐控制面和状态服务高可用
目标:
- 3 control plane
- etcd 备份与恢复演练
- 数据库主备/托管
- Redis 哨兵或托管方案
第五阶段:做例行故障演练
至少每月一次,验证这些动作:
- 删除单个 Pod
- 下线单个节点
- 回滚一个坏版本
- 恢复误删配置
- 检查告警是否生效
总结
从单体到 Kubernetes,高可用真正难的部分,不是写 YAML,而是这三件事:
- 把故障域想清楚:Pod、节点、控制面、数据库,谁是单点
- 把流量切换做扎实:readiness、preStop、滚动发布、入口摘流量
- 把演练变成常规动作:别等线上出事才第一次验证故障切换
如果你是中小团队,我给一个非常务实的建议:
- 应用层高可用自己做
- 数据库和负载均衡优先用托管
- 控制面至少三节点
- 每个核心服务至少两副本且跨节点分布
- 每次上线前都确认探针、PDB、资源、回滚路径
最后强调一句:
高可用不是“绝不故障”,而是“故障来了,系统能优雅地退、快速度地切、明确地恢复”。
如果你现在的集群还没做过一次完整的节点故障演练,那今天最值得做的事,不是再画一张架构图,而是马上找个测试环境,亲手把一个节点 drain 掉,看业务会不会抖。