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

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

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

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

很多团队做 Kubernetes 改造,第一步往往不是“怎么上 K8s”,而是“原来还能跑的单体系统,为什么一迁移就开始抖”。这篇文章我换一个更贴近真实项目的角度来讲:不从炫技架构图出发,而是从故障和排障出发,反推中型业务集群应该怎样设计

你会看到的不是一个“完美架构”,而是一套更适合中型业务的现实方案:预算有限、团队人数有限、业务又不能停。重点包括:

  • 单体到 Kubernetes 的高可用演进思路
  • 中型业务集群的关键设计点
  • 一套可运行的部署示例
  • 如何做故障演练
  • 遇到故障时怎么定位、怎么止血
  • 安全和性能上哪些是必须做,哪些是“有条件再上”

背景与问题

为什么单体系统一上 Kubernetes 就容易暴露问题

单体应用在单机或少量虚机环境里,很多问题是被“掩盖”的:

  • 本地磁盘写文件很方便
  • Session 放内存里也能凑合
  • 依赖服务偶尔超时,重试几次就过去了
  • 应用挂了,手工重启一下也能恢复

但到了 Kubernetes 里,系统假设发生了变化:

  • Pod 是可漂移、可替换的,不保证本地状态持久
  • 服务访问是网络调用优先,延迟和超时更显著
  • 高可用依赖副本、探针、调度、服务发现
  • 一旦配置不当,自动恢复机制会把小问题放大成雪崩

一个典型中型业务的现实约束

这里假设我们的业务有这些特征:

  • 日活中等,峰值 QPS 在几百到几千
  • 有核心交易链路,不能接受长时间中断
  • 团队有研发、测试、运维,但不是专门的平台团队
  • 预算有限,希望控制节点规模和组件复杂度
  • 需要支持灰度、扩缩容、常见故障自动恢复

这类业务最怕的不是“绝对高并发”,而是局部故障导致整体不可用。比如:

  • 单个节点故障,所有 Pod 都被驱逐到一个热点节点
  • 数据库连接池打满,引发应用雪崩
  • readiness/liveness 配错,Kubernetes 不断重启本来还能服务的 Pod
  • Ingress 或 CoreDNS 异常,外部看起来像“应用全挂了”

目标:不是无限堆复杂度,而是做“够用的高可用”

中型业务的高可用目标,一般建议拆成三层:

  1. 应用层高可用
    多副本、无状态化、健康检查、限流降级、幂等重试

  2. 平台层高可用
    节点冗余、跨可用区调度、PDB、HPA、滚动升级、自动恢复

  3. 依赖层高可用
    数据库主从或高可用托管、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. 单体改造的关键不是“拆服务”,而是“先去状态”

很多团队一上来就想拆微服务,结果系统复杂度直接翻倍。
对于中型业务,更稳的路线通常是:

  1. 先把单体做成容器化
  2. 再把会话、文件、本地缓存等状态外置
  3. 补齐健康检查、优雅退出、日志标准输出
  4. 最后再按业务边界拆服务

这能显著降低迁移初期故障率。

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,可以用 heywrk

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

重点看:

  • maxUnavailable
  • maxSurge
  • 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,我的可执行建议是:

  1. 先把应用改成无状态可替换
  2. 先做2~3 副本 + 反亲和 + PDB
  3. 探针先保守配置,别一上来太激进
  4. 单 Pod 删除、节点排空、发布回滚三类演练做起来
  5. 在监控、日志、告警没成熟前,不要急着拆太细

一句话收尾:
中型业务的高可用,不是追求最炫的架构,而是让系统在常见故障下“坏得可控、恢复得自动、排查得清楚”。


分享到:

上一篇
《Web3 中级实战:用 EIP-712 与钱包签名实现链上身份认证与防重放登录系统》
下一篇
《大模型应用落地指南:基于 RAG 的企业知识库问答系统设计与优化实践》