从单体到高可用:基于 Kubernetes 的中型业务集群架构设计与故障演练实战
很多团队做 Kubernetes 改造,第一步往往不是“怎么上 K8s”,而是“原来还能跑的单体系统,为什么一迁移就开始抖”。这篇文章我换一个更贴近真实项目的角度来讲:不从炫技架构图出发,而是从故障和排障出发,反推中型业务集群应该怎样设计。
你会看到的不是一个“完美架构”,而是一套更适合中型业务的现实方案:预算有限、团队人数有限、业务又不能停。重点包括:
- 单体到 Kubernetes 的高可用演进思路
- 中型业务集群的关键设计点
- 一套可运行的部署示例
- 如何做故障演练
- 遇到故障时怎么定位、怎么止血
- 安全和性能上哪些是必须做,哪些是“有条件再上”
背景与问题
为什么单体系统一上 Kubernetes 就容易暴露问题
单体应用在单机或少量虚机环境里,很多问题是被“掩盖”的:
- 本地磁盘写文件很方便
- Session 放内存里也能凑合
- 依赖服务偶尔超时,重试几次就过去了
- 应用挂了,手工重启一下也能恢复
但到了 Kubernetes 里,系统假设发生了变化:
- Pod 是可漂移、可替换的,不保证本地状态持久
- 服务访问是网络调用优先,延迟和超时更显著
- 高可用依赖副本、探针、调度、服务发现
- 一旦配置不当,自动恢复机制会把小问题放大成雪崩
一个典型中型业务的现实约束
这里假设我们的业务有这些特征:
- 日活中等,峰值 QPS 在几百到几千
- 有核心交易链路,不能接受长时间中断
- 团队有研发、测试、运维,但不是专门的平台团队
- 预算有限,希望控制节点规模和组件复杂度
- 需要支持灰度、扩缩容、常见故障自动恢复
这类业务最怕的不是“绝对高并发”,而是局部故障导致整体不可用。比如:
- 单个节点故障,所有 Pod 都被驱逐到一个热点节点
- 数据库连接池打满,引发应用雪崩
- readiness/liveness 配错,Kubernetes 不断重启本来还能服务的 Pod
- Ingress 或 CoreDNS 异常,外部看起来像“应用全挂了”
目标:不是无限堆复杂度,而是做“够用的高可用”
中型业务的高可用目标,一般建议拆成三层:
-
应用层高可用
多副本、无状态化、健康检查、限流降级、幂等重试 -
平台层高可用
节点冗余、跨可用区调度、PDB、HPA、滚动升级、自动恢复 -
依赖层高可用
数据库主从或高可用托管、Redis 哨兵/集群、消息队列冗余
核心原理
这一部分不空讲概念,我直接按“故障会在哪里爆”来讲。
1. 高可用的本质:消除单点 + 控制故障域
从单体迁移到集群后,最常见误区是“副本数=高可用”。其实不够。
如果 3 个副本全调度到同一台节点,节点挂了照样全没。
所以高可用不只靠副本,还要考虑:
- 节点级隔离
- 可用区级隔离
- 服务依赖隔离
- 流量入口冗余
flowchart TD
A[用户请求] --> B[SLB/Ingress]
B --> C[Service]
C --> D1[Pod A on Node-1]
C --> D2[Pod B on Node-2]
C --> D3[Pod C on Node-3]
D1 --> E[(MySQL)]
D2 --> E
D3 --> E
D1 --> F[(Redis)]
D2 --> F
D3 --> F
subgraph AZ1
D1
end
subgraph AZ2
D2
D3
end
2. Kubernetes 如何帮你“自动恢复”
Kubernetes 的恢复能力主要来自这几类机制:
- Deployment/ReplicaSet:保证副本数
- Service:屏蔽 Pod 变更,提供稳定访问入口
- Probe:决定 Pod 是否健康、是否接流量
- Scheduler:把 Pod 调度到合适节点
- HPA:根据 CPU/内存或自定义指标扩容
- PDB:避免维护时一次性干掉过多副本
这里最容易踩坑的是 livenessProbe 和 readinessProbe 混用:
readinessProbe失败:Pod 不接流量,但不会被重启livenessProbe失败:Pod 会被杀掉重建
我的经验是:
依赖服务短暂抖动时,不要轻易让 liveness 绑定深层依赖,否则数据库一抖,应用会被 K8s 成批重启。
3. 单体改造的关键不是“拆服务”,而是“先去状态”
很多团队一上来就想拆微服务,结果系统复杂度直接翻倍。
对于中型业务,更稳的路线通常是:
- 先把单体做成容器化
- 再把会话、文件、本地缓存等状态外置
- 补齐健康检查、优雅退出、日志标准输出
- 最后再按业务边界拆服务
这能显著降低迁移初期故障率。
4. 故障演练为什么比架构图更重要
没有演练过的高可用,大概率只是“看起来很高可用”。
建议至少覆盖这几类演练:
- 单 Pod 异常
- 单节点宕机
- 数据库短暂不可用
- DNS/网络抖动
- 配置变更失误
- 滚动发布失败回滚
sequenceDiagram
participant U as User
participant LB as Ingress/SLB
participant S as Service
participant P1 as Pod-1
participant P2 as Pod-2
participant DB as MySQL
U->>LB: HTTP Request
LB->>S: Forward
S->>P1: Route request
P1->>DB: Query
DB--xP1: timeout
P1-->>S: 5xx / timeout
Note over P1: readiness 失败后摘流
S->>P2: Route retry/new request
P2->>DB: Query success
P2-->>U: 200 OK
架构设计:适合中型业务的“够用型高可用”方案
这里给一套实用架构,不追求最复杂,但强调故障可控。
集群分层建议
- 入口层
- 云负载均衡 + Ingress Controller(如 NGINX Ingress)
- 应用层
- Deployment 多副本
- Service 做服务发现
- HPA 做自动扩缩容
- 数据层
- MySQL 用托管高可用实例或主从方案
- Redis 用哨兵或托管版
- 运维层
- Prometheus + Grafana
- Loki/EFK 做日志
- Alertmanager 告警
- 发布层
- Helm 或 GitOps
- 分环境命名空间隔离
最少需要关注的 6 个设计点
1)副本数不要只写 2,要配拓扑分散
至少做到:
replicas >= 2- 使用
topologySpreadConstraints - 或使用
podAntiAffinity
否则两个 Pod 挤在同一节点,形同虚设。
2)探针要分层
推荐:
startupProbe:给冷启动时间readinessProbe:判定是否接流量livenessProbe:只检查进程是否“真死了”
3)优雅退出必须做
Kubernetes 停 Pod 时,会先发 SIGTERM。
如果应用不处理,正在执行的请求会被硬切断。
至少要做到:
- 收到退出信号后停止接新流量
- 等待在途请求处理完成
- 配合
terminationGracePeriodSeconds
4)PDB 避免“维护性雪崩”
比如 3 副本的服务,如果节点维护时允许同时驱逐 2 个 Pod,剩 1 个副本很容易扛不住峰值。
5)资源请求要写,不然调度和扩容都不准
很多线上抖动不是资源真的不够,而是没写 requests/limits 导致:
- 调度器判断失真
- HPA 指标异常
- 节点内存打爆后被 OOMKilled
6)数据库不是 Kubernetes 帮你高可用
应用层高可用做得再好,如果数据库单点,整体还是单点。
对中型业务来说,数据库更建议:
- 优先云厂商托管高可用
- 自建时至少主从 + 自动切换 + 备份恢复演练
实战代码(可运行)
下面用一个可运行的 Spring Boot 风格服务思路来演示,但为了方便快速验证,我用一个更轻量的 Python Flask 应用做示例。你可以把探针、优雅退出、Deployment 配置迁移到自己的服务里。
1. 应用代码
文件:app.py
from flask import Flask, jsonify
import signal
import time
import threading
import os
app = Flask(__name__)
ready = True
shutting_down = False
@app.route("/healthz/live")
def live():
return jsonify({"status": "live"}), 200
@app.route("/healthz/ready")
def health_ready():
if ready and not shutting_down:
return jsonify({"status": "ready"}), 200
return jsonify({"status": "not-ready"}), 503
@app.route("/api/order")
def order():
if shutting_down:
return jsonify({"error": "shutting down"}), 503
time.sleep(0.2)
return jsonify({"message": "order ok"}), 200
def handle_sigterm(signum, frame):
global ready, shutting_down
print("SIGTERM received, entering graceful shutdown...")
shutting_down = True
ready = False
time.sleep(10)
print("Shutdown complete.")
os._exit(0)
signal.signal(signal.SIGTERM, handle_sigterm)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
文件:requirements.txt
flask==3.0.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 ["python", "app.py"]
构建镜像:
docker build -t your-registry/ha-demo:v1 .
docker push your-registry/ha-demo:v1
2. Kubernetes 部署清单
文件:k8s-ha-demo.yaml
apiVersion: v1
kind: Namespace
metadata:
name: ha-demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ha-demo
namespace: ha-demo
spec:
replicas: 3
selector:
matchLabels:
app: ha-demo
template:
metadata:
labels:
app: ha-demo
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
image: your-registry/ha-demo:v1
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
startupProbe:
httpGet:
path: /healthz/live
port: 8080
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app: ha-demo
---
apiVersion: v1
kind: Service
metadata:
name: ha-demo
namespace: ha-demo
spec:
selector:
app: ha-demo
ports:
- port: 80
targetPort: 8080
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ha-demo-pdb
namespace: ha-demo
spec:
minAvailable: 2
selector:
matchLabels:
app: ha-demo
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: ha-demo-hpa
namespace: ha-demo
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: ha-demo
minReplicas: 3
maxReplicas: 6
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
部署:
kubectl apply -f k8s-ha-demo.yaml
kubectl get pod -n ha-demo -o wide
kubectl get svc -n ha-demo
3. 故障演练脚本
演练 1:删除单个 Pod,验证自动恢复
kubectl delete pod -n ha-demo -l app=ha-demo --field-selector=status.phase=Running
kubectl get pod -n ha-demo -w
观察点:
- Pod 是否自动拉起
- Service 是否持续可访问
- 是否出现明显 5xx 波动
演练 2:节点排空,验证 Pod 分散与 PDB
先看分布:
kubectl get pod -n ha-demo -o wide
排空某节点:
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data
观察:
kubectl get pod -n ha-demo -o wide -w
kubectl describe pdb ha-demo-pdb -n ha-demo
演练 3:模拟应用不可就绪
你可以临时改代码让 /healthz/ready 返回 503,或者直接进入容器里 kill 进程。
kubectl exec -it -n ha-demo deploy/ha-demo -- /bin/sh
这时重点看:
- readiness 失败后是否摘流
- liveness 是否在合理时间内重启
- 是否因为探针过严造成频繁重启
演练 4:压测触发扩容
如果集群里有 metrics-server,可以用 hey 或 wrk:
kubectl port-forward -n ha-demo svc/ha-demo 8080:80
本地压测:
hey -z 60s -c 50 http://127.0.0.1:8080/api/order
查看 HPA:
kubectl get hpa -n ha-demo -w
现象复现:几个最常见的线上故障场景
这一节按 troubleshooting 的方式来写:先看现象,再走定位路径,最后给止血方案。
常见坑与排查
场景 1:Pod 明明 Running,服务却一直 502/504
现象
kubectl get pod显示 Running- Ingress 返回 502/504
- 应用日志偶尔有请求,但不稳定
定位路径
先看 Pod 是否真的 ready:
kubectl get pod -n ha-demo
kubectl describe pod <pod-name> -n ha-demo
再看 Service endpoints:
kubectl get endpoints -n ha-demo ha-demo
如果 endpoints 为空或数量不足,通常说明:
- readinessProbe 没通过
- Service selector 写错
- 容器端口和 targetPort 不一致
止血方案
- 临时放宽 readinessProbe 超时和失败阈值
- 确保
/healthz/ready不依赖深层外部服务 - 检查 Service selector、targetPort
我踩过一个非常隐蔽的坑:
应用监听的是 8080,Service 写成 targetPort: 8000,Pod 全是 Running,但流量全黑洞。
场景 2:发布后 Pod 不断重启,形成 CrashLoopBackOff
现象
kubectl get pod -n ha-demo
看到:
CrashLoopBackOff- 重启次数持续增长
定位路径
先看日志:
kubectl logs <pod-name> -n ha-demo --previous
再看事件:
kubectl describe pod <pod-name> -n ha-demo
重点排查:
- 启动命令是否错误
- 配置/环境变量缺失
- 探针是否太激进
- OOMKilled
- 依赖初始化太慢
止血方案
- 加
startupProbe,给足冷启动时间 - 暂时关闭过严的 livenessProbe
- 调大内存 limits 或优化启动阶段加载
场景 3:节点没挂,但业务 RT 飙升,错误率上升
现象
- Pod 都在,副本也足够
- CPU 看起来不高
- 但接口 RT、超时和 5xx 上升
定位路径
这类问题往往不是 K8s 本身,而是依赖被打满。优先看:
- 数据库连接数
- Redis 慢查询
- 上游接口超时
- 线程池/连接池耗尽
检查资源和事件:
kubectl top pod -n ha-demo
kubectl top node
kubectl get events -n ha-demo --sort-by=.metadata.creationTimestamp
再看应用指标:
- HTTP P95/P99
- DB 连接池 active/wait
- 错误码分布
- JVM/GC 或 Python worker 数
止血方案
- 降低并发入口,启用限流
- 缩短下游超时时间,避免请求堆积
- 临时扩容应用副本
- 紧急调大连接池,但要评估数据库承载
场景 4:滚动发布时短暂全量失败
现象
- 平时服务正常
- 一发布就出现大量 5xx
- 发布完成后又恢复
定位路径
检查 Deployment 策略:
kubectl get deploy ha-demo -n ha-demo -o yaml
重点看:
maxUnavailablemaxSurge- readinessProbe
- preStop 和优雅退出
如果旧 Pod 过早退出、新 Pod 尚未 ready,就会出现发布窗口故障。
止血方案
推荐滚动参数保守一些:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
这会让发布更慢,但更稳。
一套实用的定位路径
线上出故障时,我通常按这个顺序排,避免一上来就“怀疑人生”。
flowchart TD
A[用户报错/监控告警] --> B{是全站故障吗}
B -- 是 --> C[先查 Ingress/SLB/DNS]
B -- 否 --> D[查具体服务]
D --> E{Pod Ready 吗}
E -- 否 --> F[看 Probe/Events/Logs]
E -- 是 --> G{Service Endpoints 正常吗}
G -- 否 --> H[查 Selector/端口/标签]
G -- 是 --> I{资源是否异常}
I -- 是 --> J[看 CPU/Memory/OOM/Node Pressure]
I -- 否 --> K[查依赖: DB Redis MQ API]
K --> L[应用线程池/连接池/超时/重试]
具体排查命令清单
看资源对象状态
kubectl get pod,svc,deploy,ep -n ha-demo -o wide
看事件
kubectl get events -n ha-demo --sort-by=.metadata.creationTimestamp
看 Pod 详情
kubectl describe pod <pod-name> -n ha-demo
看日志
kubectl logs <pod-name> -n ha-demo
kubectl logs <pod-name> -n ha-demo --previous
看资源使用
kubectl top pod -n ha-demo
kubectl top node
看 HPA/PDB
kubectl describe hpa ha-demo-hpa -n ha-demo
kubectl describe pdb ha-demo-pdb -n ha-demo
安全/性能最佳实践
高可用不是只看“活着”,还要看活得稳不稳、出了事能不能守住边界。
安全最佳实践
1)不要默认用 root 运行容器
至少这样约束:
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
2)敏感配置放 Secret,但别以为 Secret 就等于加密万无一失
- Secret 默认只是 Base64 编码
- 生产环境建议配合 KMS 或外部密钥管理
- 严禁把数据库密码写进镜像
3)NetworkPolicy 做最小访问控制
中型业务哪怕不做全量零信任,至少要限制:
- 非必要命名空间互访
- 应用到数据库的访问范围
- 监控和管理端口暴露范围
4)RBAC 最小权限
CI/CD、运维账号不要直接给 cluster-admin。
很多事故不是系统坏了,而是脚本误操作删错资源。
性能最佳实践
1)requests 要基于真实压测数据写
经验值只能起步,最终还得看:
- 峰值 CPU
- 内存常驻占用
- GC 或 worker 模型
- 启动耗时
2)HPA 不是万能,需要结合限流和连接池配置
应用副本扩得再快,如果数据库连接上限没变,最后只是更多副本一起争抢连接。
3)谨慎使用大而深的重试
错误配置示例:
- 应用重试 3 次
- SDK 重试 3 次
- 网关再重试 2 次
表面上只是一个请求,实际上可能把下游放大成十几次调用。
依赖抖动时,这会直接雪崩。
4)日志不要无脑打满
常见问题:
- debug 日志长期开启
- 同一错误每次请求都打印堆栈
- stdout 日志量过大拖垮磁盘和采集链路
建议:
- 关键链路保留结构化日志
- 高频错误做采样
- traceId 全链路透传
一个更稳妥的发布与演练流程
如果你准备从单体逐步迁到 Kubernetes,我建议按这个节奏推进:
第 1 阶段:先容器化,不急着拆服务
目标:
- 单体应用镜像化
- 日志输出到 stdout
- 配置环境变量化
- 会话外置
- 补齐健康检查
第 2 阶段:上 Kubernetes,但先做“低风险高可用”
目标:
- 2~3 副本
- Service + Ingress
- readiness/liveness/startupProbe
- PDB + 反亲和
- 基础监控告警
第 3 阶段:做故障演练,不要急着上复杂 Service Mesh
目标:
- 演练单 Pod、单节点、发布失败、依赖抖动
- 输出 runbook
- 明确止血手段和回滚路径
第 4 阶段:业务真的需要时再做服务拆分
边界条件很重要:
- 如果瓶颈是数据库,不是拆服务就能解决
- 如果团队没有稳定的观测体系,拆分后排障会更难
- 如果发布流程还不成熟,微服务只会放大变更风险
总结
从单体到 Kubernetes 的高可用改造,真正决定成败的,往往不是用了多少高级组件,而是这几个基础动作有没有做扎实:
- 副本数不是全部,拓扑分散更关键
- readiness 和 liveness 要分清,不要互相替代
- 优雅退出、PDB、滚动策略决定发布是否平滑
- 高可用必须演练,没演练过就不算真的可用
- 排障时先看流量入口、再看 Pod Ready、再看依赖
- 数据库和 Redis 的高可用不能指望 K8s 自动兜底
如果你现在正准备把中型业务从单体迁到 Kubernetes,我的可执行建议是:
- 先把应用改成无状态可替换
- 先做2~3 副本 + 反亲和 + PDB
- 探针先保守配置,别一上来太激进
- 把单 Pod 删除、节点排空、发布回滚三类演练做起来
- 在监控、日志、告警没成熟前,不要急着拆太细
一句话收尾:
中型业务的高可用,不是追求最炫的架构,而是让系统在常见故障下“坏得可控、恢复得自动、排查得清楚”。