用 curl 做服务健康检查几乎是所有运维脚本的标配,但大多数实现只写了一行 curl -s -o /dev/null -w "%{http_code}",然后判断是否等于 200。这种写法在真实生产环境里会暴露三个问题:没有设置超时导致脚本被挂死、把「服务不可达」和「服务返回业务错误」混为一谈、无法区分 HTTP 状态码中哪些属于自身异常、哪些属于上游依赖异常。本文给出一个可以直接落地的检查函数:用 --connect-timeout 和 --max-time 分离连接超时与整体超时,用退出码捕获 curl 层的网络错误,用状态码分级表把 5xx、429、401 等分类到不同的严重级别,并最终映射成监控系统能消费的退出码。
先弄清楚 curl 的退出码与 HTTP 状态码是两套体系
这是写健康检查脚本时最容易踩的坑。curl 的退出码(exit code)描述的是「这次 HTTP 事务有没有完成」,而不是「服务是否健康」。当 curl 无法建立连接、DNS 解析失败、TLS 握手失败或超时,它会以非零退出码结束,此时 -w "%{http_code}" 输出的是 000,而不是任何真实状态码。
常见的 curl 退出码含义:
| 退出码 | 含义 | 对健康检查的解读 |
|---|---|---|
| 0 | 事务成功完成 | 拿到了 HTTP 响应,需要进一步看状态码 |
| 6 | 无法解析主机 Could not resolve host |
DNS 问题,通常是环境级故障 |
| 7 | 无法连接 Failed to connect to ... port ... |
端口未监听、防火墙拦截、进程已死 |
| 28 | 操作超时 Operation timed out |
连接超时或整体超时,服务假死或过载 |
| 35 | TLS 握手失败 | 证书过期、密码套件不匹配 |
| 52 | 服务端返回空响应 Empty reply from server |
后端进程崩溃但端口仍被 listen,典型假活 |
| 56 | 接收数据失败 Failure when receiving data |
连接被对端重置,常见于 OOM 或限流 |
因此脚本的第一步必须先判断 curl 自身是否成功,只有在 curl 返回 0 的前提下,HTTP 状态码才有意义。注意 curl -f 会让 4xx/5xx 返回非零退出码(22),这会掩盖真实状态码,健康检查里不要加 -f。
超时参数:–connect-timeout 与 –max-time 必须同时设置
两个参数职责不同,缺一不可:
--connect-timeout <seconds>:仅限制 TCP 建连阶段(含 DNS 解析与 TLS 握手)。默认值是 300 秒,等于没有超时。建连阶段卡住通常意味着网络层丢包或 SYN 队列溢出。--max-time <seconds>:限制整个事务的总时长,包括建连、发送请求、等待响应、接收完 body。默认值是 0,即无限等待。服务进程还在但应用线程池耗尽时,就表现为建连成功、响应永远不来,只有--max-time能兜住这种情况。
推荐的比例是 --max-time 约为 --connect-timeout 的 3 到 5 倍。例如内网直连服务用 --connect-timeout 2 --max-time 5,跨机房调用用 --connect-timeout 3 --max-time 10。健康检查的探测频率通常是 10 秒到 30 秒一次,超时值必须小于探测间隔,否则上一轮还没结束下一轮就启动了,堆叠的 curl 进程本身会成为负担。
需要注意:
--max-time在部分旧版本 curl(7.32 之前)上对 DNS 解析阶段不生效,因为解析在 curl 主循环之外。如果你的环境是 CentOS 6 这类老系统,建议用timeout命令在外层再包一层:timeout -k 2 8 curl ...,双保险。
状态码分级判定表
不能简单地二分为「200 健康 / 其它不健康」。不同类型的状态码对应的故障域和处置动作完全不同。下面是一套可以直接照着实现的分级:
| 级别 | 判定条件 | 含义 | 建议动作 |
|---|---|---|---|
| OK | HTTP 200~299 | 服务正常 | 无需处理 |
| WARN | HTTP 429、401、403 | 限流触发或鉴权配置异常 | 告警但不上自动摘除,人工确认 |
| WARN | HTTP 301、302、307、308 | 发生重定向,探测 URL 可能配错 | 检查脚本是否缺少 -L 或地址写错 |
| CRIT | HTTP 500、502、503、504 | 服务内部错误或上游不可用 | 立即告警,连续 N 次后可摘流量 |
| CRIT | HTTP 404 且路径已确认存在 | 路由被删或版本回滚错误 | 核对发布记录 |
| DOWN | curl 退出码 6/7/28/35/52/56 | 网络层或进程层不可达 | 最高优先级告警 |
关于 503 需要额外说明:Kubernetes 的 readinessProbe 在容器未就绪时,kube-proxy 直接返回 503 而非连接失败,所以 503 在容器环境里往往表示「Pod 正在启动或已被摘除」,不一定是故障。脚本层面统一按 CRIT 处理,由告警侧根据持续时间做抑制(例如持续超过 3 分钟才升级)。
完整可运行脚本
下面这个脚本封装了检查函数、分级判定、连续失败计数与退出码映射,可直接放入 crontab 或接入 Zabbix / Prometheus textfile collector。
#!/usr/bin/env bash
# healthcheck.sh - 服务健康检查,带超时与状态码分级
# 退出码:0=OK 1=WARN 2=CRIT 3=DOWN(网络层失败)
set -uo pipefail
URL="${1:-http://127.0.0.1:8080/healthz}"
CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-2}"
MAX_TIME="${MAX_TIME:-5}"
EXPECT_MIN="${EXPECT_MIN:-200}"
EXPECT_MAX="${EXPECT_MAX:-299}"
VERBOSE="${VERBOSE:-0}"
# 判定状态码级别,输出 OK / WARN / CRIT
classify_http() {
local code="$1"
if [ "$code" -ge "$EXPECT_MIN" ] && [ "$code" -le "$EXPECT_MAX" ]; then
echo "OK"; return 0
fi
case "$code" in
301|302|303|307|308) echo "WARN" ;;
401|403|429) echo "WARN" ;;
500|502|503|504) echo "CRIT" ;;
400|404|405|422) echo "CRIT" ;;
*) echo "CRIT" ;;
esac
}
# 把 curl 退出码翻译成人话
curl_reason() {
case "$1" in
6) echo "DNS 解析失败" ;;
7) echo "连接被拒绝/端口未监听" ;;
28) echo "请求超时" ;;
35) echo "TLS 握手失败" ;;
52) echo "服务端空响应" ;;
56) echo "接收数据失败/连接重置" ;;
60) echo "证书校验失败" ;;
*) echo "curl 错误" ;;
esac
}
main() {
local body_file
body_file="$(mktemp)"
# shellcheck disable=SC2064
trap "rm -f '$body_file'" EXIT
# -s 静默进度 -S 出错时仍打印错误 -o 丢弃 body -w 输出状态码与耗时
# 不使用 -f,否则 4xx/5xx 会变成退出码 22,丢失真实状态码
local out
out="$(curl -sS -o "$body_file" \
--connect-timeout "$CONNECT_TIMEOUT" \
--max-time "$MAX_TIME" \
--retry 0 \
-A "healthcheck/1.0" \
-w '%{http_code} %{time_total} %{time_connect}' \
"$URL" 2>&1)"
local rc=$?
if [ $rc -ne 0 ]; then
local reason
reason="$(curl_reason $rc)"
echo "DOWN url=$URL curl_exit=$rc reason=$reason"
[ "$VERBOSE" = "1" ] && echo "detail: ${out}"
return 3
fi
local code total_time connect_time
code="$(echo "$out" | awk '{print $1}')"
total_time="$(echo "$out" | awk '{print $2}')"
connect_time="$(echo "$out" | awk '{print $3}')"
local level
level="$(classify_http "$code")"
# 可选:把状态码正常但耗时异常的情况也降级为 WARN
# 阈值取 MAX_TIME 的 80%,留出余量
local slow_flag=""
local slow_threshold
slow_threshold="$(awk -v m="$MAX_TIME" 'BEGIN{printf "%.2f", m*0.8}')"
if awk -v t="$total_time" -v s="$slow_threshold" 'BEGIN{exit !(t>s)}'; then
slow_flag=" slow=yes"
if [ "$level" = "OK" ]; then
level="WARN"
fi
fi
echo "level=$level http_code=$code total=${total_time}s connect=${connect_time}s url=$URL${slow_flag}"
case "$level" in
OK) return 0 ;;
WARN) return 1 ;;
CRIT) return 2 ;;
*) return 2 ;;
esac
}
main "$@"
使用方式:
chmod +x healthcheck.sh
# 基础调用
./healthcheck.sh http://127.0.0.1:8080/healthz
echo "exit=$?"
# 调整超时并开启详细输出
CONNECT_TIMEOUT=3 MAX_TIME=10 VERBOSE=1 ./healthcheck.sh https://api.example.com/health
# crontab 每分钟探测,失败时记录并按退出码交给告警
* * * * * /opt/scripts/healthcheck.sh http://127.0.0.1:8080/healthz >> /var/log/healthcheck.log 2>&1 || /opt/scripts/alert.sh "$?"
连续失败计数与抖动抑制
单次失败就告警会制造大量噪音:一次 GC 停顿、一次滚动发布、一次偶发丢包都会触发。生产实践是连续 N 次失败才升级告警,连续 M 次成功才恢复,这本质上是一个带迟滞的施密特触发器。
#!/usr/bin/env bash
# flapguard.sh - 在 healthcheck.sh 之上做连续失败计数
# 用法: flapguard.sh <state_file> <url>
set -uo pipefail
STATE_FILE="${1:?state_file required}"
URL="${2:?url required}"
FAIL_THRESHOLD="${FAIL_THRESHOLD:-3}"
RECOVER_THRESHOLD="${RECOVER_THRESHOLD:-2}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
fail_count=0
ok_count=0
state="OK"
if [ -f "$STATE_FILE" ]; then
# 文件格式: fail_count ok_count state
read -r fail_count ok_count state < "$STATE_FILE"
fi
"$SCRIPT_DIR/healthcheck.sh" "$URL"
rc=$?
case $rc in
0)
fail_count=0
ok_count=$((ok_count + 1))
if [ "$state" != "OK" ] && [ "$ok_count" -ge "$RECOVER_THRESHOLD" ]; then
state="OK"
echo "RECOVERED after $ok_count consecutive successes"
fi
;;
*)
ok_count=0
fail_count=$((fail_count + 1))
if [ "$fail_count" -ge "$FAIL_THRESHOLD" ] && [ "$state" = "OK" ]; then
state="ALERT"
echo "ALERT triggered: $fail_count consecutive failures (last rc=$rc)"
fi
;;
esac
echo "$fail_count $ok_count $state" > "$STATE_FILE"
[ "$state" = "ALERT" ] && exit 2
exit 0
状态文件放在 /var/lib/healthcheck/ 这类持久目录下。若脚本跑在容器里,需要把该目录挂成 volume,否则容器重启后计数清零,会重复告警。
接入 Prometheus textfile collector
如果已有 node_exporter,最省事的落地方式是让脚本输出 textfile 格式的指标文件,由 node_exporter 的 --collector.textfile.directory 采集。
#!/usr/bin/env bash
# emit_metrics.sh - 输出 Prometheus textfile 格式指标
set -uo pipefail
URL="${1:-http://127.0.0.1:8080/healthz}"
OUT_DIR="${OUT_DIR:-/var/lib/node_exporter/textfile}"
OUT_FILE="${OUT_DIR}/service_health.prom"
TMP_FILE="$(mktemp "${OUT_DIR}/service_health.XXXXXX")"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
result="$("$SCRIPT_DIR/healthcheck.sh" "$URL" 2>&1)"
rc=$?
http_code="$(echo "$result" | sed -n 's/.*http_code=\([0-9]*\).*/\1/p')"
total_time="$(echo "$result" | sed -n 's/.*total=\([0-9.]*\)s.*/\1/p')"
: "${http_code:=0}"
: "${total_time:=0}"
case $rc in
0) level=0 ;; # OK
1) level=1 ;; # WARN
2) level=2 ;; # CRIT
*) level=3 ;; # DOWN
esac
{
echo "# HELP service_health_level 0=OK 1=WARN 2=CRIT 3=DOWN"
echo "# TYPE service_health_level gauge"
echo "service_health_level{url=\"${URL}\"} ${level}"
echo "# HELP service_health_http_code 最近一次 HTTP 状态码, 0 表示未取得响应"
echo "# TYPE service_health_http_code gauge"
echo "service_health_http_code{url=\"${URL}\"} ${http_code}"
echo "# HELP service_health_duration_seconds 请求总耗时"
echo "# TYPE service_health_duration_seconds gauge"
echo "service_health_duration_seconds{url=\"${URL}\"} ${total_time}"
} > "$TMP_FILE"
# 原子替换,避免 node_exporter 读到半截文件
mv "$TMP_FILE" "$OUT_FILE"
配套的告警规则示例:
groups:
- name: service-health
rules:
- alert: ServiceDown
expr: service_health_level >= 3
for: 1m
labels:
severity: critical
annotations:
summary: "{{ $labels.url }} 不可达"
- alert: ServiceServerError
expr: service_health_http_code >= 500
for: 3m
labels:
severity: critical
- alert: ServiceSlow
expr: service_health_duration_seconds > 4
for: 5m
labels:
severity: warning
常见报错与排查
报错一:curl: (28) Operation timed out after 5001 milliseconds with 0 bytes received
这说明 TCP 已建连,但服务端在 --max-time 内没有返回任何字节。排查顺序:先在目标机上 curl -v --max-time 30 手工复现,看请求是否真的发出去了;再查应用日志是否卡在数据库连接池等待;最后检查是否被 iptables 的 DROP 规则静默丢包(DROP 表现为超时,REJECT 表现为连接被拒)。
报错二:curl: (7) Failed to connect to 10.0.1.15 port 8080: Connection refused
端口没监听。用 ss -lntp | grep 8080 确认进程是否还在;如果进程在但端口不存在,通常是 bind 到了 127.0.0.1 而非 0.0.0.0,检查配置里的 listen 或 bind_address。
报错三:curl: (52) Empty reply from server
最典型的「端口活着但进程假死」。常见于 Node.js 的 uncaught exception 后事件循环停止、Tomcat 线程池打满后 accept 但无法处理。健康检查能探测到这类问题正是它比单纯 telnet 端口更有价值的地方。
报错四:curl: (60) SSL certificate problem: certificate has expired
证书过期。健康检查脚本里绝不要图省事加 -k,那会永久掩盖证书问题。正确做法是把证书过期单独做成一项检查,例如 echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -noout -enddate 并解析剩余天数。
报错五:脚本输出 000 而不是状态码
如前所述,000 表示 curl 没有完成 HTTP 事务。你的判断逻辑如果是 if [ "$code" = "200" ] 就把 000 判成了失败,还算能工作;但如果逻辑写成 if [ "$code" != "000" ] 就判定健康,就会把故障当正常。务必先判退出码。
小结
一套可用的健康检查脚本要解决四件事,按优先级排列:先设超时(--connect-timeout 与 --max-time 同时给,且小于探测间隔),再分两层(curl 退出码管网络层,HTTP 状态码管应用层,顺序不能颠倒),然后做分级(2xx 正常、429/401/403 警告、5xx 严重、网络错误最高),最后加迟滞(连续 N 次失败才告警,连续 M 次成功才恢复)。这四点在脚本里各占十行左右代码,但决定了监控是成为运维的眼睛,还是成为半夜叫醒你的噪音源。
FAQ
健康检查应该探测 /healthz 还是业务接口?
两者都要,但用途不同。/healthz 这类专用端点做轻量探测,只反映进程存活与基础依赖(数据库连接、缓存连接),响应时间在毫秒级,适合 10 秒一次的高频探测。业务接口的探测频率放低到 1~5 分钟一次,它反映端到端可用性,但会真实产生业务副作用(写日志、算配额),不适合高频。切勿把有写操作的接口用于高频探测。
为什么不直接用 curl -f,失败了退出码非零不是更省事?
-f 会把 4xx 和 5xx 统一转成退出码 22,你就丢掉了「到底是 401 鉴权挂了还是 503 后端挂了」这个关键信息,而这两者的处置动作完全不同。健康检查的核心价值就在于分级,用 -f 等于主动放弃分级。只有在最简单的「活着/死了」二值探测里才考虑用 -f。
–max-time 设多少合适?
取 P99 响应时间的 2 到 3 倍,且必须小于探测间隔。实践中内网服务 3~5 秒、跨机房 8~15 秒是常见取值。设得过大,故障发现延迟变长;设得过小,一次正常的 GC 停顿就会误报。可以用 -w '%{time_total}' 先跑一周采集真实分布,再据此定阈值,而不是拍脑袋。
脚本返回 WARN 时监控系统该怎么处理?
WARN 不应触发自动摘流量,也不应走电话告警,只进日报或低优先级通道。典型场景是 429 限流:可能是流量确实涨了,也可能是客户端重试策略有问题,需要人判断。而 CRIT 和 DOWN 才进入值班响应链路。分级的意义就在于让不同级别走不同的通知渠道,避免告警疲劳。
容器环境里检查 localhost 还是 Service 域名?
两者都要,但对应不同故障域。检查 localhost:port 验证进程自身存活,检查 Service 域名(如 http://my-svc.ns.svc.cluster.local/healthz)验证 Service 的 Endpoints 是否正确挂载、kube-proxy 规则是否生效。只用 localhost 会漏掉「Pod 正常但没被 Endpoints 收录」这类故障,这是滚动发布中最常见的流量丢失原因。
脚本里为什么要用 mktemp 而不是固定的临时文件名?
健康检查常常是并发执行的(多个探针、多个目标),固定文件名会导致互相覆盖甚至读到别人的响应体。若脚本会被 root 执行,固定路径还带来符号链接攻击风险。mktemp 保证唯一性且权限为 600,配合 trap 在退出时清理,是脚本编程的基本功。