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

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

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

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

很多团队从单体应用走向 Kubernetes,并不是因为“想上云原生”,而是因为业务已经被现实推着走了:发布窗口越来越短、单机扛不住流量、某个节点一挂整站受影响、运维值班越来越焦虑。

我见过不少中型业务的典型演进路径:

  • 一开始是单体应用 + 单台 MySQL
  • 然后变成 Nginx + 多台应用机器
  • 再往后引入容器和 CI/CD
  • 最后进入 Kubernetes,但发现“能跑”和“高可用”之间,差了不止一个 Deployment

这篇文章不讲大而全的平台建设,而是聚焦一个更实际的问题:中型业务如何基于 Kubernetes 设计一个可落地的高可用架构,并在故障发生时完成快速切换与排障。


背景与问题

典型业务场景

假设我们有一个中型业务系统,包含以下组件:

  • Web/API 服务
  • 订单服务
  • 用户服务
  • Redis 缓存
  • MySQL 主从或云数据库
  • Ingress 网关
  • 日志与监控系统

业务特征一般是:

  • 日常并发中等,活动期间会有 3~5 倍突增
  • 允许秒级抖动,但不能长时间不可用
  • 发布频率较高,希望做到滚动升级
  • 有多服务依赖,某个组件故障可能引发连锁反应

从单体迁移后,为什么还是不稳定?

很多人把应用容器化后,就默认“已经高可用了”。实际并不是。

常见问题包括:

  1. Pod 多副本了,但流量入口单点

    • Ingress Controller 只有 1 个副本
    • Service 正常,入口挂了还是全挂
  2. 副本数够了,但调度不分散

    • 3 个 Pod 全跑在同一个节点
    • 节点宕机后,业务还是整体不可用
  3. 有探针,但探针配置不合理

    • 启动慢的服务被 livenessProbe 反复杀死
    • readinessProbe 过早通过,导致请求打到未初始化完成的实例
  4. 数据库没问题,连接池先崩了

    • 突发流量下应用线程堆积
    • 连接数耗尽,看起来像“数据库挂了”
  5. 故障切换设计有了,但缺少演练

    • 文档写着“自动切换”
    • 真出故障时,恢复时间依然很长

所以这篇文章换个角度来讲:不是先讲 K8s 有哪些组件,而是从故障和排障路径反推架构设计。


核心原理

高可用不是单一能力,而是几层能力叠加出来的:

  • 冗余:实例、节点、入口、依赖都不能单点
  • 隔离:故障不能轻易扩散
  • 探测:系统能知道“谁坏了”
  • 切换:坏了之后能自动移走流量或重建实例
  • 恢复:恢复过程足够快,且不会造成二次伤害

一张总览图:中型业务集群推荐形态

flowchart TB
    U[用户请求] --> LB[四层负载均衡/LB]
    LB --> ING1[Ingress Controller A]
    LB --> ING2[Ingress Controller B]

    ING1 --> SVC1[API Service]
    ING2 --> SVC1

    SVC1 --> POD1[api-pod-1]
    SVC1 --> POD2[api-pod-2]
    SVC1 --> POD3[api-pod-3]

    POD1 --> REDIS[(Redis Sentinel/托管 Redis)]
    POD2 --> REDIS
    POD3 --> REDIS

    POD1 --> MYSQL[(MySQL 主从/云数据库)]
    POD2 --> MYSQL
    POD3 --> MYSQL

    subgraph K8S[Kubernetes Cluster]
      NODE1[Node-1]
      NODE2[Node-2]
      NODE3[Node-3]
    end

    POD1 -.调度.-> NODE1
    POD2 -.调度.-> NODE2
    POD3 -.调度.-> NODE3

故障切换的关键链路

在 Kubernetes 里,应用从“发现故障”到“恢复服务”,大致经过这条链路:

  1. 容器异常或节点异常
  2. 健康检查失败
  3. Pod 从 Service Endpoints 中摘除
  4. 流量停止转发到异常实例
  5. Deployment/ReplicaSet 拉起新 Pod
  6. 新 Pod readiness 成功后重新接流量

这条链路里,readinessProbe 决定流量切换,livenessProbe 决定是否重启,调度策略决定副本是否真的抗故障。

故障切换时序图

sequenceDiagram
    participant User as 用户
    participant LB as 负载均衡
    participant Ingress as Ingress
    participant Service as Service
    participant PodA as Pod A
    participant Kubelet as Kubelet
    participant RS as ReplicaSet

    User->>LB: 发起请求
    LB->>Ingress: 转发流量
    Ingress->>Service: 路由到后端
    Service->>PodA: 请求进入实例

    Note over PodA: 实例卡死/依赖异常
    Kubelet->>PodA: 健康检查
    PodA-->>Kubelet: readiness/liveness 失败

    Kubelet->>Service: 将 Pod A 从可用端点摘除
    Service-->>Ingress: 更新后端列表
    Ingress-->>LB: 后端减少

    RS->>Kubelet: 创建新 Pod
    Kubelet->>RS: 新 Pod 就绪
    Service->>Ingress: 新 Pod 加入流量池
    User->>LB: 后续请求命中新实例

中型业务的架构设计重点

对于中型业务,我更建议优先做好以下 5 件事:

1)入口高可用

至少两副本 Ingress Controller,并保证前面有可用的 LB。
如果是自建机房,要重点确认 VIP、Keepalived 或 BGP 方案是否存在隐性单点。

2)应用层多副本 + 反亲和

不是简单把 replicas 改成 3,而是让副本尽量分布到不同节点

3)探针分层设计

  • startupProbe:解决慢启动
  • readinessProbe:决定接不接流量
  • livenessProbe:判断是否需要重启

这三个不要混用。我当时踩过一个坑:把数据库连通性直接放进 livenessProbe,结果 DB 短暂抖动时,整个应用层被连续重启,故障反而扩大。

4)PodDisruptionBudget 和滚动发布

避免运维操作或节点升级时,同时干掉过多副本。

5)外部依赖的高可用边界

Kubernetes 只能解决应用编排问题,不能自动让数据库变高可用。
如果 MySQL、Redis 还是单点,那么你只是把单点从应用层转移到了数据层。


现象复现:一个常见故障是怎么发生的

我们先复现一个中型业务中很典型的故障:某节点宕机,API 服务大量 502/超时。

触发条件

  • API 服务 3 个副本
  • 但没有配置反亲和
  • 调度结果是 2 个副本在 Node-1,1 个副本在 Node-2
  • Node-1 宕机后,瞬间损失 2/3 容量
  • 剩余实例扛不住流量,出现排队和超时

状态图

stateDiagram-v2
    [*] --> 正常运行
    正常运行 --> 节点故障: Node-1 宕机
    节点故障 --> 容量骤降: 丢失多个 Pod
    容量骤降 --> 请求堆积
    请求堆积 --> 超时与502
    超时与502 --> 自动重建Pod
    自动重建Pod --> 新实例预热中
    新实例预热中 --> 服务恢复
    服务恢复 --> [*]

这类故障为什么“看起来像网络问题”?

因为用户视角是:

  • 页面转圈
  • 接口偶发超时
  • Nginx/Ingress 返回 502/504

但真正根因可能是:

  • 调度不均衡
  • CPU limit 过低
  • readiness 检查不合理
  • HPA 触发太慢
  • 连接池配置不匹配

这也是为什么排障不能只盯着 Ingress 日志。


实战代码(可运行)

下面给一套适合中型业务起步的可运行示例,包含:

  • 一个 API Deployment
  • Service
  • Ingress
  • PodDisruptionBudget
  • HPA
  • 反亲和与拓扑分布
  • 健康检查

说明:示例镜像使用 nginx 模拟业务服务,真实环境请替换为你的应用镜像。

1)Deployment:高可用关键配置

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-demo
  labels:
    app: api-demo
spec:
  replicas: 3
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 1
  selector:
    matchLabels:
      app: api-demo
  template:
    metadata:
      labels:
        app: api-demo
    spec:
      terminationGracePeriodSeconds: 30
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchExpressions:
                    - key: app
                      operator: In
                      values:
                        - api-demo
                topologyKey: kubernetes.io/hostname
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: api-demo
      containers:
        - name: api
          image: nginx:1.25
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: "200m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "256Mi"
          startupProbe:
            tcpSocket:
              port: 80
            failureThreshold: 30
            periodSeconds: 2
          readinessProbe:
            tcpSocket:
              port: 80
            initialDelaySeconds: 3
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 3
          livenessProbe:
            tcpSocket:
              port: 80
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 10"]

2)Service

apiVersion: v1
kind: Service
metadata:
  name: api-demo
spec:
  selector:
    app: api-demo
  ports:
    - port: 80
      targetPort: 80
  type: ClusterIP

3)Ingress

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-demo
  annotations:
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "3"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "15"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "15"
spec:
  ingressClassName: nginx
  rules:
    - host: api-demo.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-demo
                port:
                  number: 80

4)PodDisruptionBudget

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-demo-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: api-demo

5)HorizontalPodAutoscaler

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-demo-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-demo
  minReplicas: 3
  maxReplicas: 6
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 65

6)一键部署

将以上内容保存为 k8s-ha-demo.yaml,执行:

kubectl apply -f k8s-ha-demo.yaml
kubectl get pod -o wide
kubectl get svc
kubectl get ingress
kubectl get pdb
kubectl get hpa

7)故障切换演练

模拟删除一个 Pod

kubectl delete pod -l app=api-demo

观察新 Pod 重建:

kubectl get pod -w

模拟某节点不可调度并驱逐

先查看 Pod 分布:

kubectl get pod -o wide -l app=api-demo

将某个节点 cordon:

kubectl cordon <node-name>

如果要排空节点:

kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data

然后观察:

kubectl get events --sort-by=.lastTimestamp
kubectl describe deployment api-demo
kubectl get endpoints api-demo

8)本地压测观察切换效果

如果你已经把域名解析到 Ingress,可以用 heyab 压测:

hey -n 1000 -c 50 http://api-demo.local/

在压测时删除一个 Pod,观察是否只有少量抖动而非整体不可用。


定位路径:故障发生后应该怎么查

排障时最怕“每个人都在猜”。我更建议按链路一层层往下查。

第一步:看是不是入口层问题

kubectl get ingress
kubectl get pod -n ingress-nginx -o wide
kubectl logs -n ingress-nginx deploy/ingress-nginx-controller --tail=100

重点看:

  • Ingress Controller 是否只有单副本
  • 是否频繁 reload
  • 是否有 upstream connect timeout / no live upstream

第二步:看 Service 后端是否正常

kubectl get svc api-demo
kubectl get endpoints api-demo
kubectl describe svc api-demo

如果 Endpoints 数量明显少于预期,问题通常在 Pod readiness。

第三步:看 Pod 状态和探针

kubectl get pod -l app=api-demo
kubectl describe pod <pod-name>
kubectl logs <pod-name> --previous

重点关注:

  • Readiness probe failed
  • Liveness probe failed
  • Back-off restarting failed container
  • OOMKilled

第四步:看调度和节点

kubectl get pod -o wide -l app=api-demo
kubectl get nodes
kubectl describe node <node-name>
kubectl top node
kubectl top pod

重点看:

  • Pod 是否扎堆到同一节点
  • 节点是否有内存压力
  • CPU 是否被打满
  • 是否存在 NotReadyDiskPressureMemoryPressure

第五步:看依赖是否拖垮应用

比如应用日志里反复出现:

  • 数据库连接超时
  • Redis 连接被拒绝
  • 下游服务 5xx 飙升

这时要确认是不是应用本身没坏,而是依赖先坏了。


常见坑与排查

下面这些坑,我基本都见过,而且都不算“很低级”,属于中型业务最容易中招的地方。

1. readinessProbe 通过太早

现象:

  • Pod 显示 Ready
  • 请求一打进去就报错
  • 发布时前几秒错误率上升明显

原因:

应用进程起来了,但配置没加载完、缓存没预热完、连接池没建完。

排查:

kubectl describe pod <pod-name>
kubectl logs <pod-name>
kubectl get endpoints api-demo -w

建议:

  • readiness 检测业务真正可服务的接口
  • 不要只测端口存活
  • 启动慢的服务配 startupProbe

2. livenessProbe 过于激进导致雪崩

现象:

  • 故障时 Pod 疯狂重启
  • 日志被截断
  • 恢复时间比不重启还长

原因:

把临时性外部依赖失败当成“进程不可恢复”。

止血方案:

  • 临时调大 failureThreshold
  • 去掉对外部依赖的 liveness 检查
  • 只把“进程真的死锁/失活”纳入 liveness

3. 资源限制过紧,触发 OOMKilled

现象:

  • Pod 反复重启
  • 高峰期更明显
  • kubectl describe pod 能看到 OOMKilled

排查:

kubectl describe pod <pod-name>
kubectl top pod

建议:

  • requests 按常态流量估算
  • limits 不要压得太死
  • 对 Java/Go/Python 服务结合实际内存曲线设置

4. 只配了 HPA,没配基础副本冗余

现象:

  • 日常副本只有 1~2 个
  • 流量暴涨时扩容跟不上
  • 扩容前已经超时

原因:

HPA 是“追着流量跑”,不是“提前保底”。

建议:

  • 中型核心业务最少 3 副本起步
  • HPA 负责弹性,不负责兜底高可用

5. 节点维护时一次性驱逐过多 Pod

现象:

  • drain 节点后业务瞬间抖动
  • 明明是正常维护,却像故障

原因:

缺失 PDB 或 PDB 配置过松。

排查:

kubectl get pdb
kubectl describe pdb api-demo-pdb

建议:

  • 核心服务配置 minAvailable
  • 发布策略、PDB、节点维护策略要一起看

6. 连接池和线程池没按副本数重算

现象:

  • 扩容后数据库连接突然打满
  • 每个 Pod 自己都正常,整体却异常

原因:

单 Pod 连接池上限 * Pod 数量 > 数据库承载上限。

建议:

假设 MySQL 最大连接数 300,保留系统和运维余量 60,那么应用可用约 240。
如果业务有 6 个 Pod,那么单 Pod 连接池最好不要超过 40,实际还要给峰值波动留余地。


安全/性能最佳实践

高可用不是只谈“活着”,还要谈“活得稳”。

安全建议

1)最小权限运行

  • 使用独立 ServiceAccount
  • 配置 RBAC,避免默认权限过大
  • 不要让业务容器拿到不必要的 Kubernetes API 权限

2)敏感信息进 Secret,不写死在镜像里

数据库密码、Token、证书不要直接打包进镜像。
如果条件允许,进一步接入外部密钥管理系统。

3)限制容器权限

尽量做到:

  • 非 root 运行
  • 只读根文件系统
  • 禁止特权模式
  • 丢弃多余 Linux capabilities

示例:

securityContext:
  runAsNonRoot: true
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop:
      - ALL

性能建议

1)requests 要基于监控,不要拍脑袋

建议至少观察一周:

  • CPU P95/P99
  • 内存峰值
  • GC/对象分配
  • 请求耗时分位数

再设置 requests/limits。

2)预留故障容量

如果 3 个副本才能刚好扛住日常流量,那么这不是高可用,而是“满负荷运行”。
更合理的状态是:少一个副本,系统还能撑住核心流量。

3)优先解决慢启动问题

故障切换速度不只取决于“拉起多快”,还取决于:

  • 镜像是否过大
  • 启动初始化是否太重
  • 配置中心、数据库、缓存初始化是否串行阻塞

4)观测指标不要只看 CPU

高可用场景里,更有价值的是这些指标:

  • 请求成功率
  • P95/P99 延迟
  • readiness 失败次数
  • Pod 重启次数
  • 节点 NotReady 次数
  • Ingress 5xx 比例
  • 数据库连接池使用率
  • 队列堆积长度

一套更实用的排障清单

如果线上出问题,我一般按这个顺序做,比较稳:

先止血

  1. 确认是否需要临时扩副本
  2. 确认是否需要摘除异常节点
  3. 确认是否要回滚最近一次发布
  4. 确认入口层是否存在单点或异常配置

再定位

  1. 从用户报错时间点对齐监控
  2. 看入口 5xx 与延迟
  3. 看 Service Endpoints 变化
  4. 看 Pod 探针失败和重启
  5. 看节点资源与状态
  6. 看依赖服务可用性

最后固化

  1. 补探针
  2. 补反亲和或拓扑分布
  3. 补 PDB
  4. 调整 requests/limits
  5. 校正连接池和线程池
  6. 把这次故障做成演练脚本

方案边界与取舍

Kubernetes 很强,但也别把它神化。

Kubernetes 能帮你解决的

  • 应用实例自动重建
  • 流量摘除与重新接入
  • 滚动发布
  • 调度分散
  • 基于指标自动扩缩容

Kubernetes 不能天然解决的

  • 数据库自身高可用
  • 跨地域容灾一致性
  • 代码里的死锁、内存泄漏、慢 SQL
  • 下游第三方服务不稳定
  • 错误的线程池/连接池参数

中型业务应该优先投入在哪里?

如果资源有限,我建议优先顺序是:

  1. 核心服务 3 副本 + 反亲和
  2. Ingress 双副本 + 稳定 LB
  3. 合理探针
  4. PDB + 滚动发布
  5. 监控告警补齐
  6. 依赖层高可用治理
  7. 演练与自动化切换验证

总结

从单体到 Kubernetes,不是“把应用塞进容器”就结束了。
真正决定你能不能扛住故障的,往往是这些细节:

  • 副本是不是分散到不同节点
  • readiness / liveness / startupProbe 是否各司其职
  • 发布和节点维护会不会误伤服务
  • 依赖层是不是还有单点
  • 故障切换有没有真实演练过

如果你现在正处于中型业务集群建设阶段,我给几个可执行建议:

  1. 核心服务默认 3 副本,不要从 1 副本起步
  2. 一定加反亲和或 topology spread
  3. 把 readiness 当成流量开关,而不是“端口通了就算好”
  4. liveness 保守一点,避免故障时自我放大
  5. PDB、滚动发布、节点维护策略一起设计
  6. 每月做一次故障演练,至少演练删 Pod、宕节点、入口异常三类场景

最后说个经验判断:
如果你的系统“删除一个 Pod 就明显抖一下”,那它大概率还没真正达到你想要的高可用。
高可用不是配置项,而是一次次排障、修正和演练堆出来的。


分享到:

上一篇
《从源码到部署:基于开源项目 MinIO 搭建高可用对象存储服务的实战指南-318》
下一篇
《分布式架构中基于一致性哈希与服务发现的灰度发布实战指南》