先说我踩的坑
去年我们有个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 -9 | 18~22秒 |
| 业务主动deregister | 0.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=2和failures_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"
}
}
}
参数速查表:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| interval | 5s | 别低于3s,会给服务造成不必要的压力 |
| timeout | 3s | 低于1s容易误报,高于5s故障感知太慢 |
| success_before_pass | 2 | 防止偶发超时导致节点频繁摘除/加入 |
| failures_before_critical | 3 | 表示连续失败3次才判定故障,避免抖动 |
| deregister_critical_service_after | 30s | 这个值是故障误判的最终保险 |
记住一个原则:健康检查的参数不是越短越好。interval和timeout太激进,会导致网络抖动时服务被误摘除,反而引发雪崩。