Consul健康检查配置实战:从误判到秒级摘除
发布日期: 2026/08/06 阅读总量: 1

一次由健康检查引发的「事故」

凌晨1点47分,支付服务 production-03 节点 OOM。进程还活着,端口还监听着,但请求进来全部超时。Consul 的 check 配置的是 tcp 端口探测——端口通,状态就是 passing。Nginx upstream 继续往这个半死不活的节点打流量,支付失败率飙升到 34%,耗时 47 分钟才定位到问题。

随后我做了个实验:同一台机器上,把健康检查从 tcp 改成 http,指向业务真实的 /healthz 端点,故障节点在 2.5 秒内被摘除。就这么简单,但大多数团队的 Consul 健康检查配置,都还停留在我出事故之前的水平。

Consul 健康检查的三种类型

Consul 支持三类健康检查,对应三种不同的探测思路:

检查类型探测方式适用场景常见误判点
scriptAgent 执行本机脚本磁盘占用、进程存活、自定义逻辑脚本执行权限、超时、依赖 curl/jq
httpAgent 发起 HTTP 请求,校验状态码有 HTTP 端口的服务,业务健康检查状态码范围、超时设太短、内部依赖
tcpAgent 建立 TCP 连接无 HTTP 端口的服务(数据库、缓存)端口通但服务不可用,完全测不出来

注意:Consul 还提供 TTL 检查(业务主动上报心跳),但官方文档明确说「TTL 检查仅适用于无法用脚本或 HTTP 探测的场景」,且需要业务代码配合,徒增复杂度。我们的实践结果是:除非是任务型服务(跑批、队列消费者),否则别用 TTL。

方案对比:HTTP 检查 vs 脚本检查

事故之后,我在两个业务团队分别推行了不同的健康检查方案,做了一轮对比:

方案 A:脚本检查(团队 A 原方案)

团队 A 用 Consul 官方推荐的 script 方式,脚本内容简单粗暴:检测进程存在性 + 端口连通性。这是他们最初的配置,暴露了所有问题:

#!/usr/bin/env bash
# /etc/consul/scripts/check_payment.sh
pid=$(pgrep -f "payment-service")
if [[ -z "$pid" ]]; then
  echo "payment-service process not found"
  exit 2
fi

port=$(ss -lnt | awk '$4 ~ /:8080$/ {print $4}')
if [[ -z "$port" ]]; then
  echo "port 8080 not listening"
  exit 2
fi

echo "payment-service is running"
exit 0

这个脚本的问题很明显:进程在、端口通,不代表服务能处理请求。OOM 后 JVM 进程没死,端口照样监听,脚本照样返回 exit 0。

方案 B:HTTP 检查(团队 B 改进方案)

团队 B 把所有业务服务统一暴露 /healthz 端点,HTTP 检查直连这个端点,不仅验证进程存活,还验证数据库连接、缓存连接、关键依赖是否可用。

{
  "service": {
    "name": "payment-service",
    "id": "payment-service-prod-03",
    "tags": ["production", "backend"],
    "address": "10.24.3.15",
    "port": 8080,
    "check": {
      "id": "payment-service-http-health",
      "name": "Payment Service HTTP Health Check",
      "http": "http://10.24.3.15:8080/healthz",
      "method": "GET",
      "interval": "10s",
      "timeout": "2s",
      "deregister_critical_service_after": "1m"
    }
  }
}

对比数据

对比项脚本检查(方案A)HTTP检查(方案B)
摘除故障节点耗时最大 30s(15s interval + 15s timeout)最大 12s(10s interval + 2s timeout)
能否发现 OOM 假死不能能(进程活着但 /healthz 超时)
能否检测数据库依赖不能能(健康端点内部检查 DB 连接)
每月误诊次数(100节点)6 次0 次
每次探测平均耗时180ms45ms
脚本/端点代码维护成本每台机器单独维护 bash随业务代码统一维护

这个对比不是说脚本检查一无是处。磁盘占用这类系统级指标,脚本检查仍然是唯一选择。但业务服务的健康检查,HTTP 检查是唯一可靠的方案。

原理:Consul Health Check 是怎么工作的

理解配置项背后的机制,才能写对配置。Consul 的健康检查由客户端 Agent(不是 Server)执行。

检查执行流程

每个 Consul Agent 上运行着一个 check runner,每隔 interval 执行一次检查。执行结果有四个状态:

  • passing(绿色):服务正常
  • warning(黄色):服务可用但有隐患(如磁盘超过阈值)
  • critical(红色):服务不可用,摘除流量
  • maintenance:人为下线维护

状态迁移规则:一次性检查失败立即进入 critical;连续两次成功才回到 passing,这是为了避免服务在「半可用」状态时反复切换。

摘除与恢复

当服务状态为 critical 时,Consul 不会立即注销服务,而是等待一个「宽限期」,即 deregister_critical_service_after 参数。宽限期内,服务在 Catalog 中仍然存在,但 DNS 查询和 /v1/health/service/ 接口返回的 passing 列表会排除这个节点。

如果宽限期后仍然 critical,Agent 直接注销服务。若期间恢复为 passing,服务自动重新参与流量分发。

对下游流量的影响链路

下游服务(如 Nginx、API 网关)发现服务状态变更,依赖两种机制:主动拉取或被动监听。主动拉取的场景,获取的服务列表会自动过滤掉非 passing 节点;被动监听则需要 Consul Template 或 Watch 机制触发更新。

# 拉取 passing 状态的服务列表
curl -s "http://127.0.0.1:8500/v1/health/service/payment-service?passing=true" | jq '.[].Service | {id, address, port}'

这个 passing=true 参数是整个健康检查体系的关键:如果下游在查询时没加这个参数,Consul 返回所有注册的服务,包括 critical 状态——等于把健康检查全废了。我们在生产环境确实抓到过这样的案例。

完整配置与代码实现

1. Consul Agent 基础配置

版本信息:Consul 1.18.2(2024年发布的版本),CentOS 7.9,Nginx 1.24.0。

# /etc/consul/consul.hcl
data_dir = "/opt/consul"

# 客户端模式,本机服务注册到这个 Agent
server = false

# Agent 监听地址
client_addr = "0.0.0.0"

# 通过私有网卡 IP 通信
bind_addr = "{{ GetPrivateInterfaces | exclude \"type\" \"ipv6\" | attr \"address\" }}"

# 加入已有的 Consul 集群(生产环境至少 3 个 Server)
retry_join = ["10.0.0.11", "10.0.0.12", "10.0.0.13"]

# 必须开启,否则 script 检查无法执行
enable_script_checks = true

# 日志级别
log_level = "INFO"

# 检查结果本地缓存时间,避免频繁请求 Server
check_reap_interval = "30s"

2. 服务注册配置

每个服务在 Agent 的配置目录下创建一个 JSON 文件,Agent 启动时自动加载。生产环境务必给每个实例设置独立的 id,避免多实例注册冲突。

{
  "service": {
    "name": "payment-service",
    "id": "payment-service-prod-03",
    "tags": ["production", "backend", "v1.2.3"],
    "address": "10.24.3.15",
    "port": 8080,
    "meta": {
      "owner": "payment-team",
      "maintainer": "oncall@example.com"
    },
    "check": {
      "id": "payment-service-http-health",
      "name": "Payment Service HTTP Health Check",
      "http": "http://10.24.3.15:8080/healthz",
      "method": "GET",
      "interval": "10s",
      "timeout": "2s",
      "deregister_critical_service_after": "1m"
    }
  }
}

3. 健康检查端点实现(PHP 8.3)

健康检查端点不是简单返回 200。它必须验证服务对外提供能力的核心依赖。

 2,
                PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_PERSISTENT => false,
            ]
        );
        $pdo->query('SELECT 1');
        return [
            'status' => 'ok',
            'latency_ms' => (int) ((microtime(true) - $start) * 1000),
        ];
    } catch (Throwable $e) {
        return [
            'status' => 'error',
            'error' => substr($e->getMessage(), 0, 200),
        ];
    }
}

/**
 * 检查 Redis 连接
 */
function checkRedis(): array
{
    $start = microtime(true);
    try {
        $redis = new Redis();
        $redis->connect('127.0.0.1', 6379, 2);
        $redis->ping();
        return [
            'status' => 'ok',
            'latency_ms' => (int) ((microtime(true) - $start) * 1000),
        ];
    } catch (Throwable $e) {
        return [
            'status' => 'error',
            'error' => substr($e->getMessage(), 0, 200),
        ];
    }
}

/**
 * 检查磁盘空间(核心业务日志分区)
 */
function checkDisk(): array
{
    $freeBytes = disk_free_space('/var/log/payment');
    $totalBytes = disk_total_space('/var/log/payment');
    if ($freeBytes === false || $totalBytes === false) {
        return ['status' => 'error', 'error' => 'unable to stat disk'];
    }
    $freePercent = round($freeBytes / $totalBytes * 100, 2);
    return [
        'status' => $freePercent < 10 ? 'error' : 'ok',
        'free_percent' => $freePercent,
    ];
}

$checks = [
    'database' => checkDatabase(),
    'redis'    => checkRedis(),
    'disk'     => checkDisk(),
];

$httpCode = 200;
foreach ($checks as $key => $result) {
    if ($result['status'] !== 'ok') {
        $httpCode = 503;
        break;
    }
}

http_response_code($httpCode);
echo json_encode([
    'status' => $httpCode === 200 ? 'pass' : 'fail',
    'timestamp' => time(),
    'checks' => $checks,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

注意几个要点:

  • 健康检查请求必须轻量,我们要求 P95 小于 100ms。如果健康检查本身耗时超过 Consul 的 timeout 2s,就会造成误判
  • 数据库连接不能用持久连接,否则每次健康检查会复用同一个连接,一旦连接断开且无法重连,健康检查就一直失败
  • 所有依赖检查必须串行执行,任何一个失败就直接返回 503,因为摘除一个节点比让一半流量打到故障点好太多

4. 动态服务注册:无需重启 Agent

Agent 加载配置文件只在启动时执行一次。新增服务或者临时变更,调用 HTTP API 直接注册。

# 注册服务(service.json 就是上面的注册配置)
curl -X PUT http://127.0.0.1:8500/v1/agent/service/register \
  -H 'Content-Type: application/json' \
  -d @service.json

# 注销服务
curl -X PUT http://127.0.0.1:8500/v1/agent/service/deregister/payment-service-prod-03

# 查看当前 Agent 注册的所有服务及健康状态
curl -s http://127.0.0.1:8500/v1/agent/services | jq .

5. 脚本检查的正确用法:系统级监控

脚本检查仍然有用,但应该留给脚本真正擅长的场景——系统级检查。

#!/usr/bin/env bash
# /etc/consul/scripts/check_disk.sh
# 检查根分区可用空间,超过 85% 告警,超过 95% 判死

set -euo pipefail

WARN_THRESHOLD=85
CRIT_THRESHOLD=95

USAGE=$(df / --output=pcent | tail -1 | tr -dc '0-9')

if [[ "$USAGE" -ge "$CRIT_THRESHOLD" ]]; then
  echo "DISK CRITICAL: ${USAGE}% >= ${CRIT_THRESHOLD}%"
  exit 2
fi

if [[ "$USAGE" -ge "$WARN_THRESHOLD" ]]; then
  echo "DISK WARNING: ${USAGE}% >= ${WARN_THRESHOLD}%"
  exit 1
fi

echo "DISK OK: ${USAGE}%"
exit 0

对应的服务定义片段:

{
  "service": {
    "name": "node-exporter",
    "id": "node-exporter-10-24-3-15",
    "address": "10.24.3.15",
    "port": 9100,
    "check": {
      "id": "node-exporter-disk",
      "name": "Root partition disk usage",
      "args": ["/bin/bash", "/etc/consul/scripts/check_disk.sh"],
      "interval": "60s",
      "timeout": "5s"
    }
  }
}

6. 流量自动摘除:Nginx + Consul Template

生产环境使用 Nginx 做流量入口,Consul Template 监听服务变更并自动重写 upstream。

# /etc/consul-template/templates/payment-upstream.tpl
upstream payment_backend {
    least_conn;
{{- range service "payment-service" }}
    server {{ .Address }}:{{ .Port }} max_fails=3 fail_timeout=30s;
{{- end }}
}

server {
    listen 443 ssl;
    server_name payment.example.com;

    location / {
        proxy_pass http://payment_backend;
        proxy_connect_timeout 3s;
        proxy_read_timeout 10s;
        proxy_next_upstream error timeout http_502 http_503;
    }
}
# /etc/consul-template/consul-template.hcl
consul {
  address = "127.0.0.1:8500"
}

template {
  source      = "/etc/consul-template/templates/payment-upstream.tpl"
  destination = "/etc/nginx/conf.d/payment-upstream.conf"
  command     = "/usr/sbin/nginx -s reload"
}

关键点:range service "payment-service" 默认只遍历 passing 状态的实例。Consul Template 检测到服务状态从 passing 变 critical,会在 command 指定的时间内触发 Nginx reload。

这套链路完整跑通的时序:

  1. Consul Agent 执行 HTTP 健康检查,发现 /healthz 返回 503
  2. 10s 后再次检查,仍返回 503,服务状态从 passing 变为 critical
  3. Consul Template 监听到服务列表变化,重新渲染 upstream,去掉故障节点
  4. Nginx reload,新配置生效

从故障发生到流量摘除,总耗时约 10-20 秒。相比 OOM 事故那天的 47 分钟,天壤之别。

效果数据:用了三个月,误判归零

事故后,我们把所有核心业务服务的健康检查都改成 HTTP 检查,并配齐了 Consul Template 自动摘除。选了 100 个业务节点统计了三个月数据:

指标改造前(TCP检查)改造后(HTTP检查)
故障节点摘除耗时无法摘除(端口通)10-20s
误诊次数(100节点/月)6 次0 次
健康检查请求 P95 耗时不适用85ms
健康检查请求 P99 耗时不适用210ms
因健康检查导致的 5xx 请求26 次/月3 次/月(均为部署窗口)
Consul Agent CPU 占用(每节点)0.8%1.2%

摘除延迟为什么不是「秒级」而是一个区间?因为 Consul Template 的渲染和 Nginx reload 有固定开销,生产环境实测从 Consul 标记 critical 到 Nginx 配置更新完成,最快 2.5s(interval 设为 5s 时),最慢 15s(interval 为默认 10s 时)。

避坑指南:我们逐个踩过的坑

坑 1:enable_script_checks = false,脚本检查全部静默失败

Consul 1.0 之后,出于安全考虑,默认关闭脚本检查。没有显式开启的情况下,script 类型的检查不会执行,也不报错,服务会一直显示为 passing——你永远不知道服务已经挂了。

解决:Consul Agent 配置中显式设置 enable_script_checks = true,并在每个脚本文件上执行 chmod +x,确保文件属主是 Consul Agent 运行用户。

坑 2:HTTP 检查被 Nginx 重定向篡改状态码

服务前面套着 Nginx,Nginx 配置里写了 return 302 https://$host$request_uri 做 HTTPS 跳转。Consul 的 HTTP 检查默认不跟随重定向,收到 302 视为检查失败,所有服务全部处于 critical,流量被全部摘除,线上直接雪崩。

解决:健康检查必须走内网直连,绕开所有可能改写请求的中间层。我们的做法是 Consul Agent 的 HTTP 检查直接指向业务服务本身的 8080 端口,不经过 Nginx。

坑 3:deregister_critical_service_after 设置过短

发布新版本时,服务需要重启,健康检查状态会短暂变成 critical。如果 deregister_critical_service_after 设置成 10s,服务重启需要 15s,Consul 直接把这个服务注销了。服务启动后重新注册,但 Consul 的 raft 索引已经变化,Consul Template 重新渲染,Nginx reload 一次,连接被切断,造成发布期间的 5xx 错误。

解决deregister_critical_service_after 设置不小于发布窗口,通常 1m-3m。如果服务需要 1 分钟重启,你不可能接受 1 分钟内流量打到空端口上——Consul 的 critical 状态就已经让流量摘除了,这个参数只是最终回收「僵尸服务」的兜底,不是摘除流量的触发器。

坑 4:健康端点验证了所有依赖,导致雪崩放大器

我们把 Redis 连接写进健康检查,结果 Redis 抖动 2 秒,健康检查失败,Consul 摘掉 30 个节点中的 10 个,剩下 20 个节点扛不住流量,也跟着 Redis 超时,被进一步摘除——最终 30 个节点全部 critical,整个集群雪崩。

解决:健康检查只判断「当前节点能否继续处理请求」,不判断「依赖是否健康」。如果 Redis 挂了,请求进来返回 503,那节点确实不能处理请求,摘除是合理的。但只依赖 Redis 的部分功能(如缓存)不应该出现在健康检查里。我们的最终策略是健康检查只验证本地 DB 连接(因为业务强依赖 DB),Redis 降级后不影响核心支付流程。

坑 5:TCP 检查的隐蔽危害

TCP 检查用 nctimeout 2 bash -c ' 探测端口。对于纯 TCP 协议服务(如 Redis、MySQL),这是可行的。但对于 HTTP 服务,TCP 检查只验证「内核 accept 队列有位置」,不验证应用线程是否空闲。OOM 事故就是这么漏过去的——JVM 进程活着,端口监听正常,但业务线程池已经全部阻塞。

解决:HTTP 服务一律使用 HTTP 检查。如果是 TCP 服务,至少使用 grpc 类型的健康检查(Consul 1.9+ 支持),基于 gRPC 健康检查协议,能够探测到应用层的存活状态。

最终的建议配置

如果你现在就要调整配置,从这套开始:

{
  "service": {
    "name": "your-service",
    "id": "your-service-$(hostname)",
    "tags": ["production"],
    "port": 8080,
    "check": {
      "id": "your-service-http",
      "name": "HTTP Health Check",
      "http": "http://127.0.0.1:8080/healthz",
      "interval": "10s",
      "timeout": "2s",
      "deregister_critical_service_after": "1m"
    }
  }
}

配合 Consul Template 自动更新 Nginx upstream,这套方案能覆盖大部分业务场景。等你在生产环境跑一段时间,再根据实际情况调整 interval 和 timeout。