服务健康检查脚本:curl 超时与状态码分级判定

用 curl 做服务健康检查几乎是所有运维脚本的标配,但大多数实现只写了一行 curl -s -o /dev/null -w "%{http_code}",然后判断是否等于 200。这种写法在真实生产环境里会暴露三个问题:没有设置超时导致脚本被挂死、把「服务不可达」和「服务返回业务错误」混为一谈、无法区分 HTTP 状态码中哪些属于自身异常、哪些属于上游依赖

用 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 在退出时清理,是脚本编程的基本功。