一、引言
"我的应用明明启动成功了,为什么 K8s 一直把流量打过来又切走?"
相信很多同学在 K8s 部署 Spring Boot 应用时都遇到过这个问题。根本原因在于:健康检查配置不当。
想象一下这个场景:
- 应用启动需要 30 秒初始化数据库连接池、加载缓存
- K8s 在启动后 5 秒就开始探测 readiness
- 由于初始化未完成,readiness 探针失败
- K8s 认为 Pod 未就绪,将其从 Service 中移除
- 30 秒后应用初始化完成,但此时流量已经被分发到其他实例
这就是典型的探针配置与应用启动时间不匹配问题。
二、Spring Boot Actuator 健康检查体系
2.1 三个核心端点
Spring Boot Actuator 自带三个健康检查端点:
# /actuator/health - 综合健康检查
# /actuator/health/liveness - 存活探针:应用本身是否正常运行
# /actuator/health/readiness - 就绪探针:是否准备好接收流量
关键区别:
| 端点 | 作用 | 失败后果 | 检查内容 |
|---|---|---|---|
/actuator/health | 综合健康状态 | 无 | 所有组件的聚合状态 |
/actuator/health/liveness | 判断是否需要重启 | K8s 杀死并重启容器 | 应用进程是否存活 |
/actuator/health/readiness | 判断是否可接收流量 | K8s 从 Service 移除 | 依赖服务是否就绪 |
2.2 开启探针的 3 行配置
# application.yml
management:
endpoint:
health:
probes:
enabled: true # 开启 liveness/readiness 探针
注意:Spring Boot 3.x 在 K8s 环境中会自动检测并开启探针,但建议显式配置以确保一致性。
三、自定义 HealthIndicator
3.1 数据库连接检查
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
@Autowired
private DataSource dataSource;
@Override
public Health health() {
try (Connection connection = dataSource.getConnection()) {
if (connection.isValid(1)) {
return Health.up()
.withDetail("database", "MySQL")
.withDetail("version", connection.getMetaData().getDatabaseProductVersion())
.build();
}
return Health.down().withDetail("error", "数据库连接无效").build();
} catch (SQLException e) {
return Health.down(e).withDetail("error", "数据库连接失败").build();
}
}
}
3.2 Redis 连接检查
@Component
public class RedisHealthIndicator implements HealthIndicator {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public Health health() {
try {
String result = redisTemplate.opsForValue().get("health_check");
if (result != null || redisTemplate.getConnectionFactory().getConnection().ping() != null) {
return Health.up()
.withDetail("redis", "connected")
.withDetail("version", "unknown")
.build();
}
return Health.down().withDetail("error", "Redis 响应为空").build();
} catch (Exception e) {
return Health.down(e).withDetail("error", "Redis 连接失败").build();
}
}
}
3.3 RabbitMQ 连接检查
@Component
public class RabbitMQHealthIndicator implements HealthIndicator {
@Autowired
private RabbitTemplate rabbitTemplate;
@Override
public Health health() {
try {
rabbitTemplate.execute(channel -> {
channel.queueDeclarePassive("health_check");
return null;
});
return Health.up()
.withDetail("rabbitmq", "connected")
.build();
} catch (Exception e) {
return Health.down(e).withDetail("error", "RabbitMQ 连接失败").build();
}
}
}
3.4 健康检查响应示例
curl http://localhost:8080/actuator/health
响应:
{
"status": "UP",
"components": {
"database": {
"status": "UP",
"details": {
"database": "MySQL",
"version": "8.0.33"
}
},
"redis": {
"status": "UP",
"details": {
"redis": "connected"
}
},
"rabbitmq": {
"status": "UP",
"details": {
"rabbitmq": "connected"
}
},
"readinessState": {
"status": "UP",
"details": {
"state": "ACCEPTING_TRAFFIC"
}
},
"livenessState": {
"status": "UP",
"details": {
"state": "CORRECT"
}
}
}
}
四、Liveness vs Readiness 深度解析
4.1 一个生动的比喻
Liveness(存活探针)= 人是否活着?
- 如果死了 → 必须重启(火化+投胎)
- 如果活着 → 不管是否生病,都保持存活状态
Readiness(就绪探针)= 人是否能工作?
- 如果感冒发烧 → 暂时不让工作,但人还活着
- 如果康复了 → 重新开始工作
- 如果彻底不行了 → 可能需要 liveness 来重启
4.2 关键区别对比
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Liveness(存活探针) │
│ ├── 作用:判断容器是否还"活着" │
│ ├── 失败行为:K8s 发送 SIGKILL 强制杀死并重启容器 │
│ ├── 检查内容:应用进程是否正常运行 │
│ ├── 典型场景:JVM 卡死、死循环、内存溢出 │
│ └── 设计原则:只检查应用本身,不检查依赖 │
│ │
│ Readiness(就绪探针) │
│ ├── 作用:判断容器是否"准备好接收流量" │
│ ├── 失败行为:K8s 将 Pod 从 Service endpoints 中移除 │
│ ├── 检查内容:依赖服务是否可用、初始化是否完成 │
│ ├── 典型场景:数据库连接失败、Redis 连接失败、MQ 连接失败 │
│ └── 设计原则:检查所有关键依赖 │
│ │
└─────────────────────────────────────────────────────────────────┘
4.3 设计原则
Liveness 探针设计原则:
- 轻量级:检查速度要快,避免影响应用性能
- 只检查应用本身:不检查外部依赖
- 保守策略:宁可误判为存活,也不要轻易重启
Readiness 探针设计原则:
- 全面性:检查所有关键依赖
- 快速失败:依赖不可用时立即返回失败
- 自动恢复:依赖恢复后自动重新加入 Service
五、K8s 探针配置最佳实践
5.1 配置参数详解
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10 # 启动后延迟多久开始检查
periodSeconds: 5 # 检查间隔
timeoutSeconds: 3 # 单次检查超时时间
failureThreshold: 3 # 连续失败多少次判定为失败
successThreshold: 1 # 连续成功多少次判定为成功
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60 # 启动后延迟更久才开始检查
periodSeconds: 10 # 检查间隔更长
timeoutSeconds: 5 # 超时时间更长
failureThreshold: 3 # 失败阈值
successThreshold: 1 # 成功阈值
5.2 参数配置经验值
| 参数 | Liveness 推荐值 | Readiness 推荐值 | 说明 |
|---|---|---|---|
| initialDelaySeconds | 60-120s | 10-30s | liveness 应晚于 readiness |
| periodSeconds | 10-30s | 5-10s | readiness 检查更频繁 |
| timeoutSeconds | 5-10s | 3-5s | liveness 允许更长响应时间 |
| failureThreshold | 3-5 | 3 | 连续失败次数 |
5.3 计算探针失败时间
Readiness 失败时间 = initialDelaySeconds + periodSeconds * failureThreshold
= 10 + 5 * 3 = 25s
Liveness 失败时间 = initialDelaySeconds + periodSeconds * failureThreshold
= 60 + 10 * 3 = 90s
六、优雅停机方案
6.1 问题分析
没有优雅停机的后果:
K8s 删除 Pod → 发送 SIGTERM → 容器立即停止 → 正在处理的请求被中断 → 用户收到 5xx 错误
6.2 正确的优雅停机流程
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 1. K8s 触发 Pod 删除 │
│ ↓ │
│ 2. preStop Hook 执行(sleep 10s) │
│ ↓ │
│ 3. Readiness 探针开始失败(可选) │
│ ↓ │
│ 4. K8s 将 Pod 从 Service endpoints 移除 │
│ ↓ │
│ 5. 新请求不再进入此 Pod │
│ ↓ │
│ 6. K8s 发送 SIGTERM 信号 │
│ ↓ │
│ 7. Spring Boot 开始优雅关闭流程 │
│ ↓ │
│ 8. 请求处理线程池逐渐关闭 │
│ ↓ │
│ 9. 在 terminationGracePeriodSeconds 内完成关闭 │
│ ↓ │
│ 10. 容器被删除 │
│ │
└─────────────────────────────────────────────────────────────────┘
6.3 配置优雅停机
Spring Boot 配置:
# application.yml
server:
shutdown: graceful # 开启优雅停机
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 每个关闭阶段的超时时间
K8s 配置:
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
terminationGracePeriodSeconds: 45 # 总停机时间 = preStop + shutdown + buffer
containers:
- name: app
image: your-app:latest
lifecycle:
preStop:
exec:
command: ["/bin/sleep", "10"] # 等待流量迁移
6.4 时间计算
总停机时间 = preStop 时间 + timeout-per-shutdown-phase + 缓冲时间
示例:
preStop: 10s
timeout-per-shutdown-phase: 30s
缓冲时间: 5s
terminationGracePeriodSeconds = 10 + 30 + 5 = 45s
七、完整示例
7.1 application.yml
server:
port: 8080
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
datasource:
url: jdbc:mysql://localhost:3306/example_db
username: admin
password: password
redis:
host: localhost
port: 6379
rabbitmq:
host: localhost
port: 5672
management:
endpoint:
health:
probes:
enabled: true
show-details: always
endpoints:
web:
exposure:
include: health,info
7.2 deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
labels:
app: order-service
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
terminationGracePeriodSeconds: 45
containers:
- name: order-service
image: order-service:latest
ports:
- containerPort: 8080
resources:
limits:
memory: "512Mi"
cpu: "500m"
requests:
memory: "256Mi"
cpu: "250m"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
successThreshold: 1
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
successThreshold: 1
lifecycle:
preStop:
exec:
command: ["/bin/sleep", "10"]
7.3 service.yaml
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
ports:
- port: 80
targetPort: 8080
type: ClusterIP
八、常见问题与解决方案
8.1 Readiness 失败但 Liveness 成功
场景:数据库连接失败,但应用本身正常运行
行为:
- Readiness 返回 DOWN → K8s 将 Pod 从 Service 移除
- Liveness 返回 UP → K8s 不会重启容器
- 效果:Pod 不再接收流量,但保持运行状态
- 恢复:数据库恢复后,Readiness 自动恢复,Pod 重新加入 Service
8.2 Liveness 失败导致频繁重启
场景:JVM 卡死、死循环、内存溢出
行为:
- Liveness 返回 DOWN → K8s 杀死并重启容器
- 效果:应用被强制重启
排查方向:
- 检查 GC 日志,是否存在内存泄漏
- 检查线程 Dump,是否存在死锁或死循环
- 检查资源使用情况,是否超过限制
8.3 启动时探针误判
场景:应用启动时间较长,探针在启动过程中失败
解决方案:
- 增大
initialDelaySeconds - 使用
startupProbe(K8s 1.18+):
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 12 # 最多等待 10 + 5 * 12 = 70s
8.4 优雅停机导致 Pod 删除缓慢
场景:terminationGracePeriodSeconds 设置过大
解决方案:
- 根据实际停机时间调整
- 优化应用关闭流程
- 使用
preStop钩子减少等待时间
九、最佳实践总结
9.1 探针设计原则
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 1. Liveness 探针:只检查应用本身 │
│ - 轻量级检查 │
│ - 不检查外部依赖 │
│ - 保守策略 │
│ │
│ 2. Readiness 探针:检查所有关键依赖 │
│ - 数据库连接 │
│ - Redis 连接 │
│ - 消息队列连接 │
│ - 外部 API │
│ │
│ 3. 探针参数配置 │
│ - initialDelaySeconds 要足够大 │
│ - liveness 应晚于 readiness 开始检查 │
│ - 合理设置 failureThreshold │
│ │
│ 4. 优雅停机配置 │
│ - server.shutdown=graceful │
│ - preStop hook 等待流量迁移 │
│ - terminationGracePeriodSeconds >= 总停机时间 │
│ │
└─────────────────────────────────────────────────────────────────┘
9.2 记忆口诀
Liveness = 活着吗?死了就重启
Readiness = 准备好了吗?没准备好就不让进
探针配置三要素:
1. 延迟启动(initialDelay)
2. 检查频率(period)
3. 失败阈值(failureThreshold)
优雅停机三要素:
1. preStop 等待
2. graceful shutdown
3. terminationGracePeriodSeconds
💡 互动话题:你们团队的健康检查是怎么配置的?有没有遇到过探针配置不当导致的问题?欢迎在评论区分享!
