容器健康检查设计模式CMD、exec 与 HTTP 探针的优劣健康检查的核心不是怎么探测而是当探测失败时K8s 会做什么。一、场景痛点部署了一个 Node.js 服务用curl localhost:3000/health做 HTTP 探针。某天 Node.js 的 Event Loop 被一个死循环的同步操作阻塞了但 3000 端口仍然在监听——HTTP 探针以为服务正常K8s 没有任何动作。结果是 500 个请求在 Node.js 的 Event Loop 里排队全部超时。另一个场景部署了一个 Python 推理服务用python -c check_model_loaded()做 exec 探针。推理模型加载需要 45 秒但 exec 探针在 30 秒后就开始超时——K8s 以为 Pod 不健康反复重启。服务永远启动不了。健康检查很简单——三种探针几个参数。但健康检查设计错了会同时产生服务死了却不重启和服务活着却被重启两种致命故障。二、底层机制与原理剖析2.1 三种探针的底层机制2.2 三种检查方式的对比方式原理开销适用场景典型陷阱CMD(exec)在容器内执行命令退出码 0 为健康低一次进程 fork验证内部状态、文件存在性命令本身耗时会被计入超时HTTP GET向容器端口发 HTTP 请求2xx/3xx 为健康中一次 TCP HTTPWeb 服务、API 服务端口存活 ≠ 服务健康TCP Socket尝试建立 TCP 连接连接成功为健康最低一次 TCP SYN非 HTTP 服务数据库、消息队列端口监听 ≠ 服务可用2.3 探针生命周期与关键参数livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 30 # 容器启动后等 30 秒才开始探测 periodSeconds: 10 # 每 10 秒探测一次 timeoutSeconds: 5 # 单次探测超时 5 秒 failureThreshold: 3 # 连续失败 3 次才判定为不健康 successThreshold: 1 # 连续成功 1 次恢复为健康关键时间线计算最早触发重启时间 initialDelaySeconds (periodSeconds timeoutSeconds) × failureThreshold示例:30 (10 5) × 3 75 秒也就是说容器从真正死了到被重启最坏可能需要 75 秒三、生产级代码实现3.1 三种探针方式的最佳实践apiVersion: v1 kind: Pod metadata: name: multi-probe-demo spec: containers: - name: app image: myapp:latest ports: - containerPort: 3000 name: http - containerPort: 9090 name: metrics # # Startup Probe: 处理长启动时间的场景 # 启动探针通过之前Liveness 和 Readiness 不会启动 # 这是解决启动太慢被误杀问题的终极方案 # startupProbe: httpGet: path: /healthz/startup port: 3000 initialDelaySeconds: 5 # 给进程 5 秒准备时间 periodSeconds: 10 # 每 10 秒检查一次 timeoutSeconds: 5 # 单次超时 5 秒 failureThreshold: 30 # 允许失败 30 次 (5 分钟的最长启动时间) # 解释: 5s delay 30×(10s5s) 最长 455 秒的启动宽容期 successThreshold: 1 # 只要通过 1 次就认为启动完成 # # Liveness Probe: 检测是否需要重启 # 原则: 只检查重启能解决的问题 # - 死锁 ✓ # - 进程僵尸 ✓ (exec 探针检查) # - 内存泄漏导致的 OOM ✓ # - 外部依赖挂了 ✗ (重启不能修复依赖问题!) # livenessProbe: httpGet: path: /healthz port: 3000 httpHeaders: - name: X-Health-Check value: liveness initialDelaySeconds: 0 # startupProbe 通过后立即开始 periodSeconds: 15 # 不要太频繁15 秒一次足够了 timeoutSeconds: 5 failureThreshold: 3 # 连续 3 次失败重启 45 秒后发现 successThreshold: 1 # # Readiness Probe: 检测是否应该接收流量 # 原则: 临时性的不能服务应该标记为 Not Ready # - 数据库连接池满了 ✓ (等一会就好) # - 正在做 Full GC/热重载 ✓ # - 被限流/CDN 回源压力大 ✓ # readinessProbe: httpGet: path: /healthz/ready port: 3000 httpHeaders: - name: X-Health-Check value: readiness initialDelaySeconds: 0 periodSeconds: 5 # 就绪检查要频繁5-10 秒 timeoutSeconds: 3 # 快速失败 failureThreshold: 2 # 连续 2 次失败摘除 10 秒后停止流量 successThreshold: 1 # 1 次成功恢复流量3.2 生产级健康检查端点实现 生产级健康检查端点实现 区分启动检查、存活检查、就绪检查三个层次 设计原则: 1. 启动检查: 检查核心组件是否初始化完成 2. 存活检查: 检查进程是否健康轻量级不应有外部依赖 3. 就绪检查: 检查是否准备好处理请求可以包含外部依赖检查 import asyncio import psutil import time from enum import Enum from dataclasses import dataclass from typing import Optional from fastapi import FastAPI, HTTPException app FastAPI() class ComponentStatus(Enum): HEALTHY healthy DEGRADED degraded UNHEALTHY unhealthy STARTING starting dataclass class HealthStatus: status: ComponentStatus uptime_seconds: float cpu_percent: float memory_mb: float checks: dict[str, str] # 组件名 → 状态 class HealthChecker: 分层健康检查器 def __init__(self): self.start_time time.time() self._startup_complete False self._db_connected False self._redis_connected False self._model_loaded False # 启动检查 def check_startup(self) - HealthStatus: 启动健康检查 只有当所有核心组件初始化完成后才返回健康 这个端点只需要在 startupProbe 阶段被调用 启动完成后可关闭 checks { process: healthy, db_connection: starting, redis_connection: starting, model_loading: starting } # 检查数据库连接 if self._db_connected: checks[db_connection] healthy # 检查 Redis if self._redis_connected: checks[redis_connection] healthy # 检查模型加载 if self._model_loaded: checks[model_loading] healthy all_healthy all(v healthy for v in checks.values()) if all_healthy: self._startup_complete True return HealthStatus( statusComponentStatus.HEALTHY if all_healthy else ComponentStatus.STARTING, uptime_secondstime.time() - self.start_time, cpu_percentpsutil.cpu_percent(), memory_mbpsutil.Process().memory_info().rss / 1024 / 1024, checkschecks ) # 存活检查 def check_liveness(self) - HealthStatus: 存活检查只检查进程本身是否健康 关键设计决策: - 不检查外部依赖DB/Redis/API - 不做复杂计算 - 单纯检查进程是否还在正常工作 因为: 如果 DB 挂了重启这个容器不会修复 DB # 检查 1: 进程是否响应 # 检查 2: Event Loop 是否被阻塞 (Python asyncio) # 检查 3: 内存是否超限 ( 90%) memory_percent psutil.Process().memory_percent() memory_mb psutil.Process().memory_info().rss / 1024 / 1024 checks { process_alive: healthy, event_loop: healthy, memory_usage: healthy } # 内存使用超过 90% 标记为 unhealthy if memory_percent 90: checks[memory_usage] fcritical ({memory_percent:.1f}%) return HealthStatus( statusComponentStatus.UNHEALTHY, uptime_secondstime.time() - self.start_time, cpu_percentpsutil.cpu_percent(), memory_mbmemory_mb, checkschecks ) return HealthStatus( statusComponentStatus.HEALTHY, uptime_secondstime.time() - self.start_time, cpu_percentpsutil.cpu_percent(), memory_mbmemory_mb, checkschecks ) # 就绪检查 async def check_readiness(self) - HealthStatus: 就绪检查检查服务是否准备好接收流量 可以包含外部依赖检查因为这些依赖不可用时 应该停止接收流量但不需要重启 checks { database: healthy, redis_cache: healthy, upstream_api: healthy } status ComponentStatus.HEALTHY # 检查数据库连接池 try: # await db.execute(SELECT 1) pass except Exception: checks[database] unreachable status ComponentStatus.DEGRADED # 检查 Redis try: # await redis.ping() pass except Exception: checks[redis_cache] unreachable status ComponentStatus.DEGRADED return HealthStatus( statusstatus, uptime_secondstime.time() - self.start_time, cpu_percentpsutil.cpu_percent(), memory_mbpsutil.Process().memory_info().rss / 1024 / 1024, checkschecks ) health_checker HealthChecker() app.get(/healthz/startup) async def startup(): 启动探针端点 result health_checker.check_startup() if result.status ! ComponentStatus.HEALTHY: raise HTTPException(status_code503, detailresult.checks) return {status: ok, **result.__dict__} app.get(/healthz) async def liveness(): 存活探针端点 result health_checker.check_liveness() if result.status ComponentStatus.UNHEALTHY: raise HTTPException(status_code500, detailresult.checks) return {status: ok, **result.__dict__} app.get(/healthz/ready) async def readiness(): 就绪探针端点 result await health_checker.check_readiness() if result.status ! ComponentStatus.HEALTHY: raise HTTPException(status_code503, detailresult.checks) return {status: ok, **result.__dict__}3.3 exec 探针的正确用法# exec 探针在容器内执行命令检查状态 # 典型场景 # 1. 检查某个进程是否存在 # 2. 检查某个文件是否存在配置加载完成标志 # 3. 执行自定义脚本做更复杂的健康检查 livenessProbe: exec: command: - /bin/sh - -c - | # 检查 1: 主进程是否存活 if ! pgrep -f python.*main.py /dev/null; then echo main process dead exit 1 fi # 检查 2: 是否产生了僵尸进程 ( 5 个) ZOMBIES$(ps aux | awk $8 ~ /Z/ | wc -l) if [ $ZOMBIES -gt 5 ]; then echo too many zombies: $ZOMBIES exit 1 fi # 检查 3: 临时目录是否有足够的空间 ( 100MB) AVAILABLE_MB$(df -m /tmp | tail -1 | awk {print $4}) if [ $AVAILABLE_MB -lt 100 ]; then echo low disk space: ${AVAILABLE_MB}MB exit 1 fi exit 0 periodSeconds: 30 timeoutSeconds: 5 # ← 关键: 命令必须在 5 秒内完成! failureThreshold: 33.4 调试工具健康检查模拟器#!/bin/bash # # health-probe-debug.sh # 在容器内模拟 K8s 的探针行为用于调试 # PROBE_TYPE${1:-liveness} # liveness | readiness | startup PROBE_PATH${2:-/healthz} PORT${3:-3000} echo 模拟 K8s ${PROBE_TYPE} 探针 # 模拟 K8s 的行为: for i in $(seq 1 10); do echo --- 第 $i 次探测 --- START$(python3 -c import time; print(time.time())) # 执行 HTTP 探针 HTTP_CODE$(curl -s -o /dev/null -w %{http_code} \ --max-time 5 \ http://localhost:${PORT}${PROBE_PATH} 2/dev/null || echo 000) END$(python3 -c import time; print(time.time())) ELAPSED$(python3 -c print(round(($END - $START) * 1000))) if [ $HTTP_CODE -ge 200 ] [ $HTTP_CODE -lt 400 ]; then echo ✅ ${HTTP_CODE} (${ELAPSED}ms) else echo ❌ ${HTTP_CODE} (${ELAPSED}ms) if [ $PROBE_TYPE liveness ]; then FAIL_COUNT$((FAIL_COUNT 1)) if [ $FAIL_COUNT -ge 3 ]; then echo K8s 会在此时重启容器! break fi fi fi sleep 10 # periodSeconds done四、边界分析与架构权衡4.1 Liveness Probe 的最大陷阱永远不要在 Liveness Probe 中检查外部依赖。如果你在 liveness 中检查数据库连接当数据库做维护时30 秒不可用K8s 会以为你的 Pod 出问题了开始批量重启。但重启 Pod 不会修好数据库——结果是你同时有了数据库挂了和Pod 在不断重启两个故障。正确做法Liveness → 只检查不能通过重启解决的问题进程死锁、内存泄漏、僵尸进程Readiness → 检查外部依赖DB、Redis、上游 API不可用时从 Service 摘除4.2 HTTP vs TCP Socket 的选择很多人习惯对 gRPC 服务也用 HTTP 探针。虽然 gRPC 有grpc_health_probe专用工具但最简单的做法是暴露一个独立的/healthzHTTP 端点在另一个端口上。服务类型推荐探针原因HTTP/RESTHTTP GET原生支持最可靠gRPCHTTP GET (独立端口) 或 gRPC Health CheckgRPC 的 HTTP/2 用 HTTP GET 不直观MySQL/PostgreSQLTCP Socket端口可用即可不需要执行查询RedisTCP Socket execredis-cli pingping 命令可验证 Redis 是否响应自定义 TCP 服务TCP Socket最低开销Worker/批处理exec 检查进程 进度文件没有网络端口只能 exec4.3 启动探针的最佳实践在 K8s 1.18 之前处理慢启动只能用initialDelaySeconds 大failureThreshold组合。这有两个问题启动快的时候也在空等 initialDelaySeconds启动慢的时候 failureThreshold 不一定够Startup Probe 完美解决了这两个问题只有第一次成功后才开始 Liveness/Readiness 检查失败次数独立配置。公式longest_startup_time ≈ initialDelaySeconds failureThreshold × (periodSeconds timeoutSeconds)4.4 健康检查的性能开销HTTP GET每次探测约 1-5ms本地回环 应用的 check 逻辑开销TCP Socket每次探测约 0.1-0.5msexec每次探测约 10-50msfork 进程开销对于高密度部署一个节点上 100 Pod建议健康检查逻辑尽量轻量避免健康检查本身成为性能瓶颈。五、总结健康检查的设计原则三层分离Startup 处理启动、Liveness 处理重启、Readiness 处理流量。不要用一个端点混在一起。Liveness 要傻Liveness Probe 应该简单到几乎不可能误判。只检查进程死了和内存爆了两种 case任何外部依赖的检查都放到 Readiness。Readiness 要聪明Readiness 可以检查外部依赖但应该是快速失败 3 秒超时不要阻塞探测。超时时间要现实timeoutSeconds必须大于健康检查逻辑的实际执行时间。如果你不知道需要多久exec 一个time命令实测。不要让健康检查杀死你的服务failureThreshold × periodSeconds应该给运维人员足够的反应时间至少 30 秒同时又要快到用户感知不到故障不超过 60 秒。最后一个建议上线前在 staging 环境故意杀掉外部依赖DB/Redis看你的 Liveness Probe 是否会导致 Pod 被错误重启。这个测试应该在投产前就做而不是在生产环境第一次遇到。