十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

.Net微服务架构实战:Consul服务发现与Nginx网关配置详解

.Net微服务架构实战:Consul服务发现与Nginx网关配置详解 做 .Net 微服务做多了你会发现服务注册发现和网关这两块才是架构里最容易乱的地方。服务少的时候A 服务调 B 服务直接写死地址就行服务一多拆成十几个二十几个谁在哪个端口、哪个节点挂了、流量怎么进全成了事故现场。Consul Nginx 这套组合是我这几年在 .Net 项目里用得最顺手的一套方案注册中心用 Consul网关和负载均衡交给 Nginx今天就把完整的落地过程、配置细节和踩过的坑都写出来。文章不聊空泛的架构图直接给你能抄作业的配置和代码适合正在做 .Net 微服务改造、或者被服务间调用搞到头大的团队参考。这套方案的适用场景很明确你的服务是 .NET 写的一堆 Web API内部需要互相调用对外又希望只暴露一个统一入口同时你还不想引入太重的基础组件。Consul 负责服务的注册、发现、健康检查Nginx 负责把外部请求按路径转发到具体服务再做一层负载均衡。相比直接用 Ocelot 这种全功能网关Consul Nginx 的性能开销更低运维链路更简单出了问题也好排查。1. 为什么是 Consul Nginx1.1 微服务网关到底解决什么问题微服务拆开之后第一个要解决的问题是服务发现。以前单体应用不管怎么样调用就是本地方法调用拆成服务之后变成远程调用你就得知道对方服务在哪台机器、哪个端口。硬编码 IP 和端口是最省事的做法但也是最早爆炸的做法。服务实例扩容、缩容、迁移、宕机只要有变化硬编码的调用方就会拿到失效地址。所以我们需要一个注册中心每个服务启动的时候把自己的地址报上去挂了或者停止的时候自动摘除调用方动态获取可用实例列表。第二个要解决的问题是网关。服务多了每个服务都有自己的端口和路由规则你不可能让前端或者外部调用方去挨个记住这些地址。网关就是一个统一入口外部请求只打到网关网关根据路径把请求转发到内部对应的服务。网关还能顺带做负载均衡、限流、超时控制、日志记录这些事情。某种意义上网关就是微服务对外的门面内部再乱门面要整洁可预测。Consul 在这套方案里承担的是服务注册中心和健康检查的角色。它有 HTTP API、DNS 接口还有一套简单可靠的健康检查机制服务挂了能自动从可用列表里踢掉。Nginx 承担的是反向代理和负载均衡的角色外部流量进到 Nginx它按规则转发到后面的服务实例。所以这两个组件没有功能冲突一个管服务发现一个管流量转发配合起来非常默契。1.2 方案选型为什么不用 Ocelot 或 Service Fabric很多人会问.Net 生态里现成的 Ocelot、Service Fabric 不是也能干这事吗为什么还要自己搭 Consul Nginx。这个问题我在实际项目里对比过说下我的判断。Ocelot 确实能和 Consul 集成也能做路由、限流、负载均衡但它本质上是一个跑在 .NET 进程里的网关所有请求都要先进入一个 .NET 应用再做转发。这意味着网关本身的性能和稳定性取决于你部署 Ocelot 的机器和 .NET 运行时的表现。流量大的时候Ocelot 的 CPU 和内存开销不小而且它本身也是需要维护的服务版本升级、配置热更新都还得处理。关键它不是独立于应用的网关挂了会影响所有服务。Nginx 是 C 写的事件驱动模型并发能力极强一台普通机器轻松扛几万 QPS。它的配置语法简单热重载方便社区资料也多。用 Nginx 做网关层性能上几乎不用操心。Consul 管注册发现Nginx 管流量转发两个都是轻量级组件职责单一出问题容易定位。Service Fabric 是微软的重量级方案功能很全但部署和运维复杂度高如果你的团队规模不大只是想把服务拆开管理好用 Service Fabric 有点杀鸡用牛刀。当然Ocelot 也不是一无是处它做聚合网关的时候很灵活可以在网关层做服务聚合、改请求响应结构。如果你有这类强需求可以考虑 Ocelot 和 Nginx 结合Nginx 在最外层挡流量Ocelot 在内层做聚合和协议转换。但绝大多数场景下Consul Nginx 就足够了少一层 .NET 进程就少一份开销和故障点。2. Consul服务注册中心的部署与核心机制2.1 Consul 的下载、安装和集群配置Consul 官方提供的是单文件二进制下载后直接运行不需要安装依赖这一点相当方便。选择一个和你服务器架构匹配的版本放到 /usr/local/bin 或者 Windows 的某个 PATH 目录里运行 consul version 能输出版本信息就算安装完成。开发环境直接单节点跑即可consul agent -server -bootstrap -data-dir/data/consul -ui -bind0.0.0.0 -client0.0.0.0生产环境建议至少三个节点组成集群避免单点故障。节点之间需要能互相通信每个 agent 以 server 模式启动再通过 retry_join 指定其他节点地址consul agent -server -bootstrap-expect3 -data-dir/data/consul \ -nodeconsul-1 -bind本机IP -client0.0.0.0 -retry-join节点2IP -retry-join节点3IP -ui启动之后浏览器访问 http://服务器IP:8500/ui 就能看到 Consul 的控制台。8500 是 HTTP API 端口8600 是 DNS 端口服务注册、发现、健康检查都走 8500DNS 方式的服务发现走 8600。记得在安全组或防火墙里只放开你需要访问的 IP 段不要把 8500 端口裸奔到公网否则任何人都能通过 API 看到你服务的内部地址也能随意注册和注销服务。单节点评估的话加 -bootstrap 参数让这个节点自己选举为 leader即可完成初始化。多节点集群需要注意 -bootstrap-expect 的配置它表示集群需要几个 server 节点才能形成 quorum一般设为 3 或 5。2.2 .Net 服务如何注册到 Consul.Net 服务注册到 Consul 不需要引入特别重的框架最简单的就是在服务启动的时候调用 Consul 的 HTTP API 完成注册。当然更规范的做法是引入 Consul 官方客户端库选举健康检查、服务信息更新都更方便。下面这段代码展示了在 ASP.NET Core 服务启动时通过官方客户端注册服务并注册一个 HTTP 健康检查端点。// NuGet: Consul public void ConfigureServices(IServiceCollection services) { services.AddSingletonIConsulClient(sp new ConsulClient(c { c.Address new Uri(Configuration[Consul:Address]); })); } public void Configure(IApplicationBuilder app, IHostApplicationLifetime lifetime) { var client app.ApplicationServices.GetRequiredServiceIConsulClient(); var serviceId ${Configuration[Service:Name]}-{Environment.MachineName}-{Configuration[Service:Port]}; var registration new AgentServiceRegistration { ID serviceId, Name Configuration[Service:Name], // 服务名例如 order-service Address GetLocalIpAddress(), // 注册给 Consul 的地址必须是其他服务能访问到的地址 Port int.Parse(Configuration[Service:Port]), Tags new[] { v1, order }, // 标签可用作版本或环境标识 Check new AgentServiceCheck { HTTP $http://{GetLocalIpAddress()}:{Configuration[Service:Port]}/health, Interval TimeSpan.FromSeconds(10), Timeout TimeSpan.FromSeconds(5), DeregisterCriticalServiceAfter TimeSpan.FromMinutes(1) } }; client.Agent.ServiceRegister(registration).GetAwaiter().GetResult(); lifetime.ApplicationStopping.Register(() { client.Agent.ServiceDeregister(serviceId).GetAwaiter().GetResult(); }); }这段代码里几个关键点值得展开说。第一服务 ID 必须全局唯一同一台机器上同一个服务可能起多个实例所以 ID 里拼上了机器名和端口。如果不注意 ID 冲突后注册的实例会把先注册的实例信息覆盖掉造成调用方拿到错误地址。第二注册的 Address 要特别小心。很多人习惯拿本机回环地址 127.0.0.1 去注册单机跑没问题但跨机器调用的时候别的服务拿着 127.0.0.1 来访问你必然会失败。所以注册时要用局域网内可路由的 IP一般通过路由表或者配置中心指定的方式获取。第三健康检查的地址也要保证从 Consul 服务器能访问到。如果你在 Docker 容器里跑服务容器内的 /health 地址从宿主机访问不通健康检查就会一直失败。这时候要么用容器网络模式要么在健康检查地址里配置成宿主机映射后的地址。DeregisterCriticalServiceAfter 是一个容易忽略但是很重要的参数。它表示如果服务健康检查失败超过指定时间Consul 就自动把这个服务实例从注册中心移除。没有这个参数挂了的服务会一直留在服务列表里虽然调用方在下一次拉取时可能拿不到它但控制台和 API 里会一直显示影响后续排查。我建议设置成 1 分钟或 90 秒这个时长既能容忍短暂抖动又能及时清理死服务。2.3 服务发现调用方怎么拿到可用实例服务注册上去之后调用方有两种方式来发现服务HTTP API 和 DNS。HTTP API 直接请求 /v1/health/service/服务名 就能拿到该服务的健康实例列表curl http://127.0.0.1:8500/v1/health/service/order-service?passing加 passing 参数表示只返回健康检查通过的服务实例否则会把检查失败但尚未摘除的实例也返回给你调用方用了就会出问题。返回结果是一个 JSON 数组每个元素包含 Service 和 Checks 两个部分。实际做服务发现的时候就是把 Service 里的 Address 和 Port 拼起来形成一个实例地址列表。DNS 方式更直观。Consul 内置 DNS 服务你可以在调用方配置 /etc/resolv.conf 里把 nameserver 指向 Consul 节点然后直接用服务名做 DNS 解析dig 127.0.0.1 -p 8600 order-service.service.consulDNS 方式的好处是零代码很多语言和组件天然支持通过域名访问解析的时候 Consul 会自动负载均衡返回实例地址。坏处是通常情况下 DNS 结果有 TTL 缓存服务实例变化后不会立刻生效。HTTP API 方式实时性更好适合在代码里动态获取服务地址。在 .Net 里我一般用 HTTP API 做服务发现做一个简单的负载均衡客户端每次调用前拉取可用实例列表再用轮询或者随机策略选一个地址出来。这个做法的好处是直观、可控制坏处是每个服务都要自己实现一套良逻辑。如果服务间调用非常频繁建议在网关层做服务发现和负载均衡内部服务之间直接用内网域名访问避免重复造轮子。3. Nginx网关层的实现与细节3.1 网关的职责划分Consul 解决的是服务发现那 Nginx 就要解决流量入口的问题。在我这套方案里Nginx 的职责有几个统一接收外部请求按路径映射到不同微服务对同一个服务的多个实例做负载均衡实现反向代理隐藏内部服务真实地址记录访问日志方便排查问题。最直接的做法是在 Nginx 的 http 块里用 upstream 定义一组服务实例然后在 server 块里用 location 匹配路径proxy_pass 转发到对应的 upstream。Nginx 配置长这样upstream order_service { server 192.168.1.10:8001 weight3; server 192.168.1.11:8001 weight2; server 192.168.1.12:8001 weight1; } server { listen 80; server_name gateway.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; } }location /api/order/ 的斜杠结尾和 proxy_pass 地址的斜杠结尾是很多人会搞错的地方。location 后面带斜杠表示只匹配这个前缀proxy_pass 后面带斜杠表示转发时会把 location 匹配到的前缀去掉。例如请求 /api/order/123转发到上游的 URI 是 /123。如果你不想去掉前缀proxy_pass 地址后面就不要带斜杠比如 proxy_pass http://order_service;。这个细微差别直接决定你的服务端接收到的路由是不是你预期的样子。3.2 静态 upstream 的局限和动态化改造如果服务实例数不变用上面这种静态 upstream 配置是没问题的。但微服务最核心的能力就是动态扩缩容你今天加了两个实例明天坏了一个不可能每次都手动维护 Nginx 配置再去 reload。所以要让 Nginx 和 Consul 联动起来Consul 里服务列表变了Nginx 的 upstream 配置也要跟着变。我有两种动态化方案。第一种是使用 Consul Template 生成 Nginx 配置。Consul Template 是一个守护进程它会监听 Consul 中的 key/value 和服务列表变化一旦有变化就根据模板生成新的 Nginx 配置文件然后执行 Nginx reload。这是一套非常成熟的方案。模板文件长这样upstream dynamic_order_service { {{- range service order-service }} server {{ .Address }}:{{ .Port }}; {{- end }} }Consul Template 的配置里指定要监听的服务检测到变化后重新渲染模板并 reload Nginx。这种方法改动小稳定不侵入 Nginx 本身是我最常用的方案。第二种是使用 Nginx 的 njs 模块或者第三方模块直接调用 Consul API 动态更新 upstream。这种方案在开源版 Nginx 上实现门槛较高配置复杂而且第三方模块和 Nginx 版本兼容性是个坑。我一般建议先用 Consul Template 过渡除非你的变更有秒级到达的需求否则没必要引入更复杂的机制。无论用哪种方案都要注意 Nginx reload 不是无代价的。reload 会重新加载配置虽然 Nginx 的 reload 设计成不中断现有连接但在高并发下频繁 reload 还是会造成短暂的 worker 进程重建可能出现极短时间的请求抖动。所以动态更细的间隔要设置合理一般服务列表变化后 5-10 秒内同步即可不需要刻意追求毫秒级。3.3 多个服务的路由与版本管理网关的服务多了路由规则就成了一个需要重点管理的对象。我见过有人把所有 location 堆在一个 server 块里写了一百多行看着就头晕。更合理的做法是按功能模块分文件用 include 的方式聚合。比如把每个服务的路由配置单独放到 /etc/nginx/conf.d/routes/ 目录下每个服务一个文件# /etc/nginx/conf.d/routes/order.conf location /api/order/ { proxy_pass http://dynamic_order_service/; }然后在主配置里统一 include# 主配置 include /etc/nginx/conf.d/routes/*.conf;这样每个服务自治各自的匹配规则互不影响新增一个服务只需要丢一个文件进来再 reload 一次。团队协作的时候A 服务负责人改 A 服务自己的文件不会因为改同一份配置产生冲突。另外网关层可以做简单的版本管理。比如 /api/order/v1/ 转发到 order-service 的 v1 实例/api/order/v2/ 转发到 v2 实例。常见的做法是给服务实例在 Consul 注册时打上版本 tag查询服务的时候通过 tag 过滤。在 Consul API 里可以这样查询指定 tag 的服务实例curl http://127.0.0.1:8500/v1/health/service/order-service?passingtagv2Consul Template 的模板里也可以用过滤条件只选出特定 tag 的实例然后生成对应的 Nginx upstream。这样同一个服务可以同时存在多个版本网关根据请求路径分发到不同版本为灰度发布和蓝绿发布提供了基础技术能力。3.4 HTTPS、限流、日志这些网关层的高级操作网关不只是转发请求还应该承担一些边界职责。我强烈建议在 Nginx 层把 HTTPS 证书配置好对外只暴露 443 端口内部服务之间走 HTTP 即可。配置 HTTPS 后记得设置 HTTP 强制跳转 HTTPS。server { listen 80; server_name gateway.example.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name gateway.example.com; ssl_certificate /etc/nginx/ssl/gateway.crt; ssl_certificate_key /etc/nginx/ssl/gateway.key; ssl_protocols TLSv1.2 TLSv1.3; }限流是网关层很实用的功能不需要在每个服务里实现。Nginx 的 limit_req 模块可以按 IP 对某个接口做请求速率限制比如限制每个 IP 每秒最多 5 个请求limit_req_zone $binary_remote_addr zoneapi_limit:10m rate5r/s; server { location /api/order/ { limit_req zoneapi_limit burst10 nodelay; proxy_pass http://dynamic_order_service/; } }burst10 表示允许短暂的突发超出nodelay 表示超出的请求不排队直接返回 503。这个配置对防止恶意刷接口很有效。日志方面Nginx 默认的 access_log 记录的内容已经很详细建议加上 upstream 的响应时间和上游地址方便定位是网关层慢还是服务本身慢log_format main $remote_addr - $remote_user [$time_local] $request $status $body_bytes_sent $http_referer $http_user_agent $http_x_forwarded_for upstream_addr$upstream_addr request_time$request_time upstream_response_time$upstream_response_time;4. 常见问题与排查技巧实录4.1 Consul 相关的坑Consul 最常见的问题是服务注册了但健康检查一直失败导致服务显示为 critical调用方拿不到实例。定位这个问题的第一步是去 Consul 控制台找到对应服务点开健康检查详情里面会显示健康检查的最终错误信息。绝大多数情况下都是健康检查 URL 在 Consul 服务器那边访问不通要么是 IP 写错了要么是端口没放开要么是服务本身没有实现 /health 端点。我建议每个写着 ASP.NET Core 服务都实现一个轻量的健康检查中间件不需要引入三方库自己在管道里加一个端点即可app.MapGet(/health, () Results.Ok(new { status healthy, time DateTime.Now }));健康检查里可以加上依赖组件的状态比如数据库连接池、Redis 连通性。但注意检查逻辑不要太重如果健康检查本身因为依赖组件超时而拖到几秒会拖垮 Consul 的检查效率可能导致误杀。另一个常见问题是服务停掉之后没有调用注销接口Consul 里残留一堆不存在的服务实例。虽然 DeregisterCriticalServiceAfter 能在健康检查失败后自动清理但建议在进程退出时主动调用 ServiceDeregister这个我在前面已经给出过代码。注意进程被 kill -9 强制杀掉时ApplicationStopping 可能会来不及执行所以生产环境里建议配合超时参数在进程收到终止信号后留出几秒给清理逻辑。还有一个问题是 Consul 集群的 leader 选举。常见是一些节点失联后重新加入raft 协议会花较长时间选主期间写入注册信息会失败新启动的服务注册时报 500。排查方向是检查节点间的网络连通性以及各节点的系统时间是否一致时间偏移太大会破坏 raft 的一致性这对于网络服务和时钟同步敏感的应用来说是个经典坑。集群里所有机器务必开启 NTP 时间同步。4.2 Nginx 相关的坑Nginx 最常见的问题是 502 Bad Gateway几乎每个做网关的人都会遇到。502 表示 Nginx 连接不上上游服务。先用 curl 直接访问上游服务的地址看服务是不是通的。如果服务本身有响应那要看 Nginx 和上游服务之间的网络是否通是不是防火墙拦了。如果 Nginx 是在 Docker 里跑上游服务在宿主机上那 upstream 地址不能写 127.0.0.1因为 Docker 容器里的 127.0.0.1 指容器自己。要写宿主机在 docker0 网桥上的 IP一般是 172.17.0.1。这个坑尤其常见我见过有人被这个问题卡了一下午。配置改动后要记得测试配置再 reloadnginx -t nginx -s reloadnginx -t 会检查所有配置文件语法有问题会明确报出哪个文件的哪一行。建议在 CI/CD 的流水线里也加一步配置检查避免线上提交一份语法有误的配置导致 Nginx 直接拒绝加载。还有 Nginx 转发到 .Net 服务时的 Host 头问题。ASP.NET Core 默认情况下会根据请求的 Host 头生成一些重定向地址如果你在 Nginx 里没有把 Host 头传给上游或者传的是内网地址某些场景下服务重定向出来的地址就是不对的。所以网关层我始终加上proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;4.3 微服务间调用超时与重试服务注册发现没问题网关注册没问题服务间调用还是偶发超时这种情况最让人头疼。我有次排查一个偶发的 500 问题发现是某个服务在调用另一个服务时使用了自己实现的 HTTP 客户端默认超时时间是 100 秒加上没有重试和熔断下游服务一个慢 SQL引起的告警直接蔓延到整个调用链。在 .Net 里做服务间调用我非常推荐使用 Polly 库做超时和重试策略var retryPolicy Policy .HandleHttpRequestException() .OrResultHttpResponseMessage(r (int)r.StatusCode 500) .WaitAndRetryAsync(3, retryAttempt TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); var timeoutPolicy Policy.TimeoutAsync(TimeSpan.FromSeconds(5), TimeoutStrategy.Pessimistic); var circuitBreakerPolicy Policy .HandleHttpRequestException() .CircuitBreakerAsync(2, TimeSpan.FromSeconds(30));超时时间一定要设置而且一定要小于网关层的超时时间。比如 Nginx 的 proxy_read_timeout 设了 10 秒那服务内部的 HTTP 超时就应该设成 3-5 秒这样在下游真正出问题的时候服务能比网关更快地返回失败而不是让网关一直挂住等。重试要谨慎幂等接口可以重试非幂等接口重试可能导致重复下单、重复扣款所以重试策略要按业务接口区分。4.4 Consul 与 Nginx 联调时的小技巧联调阶段我建议先把一个最小闭环跑通启动 Consul注册一个测试服务Nginx 静态转发到该服务验证最外层的请求能打到服务然后再引入 Consul Template把 upstream 改造成动态的。有一个非常实用的排查技巧就是利用 Nginx 的 upstream_addr 日志字段确认实际转发到了哪个实例。如果同时起了多个实例通过访问日志里的 upstream_addr 可以直接看到每次请求落在哪个 IP 上判断负载均衡是否生效。如果请求总是落在同一个实例上要么是 upstream 配置里其他实例的权重设成了 0要么是其他实例健康检查有问题导致 Nginx 把它剔除了。还有一个很重要但容易忽略的点Consul Template 生成的配置文件一定要用清晰的前缀和分隔符避免多个模板写同一个 upstream 名称互相覆盖。我在项目里遇到过 Consul Template 模板更新后某个服务的 upstream 配置突然空了后来发现是模板里服务名写错查询不到实例生成出来的 upstream 块就是空的Nginx reload 后该服务的请求全部 502。这个场景在无监控数据的情况下很难察觉所以我一般在 Consul Template 生成配置后写一个脚本检查生成的配置文件里 server 行数量是否为 0为 0 就不 reload直接告警。5. 从单机到集群的演进建议如果是小团队或者刚起步的微服务项目建议先把 Consul 和 Nginx 部署在同一台或者两台机器上跑通整套流程。这个时候不用考虑高可用先把服务注册、健康检查、网关转发这套机制跑稳定让团队养成通过 Consul 管理服务地址的习惯。当服务数量变多、流量变大之后就要开始拆分部署了。Consul 最好是独立集群不要和业务服务混布否则业务的高负载会影响到 Consul 的稳定性。Nginx 网关层可以部署多台前面再加一层负载均衡。这时候需要考虑会话保持、HTTPS 证书统一管理等但核心配置思路和单机一样变得是部署拓扑和运维流程。服务发现从 HTTP API 切换到 DNS 也是一种演化方向。Consul 自带 DNS 接口配合 CoreDNS 等工具可以做到服务名即域名调用方直接用域名访问。这样服务实例的变化就能对调用方透明代码里不需要自己实现拉取和负载均衡逻辑。不过 DNS 有缓存服务变更的生效时间会延后需要结合业务需求权衡。6. 最后再分享一个实用技巧如果你正在使用 Consul Nginx 这套方案建议把 Consul 的注册生命周期和容器化部署一并考虑进来。现在很多 .Net 微服务已经跑在 Docker 或者 Kubernetes 里服务实例的 IP 是动态分配的注册到 Consul 的地址如果用容器 IP其他服务也可以访问前提是网络互通。在 Kubernetes 环境下我建议直接用 Pod IP 注册并配合 readinessProbe 做好健康检查避免服务还在启动过程中就被注册进去导致调用方请求到未就绪的实例。在实际操作中我自己的体会是这套方案最大的价值不是某个单一组件多厉害而是它把服务注册发现、健康检查、流量接入这些微服务的基础能力拆成了清晰可见、可独立排查的几层。出了问题你能很快判断是 Consul 的服务列表不对还是 Nginx 的路由没配好或者是后端服务自己挂了。这种可排查性在微服务架构里比任何炫酷的特性都重要。希望这篇文章能帮你少踩几个坑把这套组合顺顺利利地用起来。
返回列表