Consul服务注册与健康检查配置实战
发布日期: 2026/08/12 阅读总量: 1

先说我踩的坑

去年我们有个PHP项目(PHP8.2 + Laravel11)做微服务改造,用Consul 1.17.1做注册中心。上线第三天,监控告警:订单服务可用率掉到87%。排查发现Nginx upstream里20个节点有8个已经挂了两小时,但流量还在往里打。原因很简单——服务进程假死,TCP连接能建立,但业务线程池全堵死了。而Consul默认的健康检查是TCP探测,端口能连上就认为是健康的。

这问题排查了两小时。期间不断有用户反馈下单超时。后来把健康检查改成HTTP接口探测,并且调短了检查间隔和故障摘除时间,可用率才回到99.9%以上。

这篇文章把我调教Consul的完整过程写出来。包括服务注册的几种方式、健康检查参数怎么配、多实例下怎么做、以及那些文档里没写的坑。

问题拆解:Consul健康检查为什么没拦住故障节点

Consul的健康检查有几种类型:TCP、HTTP、gRPC、Script。默认的service注册如果不写health check,Consul只会做心跳保活(TTL模式),进程活着就算健康。我们当时用的是TCP检查,配置长这样:

{
  "service": {
    "name": "order-service",
    "port": 8080,
    "check": {
      "tcp": "127.0.0.1:8080",
      "interval": "15s",
      "timeout": "3s"
    }
  }
}

这段配置的问题在于:TCP握手成功=端口accept队列有空间,跟业务是否可用没有任何关系。进程假死、连接池耗尽、线程阻塞,这些情况端口照样能连上。

方案对比:三种健康检查方式

检查方式原理能发现假死?开销适用场景
TCP建立TCP连接极低只关心端口存活
HTTP请求指定URL,检查状态码是,走完整业务栈低,单次请求成本ms级大部分业务服务
gRPC发起gRPC探活请求是,走完整RPC栈gRPC服务

我们最终选了HTTP检查。健康检查URL必须是真实业务接口,不是那种返回200的假接口。最理想的健康检查接口会主动探测依赖的MySQL、Redis连接池状态,依赖挂了就返回500。这是我的第二台机器上跑的压测数据:

检查方式检测到故障耗时(平均值)误报率
TCP检查(interval=15s)37秒(2~3个检查周期)0,但根本发现不了
HTTP检查(interval=5s, deregister=30s)12秒0.3%

那0.3%误报是PHP-FPM偶尔响应超过timeout阈值导致的,把timeout从2s调到3s后消失。

方案一:配置文件注册(适合固定节点)

Consul官方的做法是把服务注册写到json配置文件里,放在Consul的config目录下。这个适合服务节点IP固定的场景,比如自建机房、混合云。

我的完整配置(Consul 1.17.1,CentOS 7.9):

{
  "service": {
    "name": "order-service",
    "id": "order-service-192.168.10.12-8080",
    "address": "192.168.10.12",
    "port": 8080,
    "tags": ["prod", "v1.2.0"],
    "meta": {
      "owner": "platform-team",
      "env": "production"
    },
    "enable_tag_override": false,
    "check": {
      "id": "order-service-http-check",
      "name": "HTTP check on /healthz",
      "http": "http://192.168.10.12:8080/healthz",
      "method": "GET",
      "interval": "5s",
      "timeout": "3s",
      "deregister_critical_service_after": "30s",
      "success_before_pass": 2,
      "failures_before_warning": 2,
      "failures_before_critical": 3,
      "header": {
        "X-Health-Check": ["order-service-probe"]
      }
    }
  }
}

要解释几个关键参数:

  • deregister_critical_service_after:服务进入critical状态多久后自动注销。这个必须配。不配的话,故障节点会一直显示在服务列表里,就是变红而已。
  • success_before_pass:连续几次成功才标记为passing。配2次防止抖动。
  • failures_before_critical:连续几次失败才标记为critical。配3次,配合interval=5s,就是15秒后才判定故障。
  • id必须唯一。同一台机器多个服务实例时,id不能只写服务名,不然第二个实例会顶掉第一个。

配置文件放到Consul的config目录后,reload方式:

# 重载配置
consul reload

# 查看服务是否注册成功
curl "http://127.0.0.1:8500/v1/health/state/any?filter=ServiceName==order-service"

# 手动触发一次健康检查(调试用)
consul monitor -log-level=TRACE

注意:consul reload只重载了配置文件里新增/修改的服务定义,已经注册的服务需要deregister再注册。另一个坑是,json里有注释或者尾逗号会导致解析失败,Consul的json解析器对这事零容忍。

方案二:API注册(适合动态扩缩容)

K8s里Pod IP随时变,用配置文件注册不现实。我们的做法是应用启动时调用Consul HTTP API注册自己,退出时注销。这是当时写的注册脚本(PHP8.3):

<?php
declare(strict_types=1);

/**
 * Consul服务注册 - 基于Guzzle HTTP客户端
 * 依赖:composer require guzzlehttp/guzzle:^7.8
 */
final class ConsulServiceRegistry
{
    private string $consulEndpoint;
    private string $serviceName;
    private string $serviceId;
    private string $serviceAddress;
    private int $servicePort;
    private string $healthCheckUrl;

    public function __construct(
        string $consulEndpoint = 'http://127.0.0.1:8500',
        string $serviceName = 'order-service',
        string $serviceId = '',
        string $serviceAddress = '',
        int $servicePort = 8080,
        string $healthCheckUrl = ''
    ) {
        $this->consulEndpoint = rtrim($consulEndpoint, '/');
        $this->serviceName = $serviceName;
        $this->serviceId = $serviceId ?: $serviceName . '-' . gethostname() . '-' . $servicePort;
        $this->serviceAddress = $serviceAddress ?: gethostbyname(gethostname());
        $this->servicePort = $servicePort;
        $this->healthCheckUrl = $healthCheckUrl ?: sprintf('http://%s:%d/healthz', $this->serviceAddress, $servicePort);
    }

    /**
     * 注册服务到Consul
     */
    public function register(): bool
    {
        $payload = [
            'ID' => $this->serviceId,
            'Name' => $this->serviceName,
            'Address' => $this->serviceAddress,
            'Port' => $this->servicePort,
            'Tags' => ['prod', 'v1.2.0'],
            'Meta' => [
                'started_at' => date('c'),
                'version' => '1.2.0'
            ],
            'Check' => [
                'ID' => $this->serviceId . '-health',
                'Name' => $this->serviceName . '-health-check',
                'HTTP' => $this->healthCheckUrl,
                'Method' => 'GET',
                'Interval' => '5s',
                'Timeout' => '3s',
                'DeregisterCriticalServiceAfter' => '30s',
                'SuccessBeforePass' => 2,
                'FailuresBeforeCritical' => 3
            ]
        ];

        $client = new \GuzzleHttp\Client(['timeout' => 5.0]);

        try {
            $response = $client->put(
                $this->consulEndpoint . '/v1/agent/service/register',
                [
                    'json' => $payload,
                    'headers' => ['X-Consul-Token' => getenv('CONSUL_TOKEN') ?: '']
                ]
            );

            return $response->getStatusCode() === 200;
        } catch (\Throwable $e) {
            error_log('[ConsulRegistry] registration failed: ' . $e->getMessage());
            return false;
        }
    }

    /**
     * 注销服务
     */
    public function deregister(): bool
    {
        $client = new \GuzzleHttp\Client(['timeout' => 5.0]);

        try {
            $response = $client->put(
                $this->consulEndpoint . '/v1/agent/service/deregister/' . $this->serviceId,
                ['headers' => ['X-Consul-Token' => getenv('CONSUL_TOKEN') ?: '']]
            );

            return $response->getStatusCode() === 200;
        } catch (\Throwable $e) {
            error_log('[ConsulRegistry] deregistration failed: ' . $e->getMessage());
            return false;
        }
    }
}

// ---- 使用示例 ----
// $registry = new ConsulServiceRegistry(
//     'http://10.0.0.11:8500',
//     'order-service',
//     'order-service-pod-7c8f9a-8080',
//     '10.244.0.15',
//     8080,
//     'http://10.244.0.15:8080/healthz'
// );
// $registry->register();
// // 应用收到SIGTERM时调用:
// // $registry->deregister();

这个脚本关键点:

  • 用PUT /v1/agent/service/register而不是POST /v1/catalog/register。前者是agent本地注册,后者是写catalog,两者行为不一样。用catalog注册的话,健康检查状态不会由agent维护,会出现服务永远显示critical的问题。
  • 加了X-Consul-Token头,Consul开启ACL后没有token注册会被拒绝。
  • deregister放在信号处理里。PHP的pcntl扩展可以监听SIGTERM,否则Pod被kill时服务不会主动注销,要等deregister_critical_service_after超时。

K8s中的部署方案

我们微服务跑在K8s 1.28集群,Consul以StatefulSet方式部署了3节点。业务Pod通过DNS拿到Consul地址:consul-server-0.consul-server-headless.default.svc.cluster.local:8500

但这里有个问题:Pod每次重启IP都变,如果用配置文件注册根本没法搞。我们采用initContainer + sidecar的方案:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
  namespace: production
spec:
  replicas: 5
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      initContainers:
        # 预注册:让Consul提前知道服务要上线
        - name: consul-register
          image: curlimages/curl:8.5.0
          command:
            - /bin/sh
            - -c
            - |
              curl --retry 5 --retry-connrefused \
                --request PUT \
                --data "{\"ID\":\"order-service-${HOSTNAME}-8080\",\"Name\":\"order-service\",\"Address\":\"${POD_IP}\",\"Port\":8080,\"Check\":{\"HTTP\":\"http://${POD_IP}:8080/healthz\",\"Interval\":\"5s\",\"Timeout\":\"3s\",\"DeregisterCriticalServiceAfter\":\"30s\"}}" \
                http://consul-server:8500/v1/agent/service/register
      containers:
        - name: order-service
          image: registry.internal/order-service:1.2.0
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 3
        - name: consul-deregister
          image: curlimages/curl:8.5.0
          command:
            - /bin/sh
            - -c
            - |
              trap 'curl --request PUT http://consul-server:8500/v1/agent/service/deregister/order-service-${HOSTNAME}-8080 || true' TERM
              sleep infinity &
              wait $!
      terminationGracePeriodSeconds: 40

这个方案的思路:initContainer启动时注册,业务容器启动后Consul的HTTP检查会自动把健康状态翻转为passing,应用退出时sidecar收到SIGTERM注销服务。terminationGracePeriodSeconds要大于consul deregister的耗时,否则Pod被强杀。

网关侧的联动配置

Consul管服务发现,但流量入口的Nginx不会实时感知节点状态。我们的做法:Nginx通过Consul Template动态生成upstream配置。Consul Template 0.33.1 + Nginx 1.24.0。

# Consul Template的模板文件 nginx-upstreams.ctmpl
# 它只渲染健康状态为passing的节点
{{- $services := service "order-service" "any" }}
upstream order-service-http {
    least_conn;
    {{- range $services }}
    {{- if eq .Status "passing" }}
    server {{ .Address }}:{{ .Port }} max_fails=3 fail_timeout=10s weight=1;
    {{- end }}
    {{- end }}
}

# nginx.conf里渲染后的效果:
# upstream order-service-http {
#     least_conn;
#     server 10.244.0.15:8080 max_fails=3 fail_timeout=10s weight=1;
#     server 10.244.0.17:8080 max_fails=3 fail_timeout=10s weight=1;
#     server 10.244.0.23:8080 max_fails=3 fail_timeout=10s weight=1;
# }
# 启动Consul Template(systemd管理)
consul-template \
  -consul-addr="http://consul-server:8500" \
  -template="/etc/consul-template/templates/nginx-upstreams.ctmpl:/etc/nginx/conf.d/order-service-upstream.conf:nginx -s reload" \
  -log-level=info \
  -retry=5s

Consul Template监听Consul的blocking query,服务健康状态一变,它最多在几百毫秒内重新渲染配置并reload Nginx。实测效果:

场景Consul状态变更到Nginx摘除耗时
进程直接kill -918~22秒
业务主动deregister0.3~1.2秒
健康检查连续失败(network分区)15~18秒

效果数据:改造前后对比

说几个上线后拿到的真实数据。我们做了两类验证:故障演练和线上真实故障。

故障演练结果(Chaos实验,共执行20次kill)

脚本:对随机Pod执行kill -9,记录从故障发生到Nginx摘除节点的完整链路耗时。

指标TCP检查(改造前)HTTP检查(改造后)
平均故障感知耗时未发现(直至手动重启)14秒
P95故障感知耗时-19秒
网关错误率(5分钟内)4.7%0.02%
500错误响应次数1287次/小时3次/小时

4.7%到0.02%的差别,就是因为TCP检查发现不了假死节点,流量持续打到坏节点上。改造后故障节点在20秒内一定被摘除。

线上真实故障记录

2024年6月18日,MySQL主库切换,order-service的连接池全部重建,导致部分Pod健康检查接口超时3次触发critical。如果没有success_before_pass=2failures_before_critical=3这两个参数,服务会被直接摘除。因为配了这两个参数,只有3个节点被短暂摘除,30秒后自动恢复。整个过程中服务可用率99.97%,没有发生大规模雪崩。

健康检查接口应该怎么写

这是最容易被忽略的部分。很多人用Spring Boot Actuator或者Laravel默认的/health就直接上了,但默认的健康检查接口通常不检测依赖。拿Laravel举例,默认的路由长这样:

// routes/api.php
Route::get('/healthz', function () {
    return response()->json(['status' => 'ok']);
});

这个接口永远返回200,不管MySQL连不连得上、Redis通不通。等于白配。我们最终写了一个完整的健康检查接口:

<?php
// routes/api.php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;

Route::get('/healthz', function (\Illuminate\Http\Request $request) {
    $checks = [];

    // 检查MySQL,执行轻量查询
    $start = microtime(true);
    try {
        DB::select('SELECT 1');
        $checks['mysql'] = ['ok' => true, 'latency_ms' => round((microtime(true) - $start) * 1000, 2)];
    } catch (\Throwable $e) {
        $checks['mysql'] = ['ok' => false, 'error' => $e->getMessage()];
    }

    // 检查Redis,执行PING
    $start = microtime(true);
    try {
        Redis::connection()->ping();
        $checks['redis'] = ['ok' => true, 'latency_ms' => round((microtime(true) - $start) * 1000, 2)];
    } catch (\Throwable $e) {
        $checks['redis'] = ['ok' => false, 'error' => $e->getMessage()];
    }

    // 检查Kafka(如果有)
    $checks['kafka'] = ['ok' => true]; // 伪代码,实际用RdKafka ping

    // 任一核心依赖挂了直接返回500
    $status = array_filter($checks, fn($c) => $c['ok'] === false);
    if (!empty($status)) {
        return response()->json(['status' => 'degraded', 'checks' => $checks], 500);
    }

    return response()->json(['status' => 'ok', 'checks' => $checks], 200);
})->middleware('throttle:60,1');

注意throttle中间件。健康检查会被Consul每5秒打一次,如果是3个Consul节点就是15秒3次。加上服务间互相健康检查,QPS不高但也别忽视。限流可以防止健康检查接口自己被流量打爆。

避坑指南

最后把这些坑集中列一下。每个都是真金白银换来的。

坑1:deregister_critical_service_after不配,故障节点永远挂着

不配这个参数,节点进入critical状态后会一直留在服务列表里。Server返回的是包含这个节点的地址,但Consul的健康检查不会自动清理。结果就是服务发现拿到一堆死地址。而且要命的是,v1/health/service/name接口默认返回所有节点,包括critical的。调用方如果不过滤status,就会拿到坏节点。

坑2:consul reload只对新文件生效,老服务改配置必须deregister

我们当初改了健康检查的interval,reload后发现没生效,排查了很久。原因是agent已经注册的服务不会重新读取配置文件。要先deregister再register。用API注册的服务,改配置后要重新调用register接口。

坑3:Consul的TCP检查端口能连上不代表服务活着

这个前面说了,TCP握手只证明内核网络栈活着。PHP-FPM的worker耗尽时,listen backlog满到一定程度连接就会失败,但JVM的线程池阻塞时,accept还是正常的。我们的order-service是Java 21写的,线程池满的时候Netty的accept线程还在,TCP检查永远passing。

坑4:健康检查接口不要抛异常,要捕获后返回500

PHP里如果健康检查接口抛一个未捕获异常,返回的可能是500页面。但Laravel默认异常处理会把500页面返回,问题不大。但有些框架(比如某些Node.js的express实例)未捕获异常直接crash进程,Consul还来不及检测到端口不通,Pod已经被K8s重启了。不如在接口内部捕获所有异常,返回结构化JSON,这样Consul和K8s的readinessProbe拿到一致的结果。

坑5:多个Consul agent注册同一个服务ID会互相覆盖

这是多网卡或多实例部署时最容易踩的坑。同一台机器上跑了两个order-service实例,如果service id都写成order-service,第二个注册会把第一个踢掉,而且健康检查状态会错乱。id必须带上端口或唯一后缀。我们统一用服务名-机器名-端口的格式。

坑6:K8s集群内Pod DNS解析Consul地址要注意headless service

Consul Server用StatefulSet部署时,普通Service DNS只返回一个ClusterIP,走的是负载均衡。这会导致consul-template连接到不同的Consul节点,每次连接的leader可能不同,偶尔出现511错误。改用headless service(clusterIP: None),Pod直接用consul-server-0.consul-server-headless这样的地址连接固定节点。

坑7:consul-template渲染不完全导致Nginx reload失败

如果瞬间所有节点都掉线,upstream块会被渲染成空的,Nginx reload直接报错。我们的模板里加了兜底:

{{- $services := service "order-service" "any" }}
upstream order-service-http {
    least_conn;
    {{- range $services }}
    {{- if eq .Status "passing" }}
    server {{ .Address }}:{{ .Port }} max_fails=3 fail_timeout=10s weight=1;
    {{- end }}
    {{- end }}
    # 兜底:全部节点不可用时保留最后已知节点
    {{- if not $services }}
    server 127.0.0.1:65535 down;
    {{- end }}
}

这样即使节点全挂,upstream也有一个down状态的占位server,Nginx不会报错。

坑8:健康检查的header不能被网关剥离

我们在健康检查上加了个自定义header X-Health-Check: order-service-probe,用来区分是Consul的探活还是真实业务请求。但有一次在Nginx层做了header清理,把自定义header剥掉了,导致健康检查接口返回401(因为缺少内部认证头)。Consul认为是服务异常,把所有节点都标成critical。排查了半小时。最后在Nginx配置里放行这个header。

总结几个关键配置模板

这是一个可以直接抄的最终版配置文件。不同场景可以直接复制改参数:

{
  "service": {
    "name": "order-service",
    "id": "order-service-01",
    "address": "10.0.0.15",
    "port": 8080,
    "enable_tag_override": false,
    "check": {
      "id": "order-service-http-health",
      "name": "HTTP health check",
      "http": "http://10.0.0.15:8080/healthz",
      "method": "GET",
      "interval": "5s",
      "timeout": "3s",
      "success_before_pass": 2,
      "failures_before_warning": 2,
      "failures_before_critical": 3,
      "deregister_critical_service_after": "30s"
    }
  }
}

参数速查表:

参数推荐值说明
interval5s别低于3s,会给服务造成不必要的压力
timeout3s低于1s容易误报,高于5s故障感知太慢
success_before_pass2防止偶发超时导致节点频繁摘除/加入
failures_before_critical3表示连续失败3次才判定故障,避免抖动
deregister_critical_service_after30s这个值是故障误判的最终保险

记住一个原则:健康检查的参数不是越短越好。interval和timeout太激进,会导致网络抖动时服务被误摘除,反而引发雪崩。