1. 从单节点到集群:这次折腾要解决的根本问题
1.1 单节点注册中心到底缺什么
如果你是从这个系列一路看过来的,前面我们已经把 .NET Core 服务的注册、健康检查、服务发现这些基础流程都跑通了一遍。前几篇用单个 Consul 节点操作非常舒服:命令少、界面直接、日志全在一个窗口里滚动,出问题也能一眼定位。到了要给团队演示,或者让服务真正跨夜运行时,单节点的短板就毫无遮拦地暴露出来了。
我经历过一次很典型的三连事故。第一次是宿主机器内存告警被自动重启,Consul 和数据目录一起断电,服务列表全丢;第二次是某个服务实例崩溃后,健康检查没有及时把它标记为异常,调用方还在往一个没在监听的端口打请求;第三次是我自己误改了工作目录,把 Consul 的存储路径换掉,启动时直接数据加载失败。三件事性质完全不同,但都指向同一个事实:注册中心是微服务里的交通调度岗位,它只需要眨眼几分钟,整个系统的服务调用链条就会跟着瘫痪。所谓高可用,首先不是服务本身多写几个副本,而是让注册中心自己不成为故障点。
这篇文章是系列的第四篇,重点就是解决这个问题:用三个节点组成 Consul 集群,让注册数据具备冗余副本,并演示 .NET Core 客户端如何改造到不依赖某个固定节点。同时也把 quorum、leader 选举、故障恢复这些平时文档里只说概念、不说实操的内容,用实际演练的方式过一遍。适合已经会用单机 Consul、正在准备把基础设施推上生产的朋友。
1.2 用一次团建集合来理解 quorum
引入集群之后遇到的第一道坎,是“一条数据到底写到哪里,才算真的写成功了”。单节点时代没有这个问题,写进去就是成功。有了多个节点,情况变了:A 收到了请求,B 也收到了请求,C 恰好断电,那这条记录到底算不算在系统里?不同节点上的视图如果不一致,服务发现就会给出互相矛盾的答案。
Consul 用 Raft 协议来解决这个问题,其中最关键的概念是 quorum,也就是法定人数。我一般跟同事这样类比:假设一个部门五个人,周末组织团建,负责人只需要收到其中三个人回复“收到”,就可以按计划开始集合。哪怕另外两个人因为手机没电完全失联,三个人也足以代表大家作出决定。但如果只有两个人回复,那就不敢动,因为你没法判断是“还有三个人在路上”还是“大家根本没打算来”。
对应到 Consul 集群里,所有 server 节点一起选出 leader,一起决定哪些服务注册数据是有效的。一条写请求至少要被多数节点接受,才算是真实提交。三个节点的集群,多数就是两个;五个节点的集群,多数就是三个。这个“过半”的特性,就是集群能容忍单台机器掉线的根源。理解了这一点,后面看启动参数、看故障演练,都会顺畅很多。
2. quorum 机制与节点角色,动手之前必须想清楚的几件事
2.1 服务器节点和客户端节点的分工边界
Consul 集群里有两类角色:server 节点参与选举、保存全部数据、对写入投票;client 节点不参与选举,只负责转发请求和执行健康检查。这句话很多教程都会提,但真正动手的人往往把它忽略了,结果要么把全部节点都配成 server,要么对 client 节点的定位完全没概念。
server 节点是集群的“数据面”,每一个 server 都保存一份完整数据副本,同时参与 Raft 投票。数量多了,可用性上限会提高,但不是越加越多越好。每次选举和心跳都要在所有 server 之间通信,节点太多会让写入路径变慢。生产环境常见的做法是 3 个或 5 个 server,再多通常没有收益。client 节点则是为了扩展接入面准备的,当应用实例数量很大时,可以让它们指向就近的 client,由 client 统一转发到 server,从而降低 server 的压力。
初次搭建的朋友很容易陷入一个误区:五台机器全部配置成-server,以为机器多就是集群大。实际生产里,server 承担的是数据一致性和选主,client 承担的是服务注册和健康检查的接入,两者是不同维度的分工。本篇文章的演练阶段只需要三个 server 节点,先把原理跑通,后续你按需再加 client 节点即可。
2.2 端口规划:8300/8301/8302/8500/8600
搭集群之前,先把端口搞清楚。很多集群起不来,根源就在 8300 和 8301 被防火墙挡了,日志来回刷Failed to join,但新手往往盯着 8500 查半天,方向完全不对。
| 端口 | 用途 | 是否需要对外开放 |
|---|---|---|
| 8300 | server 之间的 Raft 通信 | 只在集群节点之间开放,不要暴露公网 |
| 8301 | Serf LAN gossip,节点间互相探测 | 集群所在局域网内互通 |
| 8302 | Serf WAN gossip,跨数据中心通信 | 有跨数据中心需求时开放 |
| 8500 | HTTP API 与管理界面 | 服务注册、查询、运维访问都走这里 |
| 8600 | DNS 接口 | 按需给内网服务开放 |
使用 Docker 容器时还要注意,容器内部的 8500 映射到宿主机是可以改端口的,比如文档后面示例里把三个节点的 8500 分别映射成 18500、28500、38500,就是为了避免和本机原有服务冲突。但 8300 和 8301 在容器间通过自定义网络直接互通,不需要映射到宿主机。这个设计一开始看着绕,实际是为了在不同机器部署时保持整洁。
2.3 奇数节点原则和常见规模选择
为什么 Consul 官方一直推荐奇数个 server?回到 quorum 公式:quorum = n/2 + 1,结果向下取整。套进去看就很清楚了:
- 3 个节点:quorum 为 2,能容忍丢失 1 个节点
- 5 个节点:quorum 为 3,能容忍丢失 2 个节点
- 4 个节点:quorum 为 3,依然只能容忍丢失 1 个节点,却比 3 节点多一台机器的成本,还多一份投票通信开销
所以 4 节点是个尴尬配置,花了钱没提升容错能力。不管是云主机还是虚拟机,只要资源允许,就从 3 节点起步。3 节点基本覆盖了大多数中小团队的可用性需求,5 节点一般出现在跨机房或更大规模的系统里。本文全部按 3 节点来搭建。
3. Docker Compose 搭建三节点 Consul 集群的完整配置
3.1 准备一个干净的 compose 文件
这一段直接给你一份能跑起来的三节点配置。我在本机用 Docker Compose 模拟三台独立主机,实际生产环境换到三台真实机器上,只要把-retry-join里的主机名改成真实地址,逻辑完全一致。
version: "3.8" networks: consul-net: driver: bridge volumes: consul-data-1: consul-data-2: consul-data-3: services: consul-node1: image: consul:1.18.0 container_name: consul-node1 hostname: consul-node1 restart: always command: > agent -server -bootstrap-expect=3 -node=consul-node1 -bind=0.0.0.0 -client=0.0.0.0 -datacenter=dc1 -data-dir=/consul/data -ui -retry-join=consul-node2 -retry-join=consul-node3 ports: - "18500:8500" - "18600:8600" volumes: - consul-data-1:/consul/data networks: - consul-net consul-node2: image: consul:1.18.0 container_name: consul-node2 hostname: consul-node2 restart: always command: > agent -server -bootstrap-expect=3 -node=consul-node2 -bind=0.0.0.0 -client=0.0.0.0 -datacenter=dc1 -data-dir=/consul/data -ui -retry-join=consul-node1 -retry-join=consul-node3 ports: - "28500:8500" - "28600:8600" volumes: - consul-data-2:/consul/data networks: - consul-net consul-node3: image: consul:1.18.0 container_name: consul-node3 hostname: consul-node3 restart: always command: > agent -server -bootstrap-expect=3 -node=consul-node3 -bind=0.0.0.0 -client=0.0.0.0 -datacenter=dc1 -data-dir=/consul/data -ui -retry-join=consul-node1 -retry-join=consul-node2 ports: - "38500:8500" - "38600:8600" volumes: - consul-data-3:/consul/data networks: - consul-net用docker compose up -d启动后,观察三个容器日志,如果最后都出现 leader 相关日志,说明这一步走通了。下面拆解一下这些参数到底做了什么,不然你后面遇到奇怪问题只能猜。
3.2 启动参数逐项拆解
-server声明这个节点是 server 角色。没有这个参数,节点默认是 client,不参与选举也不存数据。三个节点全部加-server,这是集群的基石。
-bootstrap-expect=3是集群引导的关键参数,它告诉节点:等收集到三个 server 之后,再开始选举 leader。这样做是为了避免第一个启动的节点自作主张成为 leader,导致数据视图和后续加入的节点不一致。它和-bootstrap完全是两回事,-bootstrap是允许当前节点直接自己引导出一个单节点集群,只适合临时调试,集群环境绝对不要用。
-node指定节点名称,在集群里必须是唯一的。-datacenter=dc1是数据中心名称,跨机房部署时用来区分逻辑区域,单机房保持默认 dc1 即可。-bind=0.0.0.0让 gossip 通信监听所有网络接口,这在 Docker 里最省事;如果是物理机部署,也可以显式写成内网 IP。-client=0.0.0.0是把 HTTP 和 DNS 接口开放给所有来源,方便外部服务和 UI 访问。
-data-dir=/consul/data指定数据存放目录,必须用命名卷或云盘持久化,否则容器重建后注册数据全部蒸发。-ui开启内置管理界面。-retry-join是加入已有集群的地址列表,可以写多个,三个节点互相把对方写进列表,这样任何两个节点先启动都会等待其余节点到来,而不是各自为政。
3.3 第一次启动最容易踩的几个坑
第一个常见的坑,Failed to join报错。consul-node1启动后一直连不上consul-node2,日志里反复出现No path to DC。出现这个问题,先检查两个容器是否在同一个 Docker 网络里,再确认宿主机防火墙有没有拦掉 8301 端口。跨主机部署时,-retry-join必须写对方机器的实际可达 IP,不是容器 hostname。
第二个坑,Multiple Private IPv4 addresses found。宿主机上有多个私网网卡时,Consul 不知道该选哪个作为内部通信地址,启动直接报错。解决办法是-bind指定具体 IP,或者在 Docker 网络里只保留一张网卡接口。
第三个坑更隐蔽:三个节点明明都启动了,consul members也能看到彼此,但 UI 操作一直超时。原因多半是 8500 端口没有正确映射出来,容器内部正常,宿主机访问不到。用前面的示例映射,记得访问的是http://localhost:18500,而不是原始的 8500。
第四点关于启动顺序。三个节点的-bootstrap-expect=3都是 3,意味着任何单独节点都拒绝自己启动集群。所以别一个个慢慢来,尽量一起启动,或者确保后面两个节点在几分钟内跟上,否则日志一直停留在等待状态,这是正常现象,不属于故障。
4. 验证与容灾演练:集群不是搭完就算完事
4.1 三条命令确认集群真的成形
保险起见,集群起来之后先做三件事。第一,看成员列表:
docker exec consul-node1 consul members正常输出会看到三行,Status 都是alive,Type 都是server。如果某个节点是left或者failed,说明它还没真正加入。
第二,看 Raft 选主情况:
docker exec consul-node1 consul operator raft list-peers输出里会有一个节点的 State 是leader,另外两个是follower,并且 Voter 都是true。看到这样的组合,代表 quorum 已经形成,数据写入有了多数节点背书。
第三,用 HTTP 接口验证对外服务。任意节点 IP 都可以,例如:
curl http://127.0.0.1:18500/v1/status/leader curl http://127.0.0.1:18500/v1/status/peers第一条返回当前 leader 的地址,第二条返回全部节点地址。两条接口都能通,说明服务的对外接入面是好的。浏览器打开http://localhost:18500/ui,能看到三个节点都在 Services 列表里,这个集群就算真正成型了。
4.2 停掉一个跟随节点,服务还能找到吗
集群搭建完成后,紧接着做容灾演练,别等出了问题才手忙脚乱。先停跟随节点的一台:
docker stop consul-node3等十几秒后,在另外两个节点上执行consul members:
docker exec consul-node1 consul members此时 node3 的状态会变成failed,但 node1 和 node2 仍然是alive,集群的 leader 还活着。我再注册一个模拟服务进集群,然后通过目录接口去查询:
curl http://127.0.0.1:18500/v1/catalog/services仍然能正常返回服务列表。原因很直接:三个节点的集群 quorum 是 2,node1 和 node2 仍然构成多数,数据没有丢,服务发现不受影响。这个结果说明集群确实比你之前那个单节点抗造多了。
4.3 杀 leader:看选举过程比看 UI 有趣多了
刚才停的只是 follower,接下来做一次更狠的演练:把 leader 直接停掉。先用 raft 命令找到当前 leader:
docker exec consul-node1 consul operator raft list-peers如果输出的 leader 是 node1,那就执行:
docker stop consul-node1接下来会有几秒钟的“空窗期”,这是选举过程在起作用。follower 在一段时间内收不到 leader 的心跳,就会把自己变成 candidate,然后向其他节点拉票。只要获得多数投票,新的 leader 就会被选出。在这个实验里,剩下的 node2 和 node3 正好凑成 quorum,所以选举一定成功。
验证方式有两种。一种是业务侧:连续多次请求服务目录接口,你会看到短暂的超时或者失败,然后迅速恢复;另一种是系统侧:在 node2 上执行 raft 命令,能看到 leader 已经切换到了consul-node2或consul-node3,之前注册过的服务记录依然存在。这个现象告诉我们,leader 可以换人,但数据由多数节点共同保存,不会因为一把手换了就丢。
恢复演练环境时,直接把停掉的容器启动起来即可:
docker start consul-node1它会在几秒钟内重新加入集群,状态会从failed变为alive。注意不要同时把三个节点全部停掉再来挑战集群下限,那属于破坏性测试,不是日常运维该干的事。
5. .NET Core 服务端点的接入改造:从单地址到多节点
5.1 客户端 SDK 的多地址配置
集群搭好之后,.NET Core 这边的改造主要集中在一个问题上:原来配置写的是单个 Consul 地址,现在要改成多个,让服务客户端不再依赖某一个固定节点。
较新版本的 Consul 客户端库支持直接配置一组地址。在appsettings.json里可以这样写:
"Consul": { "Addresses": [ "http://192.168.1.101:8500", "http://192.168.1.102:8500", "http://192.168.1.103:8500" ], "Datacenter": "dc1" }然后在Startup.cs或使用WebApplication的入口处,把它加载进配置:
using Consul; var consulAddresses = builder.Configuration .GetSection("Consul:Addresses") .Get<string[]>() .Select(a => new Uri(a)); builder.Services.AddSingleton<IConsulClient>(sp => new ConsulClient(new ConsulClientConfiguration { Datacenter = "dc1", Addresses = consulAddresses }));配置了多个地址后,客户端会选择一个可用节点发请求,这个节点出问题了,它会自动切换到另一个节点。对于使用旧版本 SDK、找不到Addresses属性的情况,退路也简单:自己写一个三行轮询逻辑,把三个地址存进数组,请求时逐个尝试,捕获连接异常就换下一个。功能等价,只是多几十行代码。我建议不管 SDK 支不支持,都把Datacenter显式写好,避免默认数据中心不一致导致节点对不上。
5.2 服务注册代码这样改
服务注册的代码结构变化不大,核心区别是地址来源。原来我们可能在配置里写死一个Consul:Address,现在应该保证每个服务实例把自己真实的 IP 和端口汇报上去。下面是通常放在服务启动后台任务里的注册代码:
public class ConsulRegisterHostedService : IHostedService { private readonly IConsulClient _consulClient; private readonly IConfiguration _configuration; private string _registrationId; public ConsulRegisterHostedService( IConsulClient consulClient, IConfiguration configuration) { _consulClient = consulClient; _configuration = configuration; } public async Task StartAsync(CancellationToken cancellationToken) { var serviceName = _configuration["Service:Name"]; var port = int.Parse(_configuration["Service:Port"]); var ip = ResolveLocalIp(); var registration = new AgentServiceRegistration { ID = $"{serviceName}-{ip}-{port}", Name = serviceName, Address = ip, Port = port, Tags = new[] { "v1" }, Check = new AgentServiceCheck { HTTP = $"http://{ip}:{port}/health", Interval = TimeSpan.FromSeconds(10), Timeout = TimeSpan.FromSeconds(5), DeregisterCriticalServiceAfter = TimeSpan.FromMinutes(5) } }; _registrationId = registration.ID; await _consulClient.Agent.ServiceRegister(registration); } public async Task StopAsync(CancellationToken cancellationToken) { if (!string.IsNullOrEmpty(_registrationId)) { await _consulClient.Agent.ServiceDeregister(_registrationId); } } private string ResolveLocalIp() { var hostName = Dns.GetHostName(); return Dns.GetHostAddresses(hostName) .FirstOrDefault(x => x.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) ?.ToString(); } }有几个细节值得多说一句。注册 ID 必须是全集群唯一的,这里用了服务名-IP-端口的组合,防止同一个服务在每台机器上重复注册时互相覆盖。健康检查的HTTP地址最好写当前实例真实 IP,而不是localhost,因为在 Consul 那边,这个地址是要返回给其他服务发起真实调用的,写成 localhost 会导致跨机器访问全部指向自己。
DeregisterCriticalServiceAfter是个容易被忽略但很重要的配置:它表示当健康检查连续失败超过五分钟,Consul 就把这个实例自动清理掉,避免一个死掉的实例永远躺在注册列表里占位置。这个时间可以根据业务抖动程度调整,我的建议是不要小于三分钟。
5.3 服务发现和健康检查的联动细节
服务注册进去只是第一步,调用方还得能从集群里正确拿到健康的实例。下面这段是一个简单的服务发现封装,客户端调用时可以直接传服务名:
public class ConsulServiceDiscovery { private readonly IConsulClient _consul; private readonly Random _random = new(); public ConsulServiceDiscovery(IConsulClient consul) { _consul = consul; } public async Task<string?> ResolveAsync(string serviceName) { var query = await _consul.Catalog.Service(serviceName); var instances = query.Response ?? Array.Empty<CatalogService>(); if (instances.Length == 0) return null; var instance = instances[_random.Next(instances.Length)]; var targetIp = string.IsNullOrEmpty(instance.ServiceAddress) ? instance.Address : instance.ServiceAddress; return $"http://{targetIp}:{instance.ServicePort}"; } }这里有个经常踩坑的点:Consul 返回的服务实例信息里,Address是节点地址,ServiceAddress才是服务注册时上报的地址。如果服务注册时没有显式设置ServiceAddress,后者可能为空,所以读取时要做好回退。至于选实例的策略,这里用了最简单的随机。真实项目里可以换成加权轮询、最少连接等算法,但都需要在客户端这一侧自己做,Consul 只负责提供一份健康的实例清单。
健康检查的联动效果是:实例正常时,它在服务列表里保持passing状态;实例异常后,连续两次检查失败就会被标记为critical,查询接口默认不会再返回它。这个过程不需要写额外业务代码,靠的是注册时做的健康检查配置。所以集群环境下,更要保证每个服务都带健康检查,否则注册中心无法判断哪个实例该进流量,集群再多也白搭。
6. 生产环境层面的补充:持久化、ACL、加密与监控
6.1 数据持久化与定期快照
Docker Compose 示例里我们已经把/consul/data挂到了命名卷上,容器重建不会丢数据。但命名卷不等于免死金牌,磁盘损坏、误删卷、机房迁移仍然会带走一切。所以生产环境必须做定期快照。
Consul 自带的快照功能既简单又可靠:
docker exec consul-node1 consul snapshot save /backup/consul-$(date +%Y%m%d).snap恢复的命令是对应的:
docker exec consul-node1 consul snapshot restore /backup/consul-20260201.snap快照应该在集群正常状态时保存,恢复时选择其中任意一个存活节点执行即可。我见过不少团队把所有精力放在备份数据库上,却忘了 Consul 里的服务注册数据其实就是一路微服务的“活地图”,地图丢了,服务之间连路都找不到,备份优先级应该排到最高那一档。
6.2 ACL 与 Gossip 加密,别让集群裸奔
默认情况下,Consul 是“裸奔”的:任何能访问到 8500 端口的人都可以注册服务、读取数据、甚至把整个服务列表改写掉。这个问题在内网环境里容易被忽视,但一旦有机器被入侵或者网络配置失误,后果非常严重。
生产环境至少要开两层防护。第一层是 ACL,Consul 从 1.4 版本之后启用了完整的 ACL 系统,可以在配置文件里启用:
acl = { enabled = true default_policy = "deny" }启用后,所有操作都要携带有效令牌。管理员先通过consul acl bootstrap生成初始管理令牌,然后为每个服务、每个 Agent 创建独立令牌,最小权限原则是底线。.NET Core客户端在ConsulClientConfiguration里设置Token属性,请求就会自动附带鉴权信息。
第二层是 Gossip 加密,防止集群节点之间的通信被窃听。Consul 提供了专门的命令生成密钥:
docker exec consul-node1 consul keygen把生成的密钥在三个节点的启动参数里加上-encrypt=<密钥>,集群间通信就会被加密。注意密钥一旦丢失会让新节点无法加入集群,所以要妥善保存在密钥管理系统里。
6.3 监控:从 consul monitor 到 Prometheus
最后说监控。Consul 集群本身的信息量很大,但默认日志容易淹没在噪声里。刚开始我比较推荐用consul monitor实时观察事件流:
docker exec consul-node1 consul monitor -log-level=INFO它会输出节点的选举、心跳、成员变化等重要事件。出现 leader 频繁切换、节点反复 failed 再 alive,这些都是早期预警信号。
规模稍大后,直接上 Prometheus 生态。Consul 的/v1/agent/metrics接口暴露了大量指标,比如consul_raft_leader、consul_serf_lan_members、raft_commitTime等。把这些指标抓进 Prometheus,配上几个基础告警规则:节点状态异常、leader 切换、写入延迟升高,基本就能覆盖日常运维需求。UI 界面固然直观,但监控要从指标层面去看,不然等你在 UI 上发现节点红了,故障往往已经持续了一段时间。
我在实际项目里养成的习惯是:每次改完 Consul 配置,先执行consul validate检查一遍语法,再用consul reload平滑重载,而不是动不动就重启容器。容器重启会短暂打断当前视图,虽然数据在,但无谓的抖动能免则免。另外,把这份三节点配置存成一个自动化脚本,每次要演示或复盘时一键拉起集群,然后立刻跑一遍成员检查和 raft peers 检查,这套动作做顺了,后面真正上生产扩容时你会感谢自己。