在凯泵智联(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 成功之前,livenessProbe 和 readinessProbe 不会生效。这解决了"应用启动特别慢时被存活探针误杀"的问题。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 获取更多实战分享。