1. 为什么.Net微服务一定要有网关:单体拆分后的三个失控时刻
先说一个我自己的经历。当时我们把一个老旧的ASP.NET Core单体系统拆成了十几个微服务,服务之间通过HTTP调用,每个服务都直接暴露自己的IP和端口。一开始觉得"这不挺好吗,服务都起来了,调用也能通",直到有一天做了一次线上缩容。
运维把两个实例下线了,结果客户端那边立刻报错——有个老客户端还在轮询那个已经被回收的IP。那一刻我才意识到一个事儿:服务拆分得越细,服务实例的IP和端口就越像流动的水,今天这个在,明天那个没了。如果你没有一个统一的"入口"帮你搞清楚"该把请求交给谁",那整个系统就是一堆会随时失联的裸服务。这就是网关存在的核心意义。
再说第二个失控时刻:认证和鉴权。单体时代,登录态在进程内就能校验;微服务化之后,每个服务都做一遍登录校验?十几个服务,每个都要接一遍Token解析逻辑?我见过最夸张的团队,一个服务自己搞了一套鉴权,另一个服务压根没做,全部裸奔。网关在此时的价值,就是把"跨服务通用的横切逻辑"收口到一层集中处理,下游服务只关注业务本身。
第三个失控时刻是流量治理。微服务之间是网状调用的,假设十几个服务互相调用,任何一个节点抖动,都可能像连锁反应一样把整个调用链拖垮,这就是所谓的"雪崩效应"。没有网关统一做熔断、限流、超时控制,你根本没法在入口处把所有流量管住。
所以,.Net微服务架构一旦走到一定规模,网关就不是"要不要"的问题,而是"怎么选型、怎么落地"的问题。而在我做过的方案里,采用 Consul做服务注册与发现 + Nginx做流量网关 的组合,是我认为在.Net生态里性价比最高、也最容易落地的一套路子。接下来我把这套方案的完整思路、实操步骤和踩过的坑都写出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Consul注册中心落地:服务上线后如何让网关"看见"
网关注册和管理这件事,第一步永远是服务注册与发现。简单理解就是:服务启动时主动跟注册中心报到,说"我上线了,IP是xxx,端口是xxx,健康检查地址是xxx";服务下线时也要主动注销,或者被注册中心通过健康检查"发现"已经不健康,然后踢出去。
2.1 为什么选Consul,而不是ZooKeeper或Etcd
.Net生态里做服务注册中心,常见的选项有Consul、Etcd、ZooKeeper,还有Nacos。我最终选择Consul,主要是三个原因:
- 健康检查机制完善。Consul原生态支持HTTP、TCP、gRPC三种健康检查方式,对.Net服务非常友好。你只需要提供一个健康检查端点,Consul会定时来探活,发现你挂了就自动把服务标记为不健康,并触发服务发现时的过滤。这个能力在ZooKeeper里基本没有,ZK的临时节点机制虽然也能感知掉线,但它是靠会话保活来判断的,网络分区时容易误判。
- 服务发现支持DNS和HTTP API两种方式。HTTP API对网关侧做动态感知非常灵活,DNS方式则能让一些老系统用域名直接访问内部服务,兼容性更好。
- 运维成本低。Consul是Go写的,单二进制文件就能跑,不像ZK要维护Java环境。三个节点就能组成一个高可用集群,对中小团队来说完全够用。
Etcd其实也不错,但它的定位更偏分布式键值存储,健康检查、服务目录这些功能需要自己开发一层封装,落地成本比Consul高。Nacos在.NET生态的SDK支持也一般,所以我还是选择了Consul。
2.2 .Net服务注册到Consul的两种方式
先说一下,.NET服务向Consul注册有两种常见路径。
第一种是使用Consul官方SDK(Consul包,NuGet上的Consul库)。在服务启动时调用SDK的Agent Service Register接口完成注册。核心逻辑如下:
csharp复制public static void RegisterService(this IApplicationBuilder app, IConfiguration config)
{
var consulClient = new ConsulClient(c =>
{
c.Address = new Uri(config["Consul:Address"]);
});
var registration = new AgentServiceRegistration
{
ID = $"{config["Service:Name"]}-{config["Service:IP"]}-{config["Service:Port"]}",
Name = config["Service:Name"],
Address = config["Service:IP"],
Port = int.Parse(config["Service:Port"]),
Tags = new[] { config["Service:Version"], "v1" },
Check = new AgentServiceCheck
{
HTTP = $"http://{config["Service:IP"]}:{config["Service:Port"]}/health",
Interval = TimeSpan.FromSeconds(10),
Timeout = TimeSpan.FromSeconds(5),
DeregisterCriticalServiceAfter = TimeSpan.FromSeconds(30)
}
};
consulClient.Agent.ServiceRegister(registration).GetAwaiter().GetResult();
}
这里的几个关键配置值得展开讲一下:
- Check.HTTP 指向的是服务自己的健康检查端点,这个端点必须真实存在且快速返回200。我见过很多人把健康检查端点指向了首页或某个业务接口,结果业务接口稍微慢一点,Consul就误判服务不健康,直接把你从负载池里摘掉了。所以健康检查端点最好是独立的、轻量的,只做进程存活和依赖状态的最简校验。
- Interval 是Consul探活的频率。设置10秒比较合理,太频繁会给服务造成不必要的压力,太慢又会导致故障感知不及时。
- DeregisterCriticalServiceAfter 这个参数极易被忽略。它的含义是:如果服务处于"严重不健康"状态超过30秒,Consul会直接把这个服务实例从注册中心移除,而不是一直保留在一个"不健康"状态。这个配置对网关侧很关键,因为Nginx同步服务列表时一般只拉"通过健康检查"的服务,如果实例一直处于不健康状态挂在目录里,某些同步方案会把状态搞得很混乱。
第二种方式是服务启动时自己调用Consul的HTTP API,本质上跟SDK一样,只不过不用引第三方包。就是向/v1/agent/service/register发一个PUT请求,Body是JSON格式的注册信息:
json复制{
"ID": "order-service-192.168.1.10-8080",
"Name": "order-service",
"Tags": ["v1"],
"Address": "192.168.1.10",
"Port": 8080,
"Check": {
"HTTP": "http://192.168.1.10:8080/health",
"Interval": "10s",
"Timeout": "5s",
"DeregisterCriticalServiceAfter": "30s"
}
}
这两种方式没有本质区别,我建议在.NET项目里直接用SDK,省事且类型安全。如果是非.NET语言写的服务(比如边缘的Python脚本服务),直接用HTTP API接入就行。
2.3 服务注销:优雅下线比注册更重要
注册的上半场是注册,下半场是注销。很多人只写了注册逻辑,忘记在服务停止时注销,导致Consul里堆了一堆幽灵实例。
正确的做法是在进程退出前调用Agent.ServiceDeregister,把当前实例的ID传进去:
csharp复制public static void DeregisterService(this IApplicationBuilder app, IConfiguration config)
{
var consulClient = new ConsulClient(c =>
{
c.Address = new Uri(config["Consul:Address"]);
});
var serviceId = $"{config["Service:Name"]}-{config["Service:IP"]}-{config["Service:Port"]}";
consulClient.Agent.ServiceDeregister(serviceId).GetAwaiter().GetResult();
}
在ASP.NET Core中,可以注册到IApplicationLifetime.ApplicationStopping事件里,这样进程收到停止信号时会先反注册,再开始停止处理请求。这里有个细节:如果服务是在Kubernetes或Docker环境里跑,Pod销毁时的停止信号和优雅退出时间窗(默认terminationGracePeriodSeconds一般是30秒)要留够,否则反注册请求还没发出去,进程就被强杀了。
但无论如何,你不能把优雅下线完全寄托在"进程退出时会主动反注册"上——总会有异常崩溃、kill -9这些兜不住的情况。所以健康检查 + DeregisterCriticalServiceAfter才是最后的安全网:即使进程没有注销,Consul也会在检测到健康检查失败后自动移除服务实例。
3. Nginx做网关的三种动态路由方案:脚本、upsync模块和OpenResty
Consul解决了"服务有哪些、在哪里、是否健康"的问题,但请求还是得有个统一的入口进来,然后把流量转发到正确的服务上。这个入口就是网关。
Nginx做网关最头疼的问题是:默认的upstream配置是静态的。你把IP列表写死在配置文件里,某天一个服务扩了3个实例,你总不能让运维半夜手动改配置文件再reload吧。所以Nginx + Consul的核心工作,就是让Nginx的upstream列表能跟随Consul里的服务实例变化自动更新。
我在实际项目中对比过三种方案,各有优劣。
3.1 方案一:定时脚本生成配置并reload
这个方案最朴素,思路是:写一个脚本,定时从Consul拉取某个服务的实例列表,生成对应的upstream配置块,替换Nginx配置里的相关段落,然后执行nginx -s reload。
bash复制#!/bin/bash
# /usr/local/scripts/sync_nginx_upstream.sh
SERVICES=("order-service" "user-service" "inventory-service")
for service in "${SERVICES[@]}"; do
# 从Consul获取健康的实例列表(这里用jq解析JSON)
instances=$(curl -s "http://127.0.0.1:8500/v1/health/service/$service?passing=true")
# 生成 upstream 配置片段
upstream_file="/etc/nginx/conf.d/upstream_${service}.conf"
echo "upstream ${service} {" > $upstream_file
echo "$instances" | jq -r '.[] | " server \(.Service.Address):\(.Service.Port);"' >> $upstream_file
echo "}" >> $upstream_file
done
# 校验配置并reload
nginx -t && nginx -s reload
然后配合crontab,每30秒执行一次即可。
这个方案的好处是零额外依赖,只要能跑脚本就能用。但缺点也很明显:
- reload有代价。Nginx reload时会重新解析配置、重新打开监听端口,如果每秒几万QPS的高并发场景,reload期间可能会造成少量请求的短暂延迟。
- 定时轮询的窗口期。假设是30秒同步一次,那么服务发生故障后,最坏情况下有30秒的窗口期,Nginx还在往已经挂掉的实例转发请求。
- 脚本健壮性全看自己写得好不好。比如Consul短暂不可用时,要么脚本跳过本次同步,要么会把空列表写入upstream,导致服务彻底不可用。
所以这个方案适合QPS不高、对故障容忍度有几十秒的团队。它的优点是直白,出问题好排查。
3.2 方案二:nginx-upsync模块
这个方案是我个人最推荐的。nginx-upsync是一个第三方模块,它能让Nginx直接从Consul拉取服务列表,动态更新upstream,而不需要reload。
先讲一下模块的编译。Nginx官方版本不会默认包含这个模块,你需要自己编译:
bash复制# 下载nginx源码和upsync模块
wget http://nginx.org/download/nginx-1.24.0.tar.gz
git clone https://github.com/weibocom/nginx-upsync-module.git
# 编译安装
tar -zxvf nginx-1.24.0.tar.gz
cd nginx-1.24.0
./configure --prefix=/usr/local/nginx \
--add-module=../nginx-upsync-module
make && make install
编译完成后,upstream配置可以这样写:
nginx复制upstream order-service {
upsync 127.0.0.1:8500/v1/health/service/order-service upsync_timeout=2s upsync_interval=5s upsync_type=consul;
upsync_dump_path /usr/local/nginx/conf/servers/order-service.conf;
}
server {
listen 8080;
location /api/order/ {
proxy_pass http://order-service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
解释一下关键指令:
upsync指定从哪个Consul地址拉取服务列表,upsync_interval=5s表示每5秒拉取一次,upsync_type=consul指定数据源类型。upsync_dump_path是本地备份文件路径。当Consul不可用时,Nginx会读取这个备份文件里的upstream列表,避免服务直接不可用。这个设计非常实用,我强烈建议生产环境必须配置。
这个方案的最大优势是Nginxworker进程内部动态管理upstream,不reload、不断连接,请求转发完全无感。Consul里的实例列表变了,最长5秒内就会同步到Nginx上。
不过它也有需要注意的地方:模块的更新频率取决于维护方的活跃度,而且编译Nginx时如果后续要升级,流程会麻烦一些。如果团队已经有成熟的Nginx运维经验,这个方案是性价比最高的。
3.3 方案三:OpenResty + Lua脚本动态路由
OpenResty是Nginx + LuaJIT的增强版,在Nginx里嵌入了Lua脚本解释器,你可以直接在Nginx配置里写Lua代码,随时从Consul拉数据、做路由判断。
方案简单来说就是:在access或balancer_by_lua阶段用Lua从Consul查询服务实例,然后动态选择转发目标。相比upsync模块,Lua方式的可定制性更强,比如你可以实现更复杂的负载均衡策略、灰度规则、动态超时控制等。
一个最小示例(balancer_by_lua方式):
nginx复制server {
listen 8080;
location /api/order/ {
set $backend_service "order-service";
proxy_pass http://backend_consul;
}
}
upstream backend_consul {
server 127.0.0.1:1111; # 占位地址,实际由lua动态设置
balancer_by_lua_block {
local consul = require("resty.consul")
local balancer = require("ngx.balancer")
-- 从Consul获取健康实例
local ok, instances = consul.get_health("order-service")
-- 选择其中一个实例(这里简单做轮询)
local index = ngx.shared.consul_balancer:incr("order-index", 1) % #instances
local target = instances[index + 1]
ok, err = balancer.set_current_peer(target.Address, target.Port)
if not ok then
ngx.log(ngx.ERR, "failed to set peer: ", err)
end
}
}
OpenResty方案适合什么场景?如果你的网关需要同时承担鉴权、限流、灰度发布、请求日志定制等复杂功能,用Lua脚本可以一并收编。但坏处也很直接:Lua脚本不易调试、要求团队有人会写Lua,而且功能越放越多,网关本身的复杂度会变成新的负担。我个人建议,能把逻辑留在外层(如独立的鉴权中间件服务)就尽量不要堆在OpenResty脚本里。
3.4 三个方案怎么选
| 方案 | 依赖复杂度 | 实时性 | 稳定性 | 适用场景 |
|---|---|---|---|---|
| 定时脚本生成配置 + reload | 最低,纯Shell | 秒级~分钟级(取决于crontab) | 一般,reload有瞬时影响 | 小规模、低QPS、可接受秒级故障感知 |
| nginx-upsync模块 | 中等,需要编译Nginx | 秒级(upsync_interval可调) | 高,upsync_dump_path提供兜底 | 中大规模生产环境,优先推荐 |
| OpenResty + Lua | 较高,需要Lua能力 | 极高,可做到请求级实时查询 | 依赖脚本质量,风险点较多 | 需要复杂路由/治理策略的场景 |
我的实际建议是:如果从零起步且团队Nginx运维经验一般,优先选方案二(nginx-upsync),把upsync_interval设成5秒,配合upsync_dump_path做好备份,日常使用基本不用操心。如果团队已经有OpenResty,方案三也完全可以接受。方案一我一般不推荐上生产,除非实在没有条件编译Nginx模块。
4. 网关路由与请求转发的配置细节:别忽略这些"小地方"
服务发现和动态upstream解决了"转发给谁"的问题,但Nginx作为网关,还有一些路由和转发细节决定了用户体验和系统稳定性。这些配置在文档里都能查到,但真正踩过坑之后才知道哪些细节最要命。
4.1 精细路由:一个Nginx网关代理多个微服务
单网关代理多服务的配置核心,是通过路径前缀区分服务。比如/api/order/开头转发到order-service,/api/user/开头转发到user-service。这里最关键的就是proxy_pass的URL是否带斜杠,以及location前缀是否带斜杠,二者会直接影响路径的传递方式。
nginx复制server {
listen 80;
server_name api.example.com;
# 订单服务
location /api/order/ {
proxy_pass http://order-service/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 用户服务
location /api/user/ {
proxy_pass http://user-service/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 库存服务
location /api/inventory/ {
proxy_pass http://inventory-service/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
这里有个非常经典的坑:location /api/order/ 后跟 proxy_pass http://order-service/;,因为proxy_pass的URI部分是/,所以原始请求/api/order/123会被转发成/123——前缀/api/order会被替换掉。如果proxy_pass后面不带斜杠,比如proxy_pass http://order-service;,则原始URI会原样传递给后端,包括/api/order这一整段。
很多团队在这两个写法之间反复横跳,就是因为没搞清楚这个替换规则。我的建议是:在配置里统一规范好。如果后端服务只认业务路径(比如你的Controller只定义了/123),就用带斜杠的方式;如果后端服务已经按完整路径规划了路由(比如集中网关挂了很多子服务),就直接透传。重点是写注释说明,避免后来的人手贱改一处结果全线崩盘。
4.2 超时、重试和请求体大小限制
网关是请求的必经之路,所以要把超时、重试这些策略设好,否则一个慢接口能拖死一整条链路。
nginx复制location /api/order/ {
proxy_pass http://order-service/;
# 连接超时、发送超时、读取超时
proxy_connect_timeout 5s;
proxy_send_timeout 10s;
proxy_read_timeout 30s;
# 请求体大小限制,默认1m,如果有文件上传接口要调大
client_max_body_size 20m;
# 允许向下一跳传递的请求头
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
proxy_connect_timeout:Nginx与后端建立连接的超时时间。不要设太短,否则服务启动或GC尖峰时可能误杀;也不要设太长,一般5秒够用。proxy_read_timeout:等后端响应的时间,这个时间要根据业务接口的最慢耗时来定。如果接口里有报表导出、批量计算这种慢操作,要单独给对应location设置更长的读超时,不能所有接口一把梭用同一个值。我们曾经有个导出接口需要跑20秒,结果一直被网关的30秒超时卡在边缘,后来单独调大才稳定。client_max_body_size:很多人忘了调这个,线上传文件一直报413。默认值只有1MB,做文件上传的接口必须显式调大。
4.3 转发请求头:真实IP和Scheme
服务拆分成微服务之后,后端服务看到的客户端IP默认是Nginx的IP,而不是真实用户的IP。这对于日志分析、审计、风控来说都是大问题。所以必须在Nginx转发时,把原始客户端的IP、协议、域名等信息通过请求头传递下去。
nginx复制proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
后端ASP.NET Core中要正确识别客户端IP,还需要在配置中设置ForwardedHeaders中间件,否则即使请求头带了X-Forwarded-For,ASP.NET Core默认也不会采信它(防止伪造头攻击)。
csharp复制app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
这里还有个安全细节:一定要配置KnownProxies。如果你不限制信任的代理来源,任何人都可以伪造X-Forwarded-For头来伪装自己的IP。正确的做法是把Nginx网关的IP加入KnownProxies列表,这样ASP.NET Core只信任来自网关的转发头。
csharp复制app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
KnownProxies = { IPAddress.Parse("192.168.1.100") } // Nginx网关IP
});
5. 服务下线与优雅停机:避免把流量打到死节点
前面讲完了路由,这部分重点说服务上下线过程中最容易出问题的环节。很多团队在微服务刚上线时,会出现"明明服务挂了,Nginx还在往那里转发请求"或者"明明服务起来了,Nginx流量就是打不过去"的问题。这些问题多半是服务生命周期管理和网关同步节奏没配好。
5.1 主动注销 + 健康检查 = 双保险
服务的生命周期有两条路径:
- 正常发布/停机:运维执行停止命令时,进程应该先向Consul反注册,告诉网关"我要下线了,请不要再给我分配流量",然后再停止接收新请求、等待存量请求处理完,最后退出进程。这就是"优雅停机"。
- 异常崩溃:进程没有机会反注册,此时需要Consul的健康检查在间隔时间内发现服务不健康,然后将实例标记为critical,经过
DeregisterCriticalServiceAfter的时间后自动清理。
这两条路径缺一不可。只靠主动注销,异常崩溃时就留了黑洞;只靠健康检查,正常发布时服务明明在优雅关机,流量还在不断进来,会导致大量请求失败。
5.2 ASP.NET Core优雅停机的实现细节
ASP.NET Core的Kestrel本身就做了优雅停机的基础工作——ApplicationStopping触发后,Kestrel会停止接收新连接,并给存量请求一段时间完成处理。你需要做的是在这个时间里完成Consul反注册。
csharp复制public class Startup
{
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IHostApplicationLifetime lifetime)
{
// 注册优雅停机处理
lifetime.ApplicationStopping.Register(() =>
{
// 1. 先从Consul注销服务
app.DeregisterService(Configuration);
// 2. 等待存量请求处理完成(可以配合HealthCheck状态置为不健康)
System.Threading.Thread.Sleep(TimeSpan.FromSeconds(5));
});
}
}
另一个更精细的做法是:在停止前先把健康检查端点主动返回不健康状态,让Consul在下一个健康检查周期就把服务置为不健康,然后再等几十秒,让Nginx侧完成同步(upsync_interval一般5秒),最后再真正推出。这样流量切换的过渡会更平滑,不会存在"刚反注册但Nginx还在转发"的竞态窗口。
这个细节我在生产环境验证下来,效果很明显。如果不做健康检查先行置灰,直接反注册,Nginx最坏情况下还会继续向这个实例转发请求,直到下一轮同步完成;而健康检查置灰这个动作,会比反注册更早被各个组件感知到。
5.3 健康检查端点设计
健康检查端点(就是Consul用来探活的那个HTTP接口)不是越复杂越好,也不是越简单越好。我见过两种极端:
- 太复杂:健康检查接口里查数据库、查Redis、调远程服务,任何一个依赖抖动一下,Consul就认为你挂了,把服务摘掉。实际上你的服务本身还能正常工作,只是某个依赖慢了一点。结果生产环境频繁出现"服务漂移",流量被来回切换,用户感受到的就是间歇性超时。
- 太简单:只返回一个200的空响应,完全不检查依赖。结果数据库挂了,服务进程还活着,健康检查还是200,请求照样被转发进来,然后业务接口全部超时失败。
正确的健康检查设计应该是分层的:
- 基础存活检查:进程是否活着,端口是否在监听,返回200即可。
- 依赖状态检查:数据库、Redis等核心依赖是否可用。建议单独开一个"/health/ready"端点做就绪检查,Consul配置指向这个端点,但这个端点的内部逻辑要控制好超时(比如数据库连接测试最多等2秒),避免因依赖响应慢导致健康检查本身超时。
我习惯的做法是:/health只做进程存活检查,/health/ready做完整依赖检查。Consul的健康检查指向/health/ready,但把DeregisterCriticalServiceAfter调大一点(比如60秒),避免依赖短暂抖动就直接把服务摘除。网关层面再配置max_fails和fail_timeout做一层兜底,这样即使Consul还没摘除节点,Nginx也会根据自身转发失败情况临时把节点标记为不可用。
nginx复制upstream order-service {
upsync 127.0.0.1:8500/v1/health/service/order-service upsync_timeout=2s upsync_interval=5s upsync_type=consul;
upsync_dump_path /usr/local/nginx/conf/servers/order-service.conf;
# 兜底:主动健康检查 + 失败熔断
server 192.168.1.10:8080 max_fails=2 fail_timeout=10s;
server 192.168.1.11:8080 max_fails=2 fail_timeout=10s;
}
注意:server这一行在这里是占位符还是显式列表,取决于你用的upsync模块版本。有的版本中server列表会被upsync动态覆盖,所以这里的兜底配置在不同版本下行为略有差异。我建议在你的测试环境实际验证一下,不要把兜底逻辑完全依赖在静态server声明上。
6. 线上故障排查实录:503、流量倾斜、Nginx频繁reload
方案落地之后,真正的考验在线上。这里我记录几个真实遇到过的故障案例和排查过程,希望能给你一些参考。
6.1 故障一:服务偶发503 Service Unavailable
现象:某服务高峰期有少量请求返回503,但服务实例本身并没有挂掉,Consul里也显示健康。
排查链路:
我一开始怀疑是Nginx upstream里服务实例列表为空,但去Consul查了一下,服务明明在且健康。后来看Nginx错误日志,发现大量"connect() failed (111: Connection refused) while connecting to upstream"。这说明Nginx在尝试连接某个实例的端口时被拒了。再深挖发现,这个服务是ASP.NET Core默认配置——Kestrel只有一个监听端口,当进程内线程池满或者某个服务依赖的数据库连接池被占满时,Kestrel不会立刻拒绝新连接,而是让它们在队列里等待。但如果连接排队太深,新连接仍然会被操作系统拒绝,表现为Connection refused。
还有一个常见原因是服务的MaxConcurrentConnections设得太小,Kestrel层就开始拒绝新连接。
解决思路:
- 检查服务实例的CPU、内存、线程池指标,确认是否存在资源瓶颈。
- 检查数据库连接池配置。ASP.NET Core的Npgsql/SqlClient连接池默认最大100个连接,如果接口并发高且每个请求占用连接时间较长,很容易占满。
- 在Consul健康检查里增加"连接数/线程池队列长度"的检查,超过阈值直接返回不健康,让网关把流量切走。
这个故障的本质是:单个服务的容量上限和网关的感知之间存在时间差。Consul健康检查只关心"进程活着且端口通",但进程活着不代表它能正常处理请求。所以健康检查的设计必须能反映"真正可用"的状态,而不仅仅是"进程还活着"。
6.2 故障二:流量在实例之间分配不均匀
现象:Nginx upsync自动管理upstream后,某服务有5个实例,但高峰期有两个实例的CPU到了90%,其他三个才50%。
排查链路:
Nginx默认的负载均衡策略是round-robin(轮询),理论上流量应该均匀分配。但如果服务实例的规格不一样(比如新扩容的实例是4核8G,旧实例是2核4G),轮询仍然会按请求数平分,规格较低的实例自然就会更早被打满。
另一个常见原因是某个实例的请求处理时间特别长。如果一个实例由于某种原因(比如它连接的是慢速存储)响应慢,Nginx的round-robin并不会因为"这个实例慢"而减少发给它的请求。反而因为请求长时间挂着,新请求还是会轮到它,导致它上面的堆积越来越严重。
解决思路:
- 如果服务实例规格不一致,改用
least_conn负载均衡策略,让Nginx优先将请求转发给当前活跃连接数最少的实例。 - 在Nginx的server块里配置
weight,给高规格实例更大的权重。 - 更进一步的方案是使用
nginx-sticky-module做会话粘滞,但这对无状态微服务一般不需要。
这个案例给我的经验是:网关的负载均衡策略要结合后端实例的实际规格来选择,不能默认开箱即用就万事大吉。
6.3 故障三:Nginx频繁reload导致连接抖动
现象:使用方案一(定时脚本生成配置 + reload)的团队反馈,线上每分钟都有少量请求报"upstream timed out"或者连接被重置。
排查链路:
检查了Nginx错误日志,发现每次脚本执行reload之后,都会出现一波集中的超时错误。原因是:Nginx reload会将旧的worker进程退出,新worker进程接管监听端口。对于长连接场景,Keep-Alive连接在reload时会被断开,客户端需要重新建立连接,这个过程会造成瞬间的连接抖动。 如果reload每分钟一次,这个抖动就会被放大。
解决思路:
- 最直接的办法:改用方案二(nginx-upsync模块),彻底避免reload。
- 如果暂时无法更换方案,至少把reload频率降下来(比如60秒甚至120秒一次)。
- 同时排查是否有其他自动化脚本在频繁触发reload。我遇到过一种情况是:脚本判断"配置有变化"就reload,但脚本自身的逻辑有问题,每次执行生成的upstream文件内容顺序不一样(Consul返回的实例顺序会变),导致Nginx每次都认为配置变了,于是不断reload。这个问题的修复方式是脚本在生成配置前先对实例列表做排序,保证内容稳定。
bash复制# 生成upstream配置前先排序,避免Consul返回顺序变化导致无意义reload
instances=$(curl -s "http://127.0.0.1:8500/v1/health/service/$service?passing=true" | jq -r '.[] | "\(.Service.Address):\(.Service.Port)"' | sort)
upstream_file="/etc/nginx/conf.d/upstream_${service}.conf"
echo "upstream ${service} {" > $upstream_file
while IFS= read -r addr; do
echo " server $addr;" >> $upstream_file
done <<< "$instances"
echo "}" >> $upstream_file
这个小细节,我印象很深,因为排查了大半天才发现是"配置没变化但内容顺序变了"导致的无意义reload。生产环境里很多看似诡异的问题,根因往往都在这种不起眼的地方。
7. 网关层的高可用与容量规划:别让网关成为新的单点
把Nginx当网关用,本身是把原来"所有服务各自暴露IP"的风险收敛到了一个入口,但如果这个入口本身挂了,整个架构就全挂了。所以网关层的高可用设计,跟服务注册中心的高可用同等重要。
7.1 Consul集群:三个节点起步
Consul本身是分布式系统,生产环境最少部署3个节点组成一个集群。三个节点能容忍一个节点故障,再多加节点能提升读性能和容错能力,但也会增加写延迟和网络开销。对大多数微服务团队来说,3到5个节点足够。
Consul集群的搭建本身不复杂,三个节点分别安装Consul,配置相同的retry_join地址即可组成集群:
json复制{
"datacenter": "dc1",
"data_dir": "/opt/consul/data",
"log_level": "INFO",
"server": true,
"bootstrap_expect": 3,
"retry_join": [
"192.168.1.11",
"192.168.1.12",
"192.168.1.13"
],
"ui": true
}
有一个细节值得提醒:Consul集群对时间的同步要求比较高,最好在每台机器上配置好NTP服务,否则节点间时钟偏差太大会出现奇怪的心跳问题。
7.2 Nginx网关多节点:前端加一层负载均衡
单台Nginx撑不住高QPS或者故障时,就需要多台Nginx共同对外服务。这时候需要在Nginx前面再加一层负载均衡(也就是常说的"接入层"或"LB层"),用Keepalived + VIP或者云厂商的负载均衡产品,把流量分发到多台Nginx上。
nginx复制# 多台Nginx网关配置完全一致,前端LB通过健康检查判断哪台可用
# 每台Nginx都连接同一组Consul集群
网关节点增多后,有两点要注意:
- 所有网关节点的Nginx配置必须保持一致。如果使用配置文件统一管理工具(Ansible等),建议从配置管道统一分发,避免人工手工改配置导致各节点不一致。
- Nginx的
upsync_dump_path备份文件最好通过共享存储或启动时自动同步,否则服务重启后,本地备份文件可能是过期的,而Consul恰好又不可用,这时候网关就会拿到一份过期的upstream列表。
7.3 容量规划:Nginx的瓶颈在哪里
很多人以为Nginx单机性能非常强大,随便扛几万QPS没问题。但放到网关场景里,性能瓶颈往往不在Nginx本身的转发能力上,而在以下几个方面:
- HTTP Keep-Alive到客户端的连接数。网关是客户端直接连接的入口,几万个并发长连接会占用大量文件描述符和内存。需要把
worker_connections、worker_rlimit_nofile调大。 - 与后端的连接池管理。Nginx与后端服务建立的连接也会占用资源。建议开启upstream的keepalive连接池,复用后端连接,减少频繁建连的开销。
nginx复制upstream order-service {
upsync 127.0.0.1:8500/v1/health/service/order-service upsync_timeout=2s upsync_interval=5s upsync_type=consul;
upsync_dump_path /usr/local/nginx/conf/servers/order-service.conf;
keepalive 32;
}
location /api/order/ {
proxy_pass http://order-service/;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
keepalive 32表示每个Nginx worker进程与后端保持32个空闲的长连接。proxy_http_version 1.1;和proxy_set_header Connection "";是启用HTTP/1.1 Keep-Alive的必要配置。这个配置改完后,后端服务的连接数会大幅下降,整体时延也会更稳定。
7.4 容灾:Consul不可用时的降级策略
Consul集群本身是高可用的,但也不排除整个机房网络分区这种极端情况。这时要确保两个底线:
- Nginx的upsync_dump_path 保存了最近一次同步的服务列表,Consul不可用时Nginx还能继续按旧列表转发请求。
- 服务实例尽量多副本,保证即使部分服务注册信息过期,仍有多副本可以承担流量。
顺带说一句,Nginx的upsync指令有upsync_timeout参数,设成2秒或3秒就行。如果Consul连接超时,Nginx会继续用本地已有的upstream数据,不会停摆。
8. 从一个小团队的实际复盘点:落地这套方案的成本与收益
最后聊一下现实中团队落地这套方案会花多少成本。我基于做过的一次1到50人团队的微服务网关改造,做了个粗略的复盘。
8.1 人力投入
- Consul集群部署:一个运维半天能完成3节点集群搭建,加上测试环境联调,一天内可以搞定。
- .Net服务接入Consul:每个服务需要加注册、注销、健康检查三段代码。熟练的.NET开发每人每天能改造三到五个服务(如果服务本身有统一的启动模板,工作量更小)。
- Nginx网关搭建与动态upstream配置:如果采用nginx-upsync方案,需要编译Nginx,这部分一个运维半天到一天也能搞定。
- 整体从零到一,一个3人小组(1个后端、1个运维、1个测试)一周时间可以完整体验一把。
8.2 最常见的隐性成本
隐性成本往往在后续运维。比如:
- 服务实例扩缩容后,没有及时审视Nginx的worker参数和连接池配置,高峰时出现性能问题。
- 健康检查端点没人维护,随着业务变化,健康检查逻辑已经不能真实反映服务可用性了。
- upsync模块和Nginx版本需要一起升级,Nginx的CVE安全公告出来后,团队必须安排重新编译部署。
这些成本不是一次性的,而是持续存在的。所以我的建议是:在引入这套方案时,把配置管理、脚本、健康检查标准都纳入团队的基础设施规范,不要做一个"一次性配置完就不管"的方案。
8.3 这个方案能扛多大流量
按我实际压测数据来看,一台标准配置的Nginx(4核8G),开启keepalive、合理配置worker数量后,处理纯反向代理的QPS能达到几万,如果加上限流、鉴权等Lua逻辑,性能会下降一些,但仍然能轻松扛住中小规模的业务压力。对于更大规模(百万级以上并发)的场景,就需要考虑更复杂的网关架构(比如基于云原生网关或自研网关),但那是另一个话题了。
如果你们的微服务规模在几个到几十个服务之间,QPS在几千到几万,这套Consul + Nginx的方案目前来看是最省心、最不容易出幺蛾子的组合。等业务规模大到Nginx扛不住了,再审视是否有必要引入更重的云原生网关,那时候你已经积累了足够的流量数据和治理经验,选型也会更有底气。
有一个细节如果重来一次我会提前做:从第一天就把网关的访问日志和Consul的服务状态导出到监控系统里,这样后续排查线上问题会省很多时间。两套日志一个是"用户请求到了哪台服务",一个是"服务当时是什么样的健康状态",当问题需要跨组件定位时,这两个维度缺一不可。
