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

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

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

背景与问题

很多团队从单体系统走向容器化时,第一步往往不是“高可用”,而是“先跑起来”。这很正常。我见过不少中型业务在迁移到 Kubernetes 后,表面上已经有了多副本、Service、Ingress,看起来像是“云原生”了,但一出故障,还是会暴露出典型问题:

  • Pod 明明有多个副本,但发布时仍然出现瞬断
  • 某个节点宕机后,请求超时飙升,恢复很慢
  • 数据库连接池打满,应用虽然活着但服务不可用
  • 探针配置不合理,Kubernetes 把“慢”当成“死”,反复重启
  • Ingress、Service、Deployment 都配了,但流量切换不符合预期
  • 真正出问题时,团队不知道先看哪一层

这篇文章不想只讲“架构应该怎样”,而是从故障切换与排障这个角度,带你看一套适合中型业务的 Kubernetes 高可用集群设计:它不追求最贵、最大,而追求稳定、可排查、可演进

我们假设业务特点如下:

  • 日均流量中等,存在波峰波谷
  • 核心服务 10~30 个,部分有状态,部分无状态
  • 需要支持节点故障、应用故障、滚动发布、基础自动扩缩
  • 团队有一定 K8s 基础,但还没建立完整的高可用治理体系

典型故障现象

在进入设计前,先看几个常见“事故现场”:

  1. 单节点故障后,业务恢复超过 5 分钟
  2. 滚动发布期间出现 502/504
  3. Pod 被频繁重启,但应用日志没明显报错
  4. 服务副本数足够,吞吐却突然腰斩
  5. 集群看起来健康,但上游持续报连接失败

如果你也遇到过这些情况,通常说明问题不是单点配置错了,而是高可用链路没有闭环


核心原理

高可用不是“多加几个 Pod”这么简单,它至少包含 4 层:

  1. 入口高可用:流量如何进入集群,入口节点是否冗余
  2. 应用高可用:服务副本、探针、滚动发布、亲和性是否合理
  3. 节点高可用:节点宕机后是否能快速迁移
  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、BFFDeployment + 多副本 + 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 个副本
  • 滚动更新
  • readinessProbelivenessProbe 分离
  • 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 failed
  • Liveness probe failed
  • Back-off restarting failed container
  • Killing 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 后,应该:

  1. 先停止接新流量
  2. 等待在途请求处理完成
  3. 再退出进程

如果应用支持,可以加 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 与容量规划一致
  • 依赖层不成为新的单点
  • 故障切换过程能被监控、能被演练、能被解释

如果你让我给中型业务一个最实用的建议,我会说:

  1. 先把探针和滚动发布配对调好
  2. 再把副本分散与 PDB 补齐
  3. 最后做节点故障和发布故障演练

边界条件也要说清楚:
Kubernetes 能显著提升应用层与节点层的可用性,但它不能替你解决数据库架构、跨可用区网络、第三方依赖不稳定这些根问题。高可用从来不是某个 YAML 字段,而是端到端的工程体系。

如果你现在正准备把一套中型业务从“能跑”升级到“稳跑”,那就别只盯着副本数。真正该盯住的,是故障发生时,流量如何摘除、服务如何恢复、团队如何定位。这三件事做好了,你的 Kubernetes 集群才算真正进入高可用阶段。


分享到:

上一篇
《中级开发者实战:用 RAG 构建企业内部知识库问答系统的架构设计与性能优化》
下一篇
《从提示工程到 RAG:中级开发者构建企业级 AI 问答系统的实战路径》