在凯泵智联(KiCloud)项目中,我们将微服务从 Docker Compose 迁移到了 Kubernetes 集群。本文记录生产环境中的部署策略设计、一次真实的回滚操作,以及 CrashLoopBackOff 排查的系统方法论。

一、从 Docker Compose 到 K8S

1.1 迁移动机

凯泵智联最初用 Docker Compose + Jenkins 部署,手动在三台服务器上分别执行 docker-compose up -d。随着服务增长到 12 个微服务、客户增长到 120+ 家,这套方案的几个问题愈发严重:

手动扩缩容。 某个客户上了一批新设备导致数据采集服务负载翻倍,要手动在另一台机器上起一个新容器,再去 Nginx 手动添加上游。

更新不原子。 三台服务器要分别更新,中间有几分钟窗口期是"一半新一半旧"的状态,如果新版本有不兼容的接口改动,就会出 500 错误。

故障无自愈。 容器挂了不会自动重启(除非配了 restart: always,但那无法处理"起来了又马上挂"的情况),也不会自动迁移到健康的节点。

K8S 的声明式管理、自动扩缩容、滚动更新和自愈能力,正好解决了这些问题。

1.2 集群架构

K8S 集群(3 Master + 5 Worker)
├── Namespace: production
│   ├── Deployment: gateway (2 replicas)
│   ├── Deployment: device-service (3 replicas)
│   ├── Deployment: collect-service (3 replicas)
│   ├── Deployment: alarm-service (2 replicas)
│   ├── Deployment: watchtower-service (2 replicas)
│   └── ... 其他服务
├── Namespace: staging
│   └── ... 预发环境(每个服务 1 replica)
└── Namespace: monitoring
    ├── Prometheus
    ├── Alertmanager
    └── Grafana

二、Deployment 滚动更新策略

2.1 核心配置

以 device-service 为例,Deployment 的更新策略配置:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: device-service
  namespace: production
spec:
  replicas: 3
  revisionHistoryLimit: 5  # 保留5个历史版本,支持回滚
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1        # 最多同时多出1个Pod
      maxUnavailable: 0  # 更新过程中不允许有Pod不可用
  selector:
    matchLabels:
      app: device-service
  template:
    metadata:
      labels:
        app: device-service
        version: v2.3.1
    spec:
      containers:
        - name: device-service
          image: registry.company.com/kcloud/device-service:v2.3.1
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "500m"
              memory: "512Mi"
            limits:
              cpu: "1000m"
              memory: "1024Mi"
​
          # 就绪探针:决定Pod何时接收流量
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 30   # Spring Boot启动需要时间
            periodSeconds: 5
            failureThreshold: 3
​
          # 存活探针:决定Pod是否需要重启
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 60
            periodSeconds: 10
            failureThreshold: 3
​
          # 启动探针:保护慢启动的应用
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
            failureThreshold: 30  # 最长等待 10+30*5=160秒启动

2.2 关键参数的设计理由

maxSurge: 1 + maxUnavailable: 0 的组合是"最保守"的滚动更新策略。含义是:先启动 1 个新版本 Pod(总数变成 4),等它通过就绪探针后,再终止 1 个旧版本 Pod(总数回到 3),如此循环直到所有 Pod 都更新完毕。

为什么 maxUnavailable 设为 0?因为 device-service 是核心服务,任何时候都不能比预期的 3 个副本少。如果设为 1,更新过程中最少只有 2 个 Pod 在服务,对于高负载的设备数据采集场景来说可能导致处理延迟。

为什么 maxSurge 只设 1 不设更大?设为 2 或 3 可以加快更新速度(同时启动多个新 Pod),但会临时占用更多资源。我们的 Worker 节点资源有限,同时多出 2 个 Pod 可能导致节点资源紧张,影响其他服务。maxSurge: 1 在更新速度和资源开销之间取了平衡。

就绪探针(readinessProbe) 是滚动更新零停机的关键。K8S 只有在新 Pod 通过就绪探针后,才会把流量路由到新 Pod,并开始终止旧 Pod。initialDelaySeconds: 30 是因为 Spring Boot 应用启动需要 20-30 秒(加载 Bean、建立数据库连接池、注册到 Nacos 等)。

存活探针(livenessProbe) 用于检测"应用是否卡死"。如果连续 3 次探测失败(30 秒),K8S 会重启这个 Pod。initialDelaySeconds: 60 比就绪探针长,是为了避免在启动阶段误杀——应用可能已经启动但还在做数据预热。

启动探针(startupProbe) 是对前两个探针的保护。在 startupProbe 成功之前,livenessProbereadinessProbe 不会生效。这解决了"应用启动特别慢时被存活探针误杀"的问题。failureThreshold: 30 × periodSeconds: 5 = 最长等待 150 秒启动。

2.3 更新过程的时序

一次 3 个副本的滚动更新,完整时序如下:

初始状态: [Pod-v1-a ✓] [Pod-v1-b ✓] [Pod-v1-c ✓]  (3 pods, all v1)
​
Step 1: 创建新 Pod
[Pod-v1-a ✓] [Pod-v1-b ✓] [Pod-v1-c ✓] [Pod-v2-a 启动中...]
​
Step 2: Pod-v2-a 通过就绪探针 → 开始接收流量 → 终止 Pod-v1-a
[Pod-v1-a 终止中] [Pod-v1-b ✓] [Pod-v1-c ✓] [Pod-v2-a ✓]
​
Step 3: Pod-v1-a 终止完毕,创建 Pod-v2-b
[Pod-v1-b ✓] [Pod-v1-c ✓] [Pod-v2-a ✓] [Pod-v2-b 启动中...]
​
... 重复 ...
​
最终状态: [Pod-v2-a ✓] [Pod-v2-b ✓] [Pod-v2-c ✓]  (3 pods, all v2)

整个过程中,始终有 3 个健康的 Pod 在服务(虽然可能混合 v1 和 v2),用户无感知。

三、回滚:一次真实的生产事故

3.1 事故经过

某次版本更新(v2.2.0 → v2.3.0)后,设备数据采集服务的一个新功能在特定数据格式下会抛出 NPE。由于这种数据格式只有少数客户的设备会产生,我们的测试环境没有覆盖到,导致上线后部分客户的设备数据出现丢失。

3.2 快速回滚

收到告警后的处理流程:

# 1. 查看更新历史
$ kubectl rollout history deployment/device-service -n production
REVISION  CHANGE-CAUSE
3         image update to v2.1.0
4         image update to v2.2.0
5         image update to v2.3.0  ← 当前版本(有bug)
​
# 2. 一键回滚到上一个版本
$ kubectl rollout undo deployment/device-service -n production
deployment.apps/device-service rolled back
​
# 3. 确认回滚状态
$ kubectl rollout status deployment/device-service -n production
Waiting for deployment "device-service" rollout to finish: 1 old replicas are pending termination...
deployment "device-service" successfully rolled out
​
# 4. 验证版本
$ kubectl get pods -n production -l app=device-service -o jsonpath='{.items[*].spec.containers[0].image}'
registry.company.com/kcloud/device-service:v2.2.0

从发现问题到回滚完成,总耗时 3 分钟。回滚过程同样是滚动的(遵循 Deployment 的 strategy 配置),不会中断服务。

3.3 回滚到指定版本

如果需要回滚到更早的版本(不是上一个):

# 回滚到 REVISION 3(v2.1.0)
kubectl rollout undo deployment/device-service -n production --to-revision=3

这就是 revisionHistoryLimit: 5 的价值——保留了最近 5 个版本的 ReplicaSet 和 Pod 模板,随时可以回退到任意一个。

3.4 Jenkins Pipeline 中的自动回滚

后来我在 Jenkins Pipeline 中加了自动化回滚机制——部署完成后自动运行冒烟测试,失败则自动回滚:

stage('Deploy to Production') {
    steps {
        sh "kubectl set image deployment/device-service device-service=${IMAGE}:${TAG} -n production"
        sh "kubectl rollout status deployment/device-service -n production --timeout=300s"
    }
}
​
stage('Smoke Test') {
    steps {
        script {
            def healthCheck = sh(
                script: "curl -sf http://device-service.production.svc/actuator/health",
                returnStatus: true
            )
            def apiCheck = sh(
                script: "curl -sf http://device-service.production.svc/api/v1/devices/health-check",
                returnStatus: true
            )
            if (healthCheck != 0 || apiCheck != 0) {
                echo "冒烟测试失败,自动回滚..."
                sh "kubectl rollout undo deployment/device-service -n production"
                sh "kubectl rollout status deployment/device-service -n production --timeout=300s"
                error("部署已自动回滚,请检查新版本")
            }
        }
    }
}

四、CrashLoopBackOff 排查方法论

4.1 什么是 CrashLoopBackOff

CrashLoopBackOff 是 K8S 中最常见也最让人头疼的 Pod 状态之一。它表示容器启动后立刻(或很快)崩溃,K8S 不断重启它,但每次都崩溃,重启间隔越来越长(10s → 20s → 40s → ... 最长 5 分钟)。

4.2 我遇到过的三次 CrashLoopBackOff

Case 1:OOM Killed

表现: Pod 状态反复在 Running 和 CrashLoopBackOff 之间切换。

排查过程:

# 查看 Pod 事件
$ kubectl describe pod device-service-xyz -n production
Events:
  Type     Reason     Age   Message
  ----     ------     ----  -------
  Warning  OOMKilled  30s   Container device-service was OOMKilled
​
# 查看上一次容器的退出状态
$ kubectl get pod device-service-xyz -n production -o jsonpath='{.status.containerStatuses[0].lastState}'
{"terminated":{"exitCode":137,"reason":"OOMKilled"}}

退出码 137 = 128 + 9(SIGKILL),OOMKilled 说明容器使用的内存超过了 resources.limits.memory

根因: device-service 在处理批量设备数据时,会在内存中缓存一批数据做聚合计算。新版本增加了缓存的批次大小但没有调整内存限制,导致 JVM 堆加上堆外内存超过了容器的 1024Mi 限制,被 Linux OOM Killer 杀掉。

解决: 将 memory limit 从 1024Mi 调整到 1536Mi,同时在 JVM 参数中加 -XX:MaxRAMPercentage=75.0 确保 JVM 堆不超过容器内存的 75%。

Case 2:配置错误导致启动失败

表现: Pod 一直处于 CrashLoopBackOff,从未进入 Running 状态。

排查过程:

# 查看容器日志(关键:-p 参数查看上一次崩溃的容器日志)
$ kubectl logs device-service-xyz -n production -p
...
Caused by: java.lang.IllegalArgumentException:
  Could not resolve placeholder 'spring.datasource.password' in value "${spring.datasource.password}"

根因: 新版本引用了一个 ConfigMap 中的数据库密码配置,但部署时忘记更新 ConfigMap(密码字段名从 db.password 改成了 spring.datasource.password),应用启动时找不到配置项直接报错退出。

解决: 更新 ConfigMap 后重启 Pod。事后在 CI/CD 流水线中加了 ConfigMap 校验步骤——在部署前检查新版本依赖的所有配置项是否在 ConfigMap/Secret 中存在。

Case 3:存活探针配置不当

表现: Pod 状态先是 Running,大约 60 秒后变成 CrashLoopBackOff。

排查过程:

$ kubectl describe pod alarm-service-xyz -n production
Events:
  Warning  Unhealthy  45s (x3 over 65s)  kubelet  Liveness probe failed: HTTP probe failed with statuscode: 503
  Normal   Killing    45s                 kubelet  Container alarm-service failed liveness probe, will be restarted

存活探针连续 3 次失败(503 状态码),K8S 认为容器卡死了,强制重启。

根因: alarm-service 使用了 Spring Boot 的 Graceful Shutdown 特性。在 Spring Boot 2.7+ 中,应用启动后会先进入一个"预热"阶段,此时 /actuator/health/liveness 返回 503(SERVICE_UNAVAILABLE)。存活探针的 initialDelaySeconds 设了 30 秒,但 alarm-service 的预热阶段需要 50 秒(加载告警规则引擎),30 秒后存活探针开始检测时应用还没完成预热,连续 3 次 503 后被杀掉重启,重启后又是同样的情况,形成死循环。

解决: 增加 startupProbe(前面的配置中已经体现)。在 startupProbe 成功之前,livenessProbe 不会生效,避免了"启动期间被误杀"的问题。

4.3 CrashLoopBackOff 排查流程图

Pod 状态: CrashLoopBackOff
        │
        ▼
kubectl describe pod → 查看 Events 和 Last State
        │
        ├── OOMKilled (exitCode=137)
        │   → 调整 memory limits 或优化应用内存使用
        │
        ├── Liveness probe failed
        │   → 检查探针配置(initialDelay是否足够、端口是否正确)
        │   → 检查应用启动时间,增加 startupProbe
        │
        ├── 其他退出码
        │   │
        │   ▼
        │   kubectl logs <pod> -p → 查看崩溃前的日志
        │   │
        │   ├── 配置错误(Missing property / DB connection failed)
        │   │   → 检查 ConfigMap / Secret / 外部依赖可达性
        │   │
        │   ├── 端口冲突(Address already in use)
        │   │   → 检查 containerPort 配置和其他容器
        │   │
        │   └── 应用 Bug(NPE / 类加载错误)
        │       → 修复代码,回滚到上一版本
        │
        └── 没有明显错误
            → kubectl exec -it <pod> -- /bin/sh 进入容器排查
            → 检查文件系统权限、挂载卷是否正常

核心原则:先看 describe(Events 和退出码),再看 logs -p(崩溃前日志),最后 exec 进去看。 80% 的 CrashLoopBackOff 在前两步就能定位。

五、经验总结

maxUnavailable: 0 是生产环境的必选项。 除非你能接受更新过程中服务降级。多花几分钟更新完毕,远好过更新期间出现请求失败。

三个探针各有分工,缺一不可。 readinessProbe 控制流量、livenessProbe 控制重启、startupProbe 保护慢启动。不配 startupProbe 的应用,迟早会遇到"启动期间被存活探针误杀"的问题。

revisionHistoryLimit 不要设为 0。 生产环境至少保留 3-5 个版本的回滚能力。磁盘上多保留几个 ReplicaSet 的开销微乎其微,但关键时刻一条回滚命令能救命。

CrashLoopBackOff 不可怕,可怕的是没有方法论。 按照"describe → logs -p → exec"的三步法,绝大多数情况都能快速定位。


如果这篇文章对你有帮助,欢迎访问我的博客 robinzhu.top 获取更多实战分享。


本站由 困困鱼 使用 Stellar 创建。