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

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

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

从单体到高可用:先别急着“上云原生”,先把故障切换跑通

很多团队从单体应用迁移到 Kubernetes 时,第一反应往往是:上了 K8s,不就天然高可用了?
但我实际参与过几次中小规模集群改造后发现,事情没这么简单。

Kubernetes 提供的是“高可用能力的基础设施”,不是“自动帮你兜底的一切”。
如果你的应用还是单副本、数据库没有主从、探针配置不合理、Service 只做了四层转发,那故障一来,业务照样抖。

这篇文章我换一个更偏排障和落地的角度来讲:不是从“大而全架构图”出发,而是从“故障发生时你怎么定位、怎么止血、怎么设计得更稳”出发。适合已经会写 Deployment、Service、Ingress,但还没把故障切换真正做扎实的同学。


背景与问题

典型场景是这样的:

  • 原来是单体应用,跑在一台或几台虚拟机上
  • 现在迁到 Kubernetes,希望做到:
    • 应用多副本
    • 节点故障自动迁移
    • 发布不中断
    • 服务异常自动摘除
  • 团队规模不大,预算有限,集群规模通常在 3~10 个工作节点

听起来不复杂,但中小规模集群最容易踩的坑恰恰在这里:

  1. 控制面不是高可用,只是“能用”
  2. 业务副本数看起来有多个,但其实都压在同一台节点
  3. 探针配置不合理,导致服务假死却不切流
  4. 数据库、缓存、消息队列仍是单点
  5. 网络策略、PodDisruptionBudget、拓扑分布压根没配
  6. 故障切换只存在于 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

可以把高可用拆成四层:

  1. 入口层:Ingress / LoadBalancer 是否还能接流量
  2. 服务层:Service 能不能把异常 Pod 自动摘掉
  3. 工作负载层:Deployment 能不能自动补 Pod、滚动升级、跨节点分布
  4. 控制面层:API Server、etcd、Scheduler 挂一个后是否还能继续调度和管理

2. Pod 为什么“看着活着,实际上不可用”

很多故障不是进程退出,而是:

  • 线程池打满
  • 数据库连接耗尽
  • GC 卡顿
  • 应用启动后依赖没准备好
  • 健康检查接口写得太乐观,只返回 200

所以要区分三类探针:

  • startupProbe:启动慢时防止被过早判死
  • livenessProbe:进程卡死时重启容器
  • readinessProbe:服务不可接流量时从 Service endpoint 中摘除

3. 故障切换的本质

Kubernetes 的“故障切换”并不是神秘机制,本质上是三步:

  1. 发现异常
  2. 摘除流量
  3. 补齐副本 / 调度到其他节点
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 failed
    • Liveness probe failed
    • Back-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

看这些字段:

  • Available
  • Unavailable
  • UpdatedReplicas
  • ReplicaSet 切换情况

4. 节点是不是已经不健康

kubectl get nodes
kubectl describe node <node-name>

重点关注:

  • NotReady
  • MemoryPressure
  • DiskPressure
  • PIDPressure

如果节点资源压力大,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,而是这三件事:

  1. 把故障域想清楚:Pod、节点、控制面、数据库,谁是单点
  2. 把流量切换做扎实:readiness、preStop、滚动发布、入口摘流量
  3. 把演练变成常规动作:别等线上出事才第一次验证故障切换

如果你是中小团队,我给一个非常务实的建议:

  • 应用层高可用自己做
  • 数据库和负载均衡优先用托管
  • 控制面至少三节点
  • 每个核心服务至少两副本且跨节点分布
  • 每次上线前都确认探针、PDB、资源、回滚路径

最后强调一句:
高可用不是“绝不故障”,而是“故障来了,系统能优雅地退、快速度地切、明确地恢复”。

如果你现在的集群还没做过一次完整的节点故障演练,那今天最值得做的事,不是再画一张架构图,而是马上找个测试环境,亲手把一个节点 drain 掉,看业务会不会抖。


分享到:

上一篇
《Kubernetes 集群架构实战:基于多可用区的高可用控制平面与工作负载容灾设计》
下一篇
《Java 中基于 CompletableFuture 的异步编排实战:从并行调用到超时控制与异常兜底》