跳转到内容
123xiao | 无名键客

《从单体到高可用:基于 Kubernetes 的中型业务集群架构设计与故障切换实战-445》

字数: 0 阅读时长: 1 分钟

从单体到高可用:基于 Kubernetes 的中型业务集群架构设计与故障切换实战

中型业务做架构升级时,最容易卡住的不是“怎么把应用跑起来”,而是“出了故障以后,系统到底会怎么反应”。很多团队从单体迁到 Kubernetes 后,表面上有了副本、有了 Service、有了 Ingress,看起来已经“云原生”了,但一到节点宕机、探针误杀、数据库连接池打满、流量突增时,业务照样抖得厉害。

这篇文章我换一个更偏 troubleshooting 的角度来写:不只讲怎么搭,还讲为什么会挂、挂了怎么止血、怎么把故障切换设计成可预期行为。目标读者是已经接触过 Kubernetes、准备承接中型线上业务的同学。


背景与问题

典型演进路径

很多中型业务的演进都长这样:

  1. 单体应用 + 单机数据库
  2. 单体应用多实例 + Nginx/LB
  3. 拆出部分服务 + 容器化
  4. 迁入 Kubernetes
  5. 开始追求高可用与自动故障切换

问题在于,前 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:防止维护操作同时干掉太多 Pod
  • maxUnavailable / 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 列是否集中。

解决

配置:

  • podAntiAffinity
  • topologySpreadConstraints

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 到“可预期的高可用”,才是真正的门槛。

如果你只记住几件事,我建议是这几条:

  1. 副本数不等于高可用
  2. Readiness 决定摘流量,Liveness 决定重启,别混用
  3. 调度约束比单纯多开 Pod 更重要
  4. 故障切换必须演练,不能靠想象
  5. 先保应用层,数据层优先用成熟高可用方案
  6. 排障时先看 Endpoints,再看 Pod,再看 Node,最后看依赖

最后给一个适合中型业务的落地建议:

  • 至少 3 副本
  • 至少 2 个可用区
  • 配好 readiness/liveness/startup probe
  • 加 PDB、反亲和、拓扑分布
  • 发布时 maxUnavailable: 0
  • 做一次 Pod 删除演练、一次节点驱逐演练、一次依赖超时演练

高可用不是某个 YAML 字段,而是一套“故障发生时系统仍然按预期工作”的工程能力。
把这件事做扎实,Kubernetes 才不是新的复杂度来源,而是你业务稳定性的放大器。


分享到:

上一篇
《Docker 多阶段构建与镜像瘦身实战:从构建缓存到生产环境安全优化》
下一篇
《前端性能优化实战:从 Core Web Vitals 指标出发定位并修复渲染瓶颈》