.net core 微服务之Yarp网关

概念

之前写过Ocelot网关,无意之中看到了微软官方有一个Yarp网关(官网),这里记录一下。

对比两者:

Yarp:

1. 高性能,灵活定制反向代理框架

2. 主要是反向代理、路由、负载均衡、健康检查、缓存等核心代理功能

3. 几乎所有功能都可以自己编写中间件或扩展,内置功能不多,但是扩展性强

4. 微软官方支持,基于 Kestrel 和 ASP.NET Core 的底层管道,性能非常接近裸 Kestrel(官方基准测试 QPS 可达百万级)

5. 不内置服务发现(Consul/Eureka/Etcd)等

Ocelot:

1. 配置驱动,内置很多功能(包括 Consul/Eureka 服务发现、RateLimit、JWT、Claims 转换等)

2. 扩展能力有限,如果内置功能满足不了,需要改源码或写中间件,但灵活性比 YARP 低

3. 功能较多,封装较深,性能相比 YARP 略低,尤其在高并发场景下(多层中间件和 JSON 配置解析有开销)

4. 第三方开源项目,更新频率较低,功能稳定,但长期看可能跟不上新版本 .NET 的节奏

5. 内置 Consul/Eureka 支持,直接配置即可

总结:

Ocelot = “全家桶”网关,配置多,开发少,扩展弱。适合小团队快速上手

YARP = “网关框架”,开发多,配置少,扩展强,性能更好。适合要做高性能、灵活定制的网关

实操

简单使用

1. 引入包

dotnet add package Yarp.ReverseProxy

2. Program 引入网关及管道引用

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();
app.Run();

注意:这里ReverseProxy可以自定义名称,只要与3中json文件命名一致既可。

也可以引入多个配置源

services.AddReverseProxy()
    .LoadFromConfig(Configuration.GetSection("ReverseProxy1"))
    .LoadFromConfig(Configuration.GetSection("ReverseProxy2"));

注意:通常有不同的租户或者多个团队,就会配置不同的配置源去区分路由,尽量用同一个配置源,用不同的 Routes和 Clusters 去区分。ReverseProxy最开始设计就是一个配置源,只初始化一次反向代理性能更好。具体根据自己实际情况选择

3. appsettings.json 添加配置文件

{
 "ReverseProxy": {
   "Routes": {
     "route1" : {
       "ClusterId": "cluster1",
       "Match": {
         "Path": "{**catch-all}"
       }
     }
   },
   "Clusters": {
     "cluster1": {
       "Destinations": {
         "destination1": {
           "Address": "https://example.com/(下游服务url)"
         }
       }
     }
   }
 }
}
这里有两个核心(必须配置): Routes 和 Clusters
Routes :
用于匹配路由,可以配置Headers,Authorization,Cors 等策略。但是至少包含以下3个字段
1. RouteId - 路由匹配唯一名称
2. ClusterId - 指的是集群部分中条目的名称。就是指定下游转发的名称,要与 Clusters 中 对应(现在这个示例叫cluster1)
3. Match - 包含 Hosts 数组或 Path 模式字符串。Path的匹配规则
这里有两个核心: Routes 和 Clusters
Clusters:用于配置下游服务,可以配置下游集群,健康检测,负载均衡等策略。至少包含以下1个字段
1. Destinations - 下游地址。如果是集群有多个地址就配置多个 destination1

官方提供了完整的配置,可以参考。接下来展示一些常用的配置

{
  // Base URLs the server listens on, must be configured independently of the routes below
  "Urls": "http://localhost:5000;https://localhost:5001",
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      // Uncomment to hide diagnostic messages from runtime and proxy
      // "Microsoft": "Warning",
      // "Yarp" : "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "ReverseProxy": {
    // Routes tell the proxy which requests to forward
    "Routes": {
      "minimumroute" : {
        // Matches anything and routes it to www.example.com
        "ClusterId": "minimumcluster",
        "Match": {
          "Path": "{**catch-all}"
        }
      },
      "allrouteprops" : {
        // matches /something/* and routes to "allclusterprops"
        "ClusterId": "allclusterprops", // Name of one of the clusters
        "Order" : 100, // Lower numbers have higher precedence
        "MaxRequestBodySize" : 1000000, // In bytes. An optional override of the server's limit (30MB default). Set to -1 to disable.
        "AuthorizationPolicy" : "Anonymous", // Name of the policy or "Default", "Anonymous"
        "CorsPolicy" : "Default", // Name of the CorsPolicy to apply to this route or "Default", "Disable"
        "Match": {
          "Path": "/something/{**remainder}", // The path to match using ASP.NET syntax.
          "Hosts" : [ "www.aaaaa.com", "www.bbbbb.com"], // The host names to match, unspecified is any
          "Methods" : [ "GET", "PUT" ], // The HTTP methods that match, uspecified is all
          "Headers": [ // The headers to match, unspecified is any
            {
              "Name": "MyCustomHeader", // Name of the header
              "Values": [ "value1", "value2", "another value" ], // Matches are against any of these values
              "Mode": "ExactHeader", // or "HeaderPrefix", "Exists" , "Contains", "NotContains", "NotExists"
              "IsCaseSensitive": true
            }
          ],
          "QueryParameters": [ // The query parameters to match, unspecified is any
            {
              "Name": "MyQueryParameter", // Name of the query parameter
              "Values": [ "value1", "value2", "another value" ], // Matches are against any of these values
              "Mode": "Exact", // or "Prefix", "Exists" , "Contains", "NotContains"
              "IsCaseSensitive": true
            }
          ]
        },
        "Metadata" : { // List of key value pairs that can be used by custom extensions
          "MyName" : "MyValue"
        },
        "Transforms" : [ // List of transforms. See the Transforms article for more details
          {
            "RequestHeader": "MyHeader",
            "Set": "MyValue"
          }
        ]
      }
    },
    // Clusters tell the proxy where and how to forward requests
    "Clusters": {
      "minimumcluster": {
        "Destinations": {
          "example.com": {
            "Address": "http://www.example.com/"
          }
        }
      },
      "allclusterprops": {
        "Destinations": {
          "first_destination": {
            "Address": "https://contoso.com"
          },
          "another_destination": {
            "Address": "https://10.20.30.40",
            "Health" : "https://10.20.30.40:12345/test" // override for active health checks
          }
        },
        "LoadBalancingPolicy" : "PowerOfTwoChoices", // Alternatively "FirstAlphabetical", "Random", "RoundRobin", "LeastRequests"
        "SessionAffinity": {
          "Enabled": true, // Defaults to 'false'
          "Policy": "Cookie", // Default, alternatively "CustomHeader"
          "FailurePolicy": "Redistribute", // default, Alternatively "Return503Error"
          "Settings" : {
              "CustomHeaderName": "MySessionHeaderName" // Defaults to 'X-Yarp-Proxy-Affinity`
          }
        },
        "HealthCheck": {
          "Active": { // Makes API calls to validate the health.
            "Enabled": "true",
            "Interval": "00:00:10",
            "Timeout": "00:00:10",
            "Policy": "ConsecutiveFailures",
            "Path": "/api/health", // API endpoint to query for health state
            "Query": "?foo=bar"
          },
          "Passive": { // Disables destinations based on HTTP response codes
            "Enabled": true, // Defaults to false
            "Policy" : "TransportFailureRateHealthPolicy", // Required
            "ReactivationPeriod" : "00:00:10" // 10s
          }
        },
        "HttpClient" : { // Configuration of HttpClient instance used to contact destinations
          "SSLProtocols" : "Tls13",
          "DangerousAcceptAnyServerCertificate" : false,
          "MaxConnectionsPerServer" : 1024,
          "EnableMultipleHttp2Connections" : true,
          "RequestHeaderEncoding" : "Latin1", // How to interpret non ASCII characters in request header values
          "ResponseHeaderEncoding" : "Latin1" // How to interpret non ASCII characters in response header values
        },
        "HttpRequest" : { // Options for sending request to destination
          "ActivityTimeout" : "00:02:00",
          "Version" : "2",
          "VersionPolicy" : "RequestVersionOrLower",
          "AllowResponseBuffering" : "false"
        },
        "Metadata" : { // Custom Key value pairs
          "TransportFailureRateHealthPolicy.RateLimit": "0.5", // Used by Passive health policy
          "MyKey" : "MyValue"
        }
      }
    }
  }
}
完整网关json配置

代理

1. 请求头限制,在Match中设置请求头标头、值、及对应的匹配策略

        "Match": {
          "Path": "{**catch-all}",
          // 限制请求头
          "Headers": [
            {
              "Name": "header4",
              "Values": [ "value1", "value2" ],
              "Mode": "ExactHeader"  //过滤策略
            },
            {
              "Name": "header5",
              "Mode": "Exists"
            }
          ]
        }

Mode匹配策略有

ExactHeader:0,【精确标题】任何具有给定名称的标头都必须完全匹配,并遵守区分大小写的设置。如果标头包含多个值(以 , 或 ; 分隔),则会在匹配前将它们拆分。单对引号也会在匹配前从值中剥离。
HeaderPrefix:1,【标头前缀】任何具有给定名称的标头都必须按前缀匹配,并遵守区分大小写的设置。如果标头包含多个值(以 , 或 ; 分隔),则会在匹配前将它们拆分。匹配前,还会从值中删除单对引号。
Contains:2,【包含】具有给定名称的任何标头都必须包含任何匹配值,但须遵守区分大小写的设置。
NotContains:3,【不包含】标头必须存在,且值必须非空。具有给定名称的任何标头都不能包含任何匹配值,但需遵守区分大小写的设置。
Exists:4,【存在】标头必须存在且包含任意非空值。如果存在多个同名标头,则规则也会匹配。
NotExists:5,【不存在】该标题一定不存在。

2. 参数限制

"Match": {
      "Path": "{**catch-all}",
      "QueryParameters": [
        {
          "Name": "queryparam1",
          "Values": [ "value1" ],
          "Mode": "Exact"
        }
      ]
    }

Mode匹配策略有

Exact:0,【精确】查询字符串必须完全匹配,并受大小写区分设置约束。查询参数仅支持单个名称。如果存在多个同名查询参数,则匹配失败。
Contains:1,【包含】查询字符串键必须存在,且子字符串必须与每个相应的查询字符串值匹配。设置区分大小写。仅支持单个查询参数名称。如果存在多个同名查询参数,则匹配失败。
NotContains:2,【不包含】查询字符串键必须存在,且值不得与每个查询字符串值匹配。需注意区分大小写设置。如果有多个值,则该值必须不包含任何值。仅支持单个查询参数名称。如果有多个同名查询参数,则匹配失败。
Prefix:3,【前缀】查询字符串键必须存在,并且前缀必须与每个相应的查询字符串值匹配。设置区分大小写。仅支持单个查询参数名称。如果有多个查询参数具有相同的名称,则匹配失败。
Exists:4,【存在】查询字符串键必须存在并且包含任何非空值。

3. 请求及响应转换

修改请求和响应的内容,比如添加请求头,通过写入请求头对不同的路由进行区分

{
  "ReverseProxy": {
    "Routes": {
      "route1" : {
        "ClusterId": "cluster1",
        "Match": {
          "Hosts": [ "localhost" ]
        },
        "Transforms": [
          { "PathPrefix": "/apis" },
          {
            "RequestHeader": "header1",
            "Append": "bar"
          },
          {
            "ResponseHeader": "header2",
            "Append": "bar",
            "When": "Always"
          },
          { "ClientCert": "X-Client-Cert" },
          { "RequestHeadersCopy": "true" },
          { "RequestHeaderOriginalHost": "true" },
          {
            "X-Forwarded": "Append",
            "HeaderPrefix": "X-Forwarded-"
          }
        ]
      },
      "route2" : {
        "ClusterId": "cluster1",
        "Match": {
          "Path": "/api/{plugin}/stuff/{**remainder}"
        },
        "Transforms": [
          { "PathPattern": "/foo/{plugin}/bar/{**remainder}" },
          {
            "QueryValueParameter": "q",
            "Append": "plugin"
          }
        ]
      }
    },
    "Clusters": {
      "cluster1": {
        "Destinations": {
          "cluster1/destination1": {
            "Address": "https://localhost:10001/Path/Base"
          }
        }
      }
    }
  }
}

这里X-Forwarded默认是开启的,会将请求的源,客户端ip端口等信息写入请求头,写入的请求头前缀就是配置的HeaderPrefix

4. 负载均衡

针对下游集群,设置下游的负载均衡策略

    "Clusters": {
      "user_cluster": {
        "LoadBalancingPolicy": "PowerOfTwoChoices", //负载均衡策略
        "Destinations": {
          "user_cluster/destination1": {
            "Address": "http://localhost:5224/"
          },
          "user_cluster/destination2": {
            "Address": "http://localhost:5225/"
          }
        }
      }
    }

这里yarp 内置了5种负载均衡策略

  • FirstAlphabetical :按字母顺序选择第一个可用目标,不考虑负载。这对于双目标故障转移系统很有用。
  • Random : 随机选择目的地。
  • PowerOfTwoChoices:(默认)选择两个随机目的地,然后选择分配请求最少的目的地。这样可以避免选择繁忙目的地所带来的开销LeastRequests以及最坏的情况。
  • RoundRobin :按顺序循环选择目的地
  • LeastRequests :选择分配请求最少的目的地。这需要检查所有目的地。(更精准,但是性能消耗更多)

如果以上5中都满足不了需求,就可以自定义负载均衡策略

using Microsoft.ReverseProxy.Service.LoadBalancing;
using Yarp.ReverseProxy.Model;

public class LowestLatencyPolicy : ILoadBalancingPolicy
{
    public string Name => "LowestLatency";

    public DestinationState PickDestination(HttpContext context, ClusterState cluster, IReadOnlyList<DestinationState> availableDestinations)
    {
        return availableDestinations
            .OrderBy(d => d.DynamicState?.Health?.Latency ?? TimeSpan.MaxValue)
            .FirstOrDefault();
    }
}

 

builder.Services.AddReverseProxy()
    .AddLoadBalancingPolicies<LowestLatencyPolicy>() // 注册自定义策略
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

 

"Clusters": {
  "user_cluster": {
    "LoadBalancingPolicy": "LowestLatency",
    "Destinations": {
      "d1": { "Address": "http://localhost:5001" },
      "d2": { "Address": "http://localhost:5002" }
    }
  }
}

 

5.  健康检查

网关会定期对集群中的地址进行健康检查。健康检查分为主动健康检查和被动健康检查

5.1 主动健康检查

YARP 可以通过定期向指定的健康端点发送探测请求并分析响应来主动监控目标的健康状况。该分析由集群指定的主动健康检查策略执行,并计算出新的目标健康状况。最终,该策略会根据 HTTP 响应代码(2xx 表示健康)将每个目标标记为健康或不健康,并重建集群的健康目标集合。

    "Clusters": {
      "user_cluster": {
        "LoadBalancingPolicy": "PowerOfTwoChoices", //负载均衡策略
        "HealthCheck": {
          "Active": { //主动健康检测
            "Enabled": true, //启用健康检测
            "Interval": "00:00:10", // 健康检测间隔
            "Timeout": "00:00:02", // 健康检测超时
            "Policy": "ConsecutiveFailures", // 健康检测策略
            "Path": "/api/Health", // 下游服务接收健康检测请求的路径
            "Query": "?foo=bar" // 下游服务接收健康检测请求的参数
          }
        },
        "Metadata": {
          "ConsecutiveFailuresHealthPolicy.Threshold": "3" //主动健康检测策略中连续失败次数阈值
        },
        "Destinations": {
          "user_cluster/destination1": {
            "Address": "http://localhost:5224/"
          },
          "user_cluster/destination2": {
            "Address": "http://localhost:5225/"
          }
        }
      }
    }

这里ConsecutiveFailuresHealthPolicy.Threshold 设置的是健康检测失败的次数,达到设置的次数就会标记为不健康,默认为2

其他参数:

  • Enabled:指示集群是否启用主动健康检查的标志。默认false
  • Interval:发送健康探测请求的周期,默认00:00:15
  • Timeout:探测请求超时。默认00:00:10
  • Policy:评估目标活跃健康状态的策略名称。必填参数
  • Path:集群所有目标的健康检查路径。默认null
  • Query:对所有集群目标进行健康检查查询。默认null

也可以自定义扩展主动健康检测策略,需要继承IActiveHealthCheckPolicy。具体用法可以自己了解

5.2 被动健康监测

YARP 可以被动地监视客户端请求代理的成功和失败情况,从而被动地评估目标的健康状况。代理请求的响应会被专用的被动健康检查中间件拦截,并传递给集群上配置的策略。该策略会分析响应,以评估生成响应的目标是否健康。然后,它会计算并将新的被动健康状态分配给相应的目标,并重建集群的健康目标集合。

与主动健康检查逻辑有一个重要区别。一旦目标被分配了不健康的被动状态,它将停止接收所有新流量,从而阻止将来的健康状况重新评估。该策略还会在配置的时间段后安排目标重新激活。

两者区别:

主动健康检查是通过间隔时间去轮询看是否为不健康,能够提前发现,但是消耗请求资源,并且不精准。

被动健康检查不会轮询去检测,会监控每次请求的情况,根据情况或者失败的具体内容更精准的标记是否健康。

"Clusters": {
  "cluster1": {
    "HealthCheck": {
      "Passive": {
        "Enabled": "true",
        "Policy": "TransportFailureRate",
        "ReactivationPeriod": "00:02:00"
      }
    },
    "Metadata": {
      "TransportFailureRateHealthPolicy.RateLimit": "0.5"
    },
    "Destinations": {
      "cluster1/destination1": {
        "Address": "https://localhost:10000/"
      },
      "cluster1/destination2": {
        "Address": "http://localhost:10010/"
      }
    }
  }
}
  • Enabled- 指示集群是否启用被动健康检查的标志。默认false
  • Policy- 评估目标被动健康状态的策略名称。必填参数
  • ReactivationPeriod- 不健康目标的被动健康状态重置为“ Unknown ”并重新开始接收流量的周期。默认值为 null,这意味着该周期将由 IPassiveHealthCheckPolicy 设置。

自定义扩展策略集成:IPassiveHealthCheckPolicy

6. 请求速率限制

//自定义请求速率策略
services.AddRateLimiter(options => {
    options.AddFixedWindowLimiter("general_ratelimit_policy", opt =>
    {
        opt.PermitLimit = 10; // 每窗口最多 10 个请求
        opt.Window = TimeSpan.FromSeconds(1); // 每个窗口是 1 秒,结合上面就是每秒最多 10 个请求
        opt.QueueLimit = 10; // 超出请求可以排队 10 个,超过10个直接拒绝请求
        opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; // 先进先出处理排队
        opt.AutoReplenishment = true; // 到时间自动重置窗口
    });
    //超过限制请求数后触发事件,自定义提示
    options.OnRejected = async (context, cancellationToken) =>
    {
        var result = new OperatorResult
        {
            Message = "当前服务器繁忙,请稍后再试。",
            Result = ResultType.Exception
        };
        // context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        context.HttpContext.Response.StatusCode = StatusCodes.Status200OK;   //标准应该返回429,这里返回200方便前端处理
        //context.HttpContext.Response.Headers["Retry-After"] = "10";
        context.HttpContext.Response.ContentType = "application/json";
        var json = JsonSerializer.Serialize(result);
        await context.HttpContext.Response.WriteAsync(json, cancellationToken);
    };
});

 

  app.UseRateLimiter(); //添加请求速率限制中间件

  

    "Routes": {
      "user_route": {
        "ClusterId": "user_cluster",
        "RateLimiterPolicy": "general_ratelimit_policy", //自定义速率限制策略
        "Match": {
          "Path": "{**catch-all}"   // 全部路径都匹配
          // "Hosts": [ "www.aaaaa.com", "www.bbbbb.com" ] // 指定域名匹配
        }
      }
    },

 

7. 请求超时设置

//自定义超时策略
services.AddRequestTimeouts(options =>
{
    options.AddPolicy("general_timeout_policy", TimeSpan.FromSeconds(60));
});

 

 app.UseRequestTimeouts(); //添加超时中间件

 

    "Routes": {
      "user_route": {
        "ClusterId": "user_cluster",
        "TimeoutPolicy": "general_timeout_policy", //自定义超时策略
        "Match": {
          "Path": "{**catch-all}"   // 全部路径都匹配
          // "Hosts": [ "www.aaaaa.com", "www.bbbbb.com" ] // 指定域名匹配
        }
      }
    },

 

8. 认证和授权

对于微服务应该在网关层进行身份校验,防止到下游才进行身份验证造成的资源浪费。

一般情况下,网关校验身份信息后,将账号信息写入请求头,下游通过请求头进行判断身份信息。网关中配置的下游地址也是内网地址,不用对外公开。但是这只针对于下游和网关都在同一内网下的情况,如果不在同一内网就会存在安全问题,当暴露了下游的接口地址,并且开通了外网访问,就可以绕过网关的认证,直接对下游接口进行请求。

所以我这里是       网关进行令牌的基础校验(只校验token颁发的issue)+下游服务进行资源客户端校验      的两种结合的模式进行认证和授权,虽然进行了两次校验,但是可以在网关层直接拦截大量异常请求,也可以防止下游是外网可访问并且IP暴露的风险。

如果下游和网关都在同一内网,还是建议只做网关的一次校验

public static class JwtConfig
{
    /// <summary>
    /// 基础认证,只校验issuer和audience,不校验token有效性
    /// </summary>
    /// <param name="services"></param>
    /// <param name="configuration"></param>
    public static void AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration)
    {
        // 配置 token 验证
        JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
        services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);

        services.AddOpenIddict().AddValidation(options =>
        {
            options.SetIssuer(configuration["OpenIddictConfig:Issuer"]);
            //options.AddAudiences(configuration["OpenIddictConfig:Audiences"].Split(" ", StringSplitOptions.RemoveEmptyEntries));

            options.AddEncryptionKey(new SymmetricSecurityKey(
                Convert.FromBase64String(configuration["OpenIddictConfig:EncryptionKey"])));

            options.UseSystemNetHttp();
            options.UseAspNetCore();
        });

        services.AddAuthorization(options => { 
            //认证策略
            options.AddPolicy("authenticated_policy", policy =>
            { 
                    policy.RequireAuthenticatedUser();
            });
         });
    }
}

 

builder.Services.AddJwtAuthentication(builder.Configuration);

app.UseAuthentication(); 
app.UseAuthorization();
    "Routes": {
      "user_route": {
        "ClusterId": "user_cluster",
        "AuthorizationPolicy": "authenticated_policy", // 自定义授权策略
        "Match": {
          "Path": "{**catch-all}"   // 全部路径都匹配
          // "Hosts": [ "www.aaaaa.com", "www.bbbbb.com" ] // 指定域名匹配
        }
      }
    },

这里只是网关层的配置,我是基于JWT进行认证的,所以是配置的JWT的校验,如果是其他认证一样的道理。如果有多个服务校验的client资源都不一样,就可以写多个自定义授权策略去配置匹配到的路由。

下游的授权就是单服务正常的授权,这里忽略。

9. 跨域

可以针对不同路由进行不同的跨域策略设置

 //自定义跨域策略
 services.AddCors(options =>
 {
     options.AddPolicy("general_cores_policy", builder =>
     {
         builder.AllowAnyOrigin();
         builder.AllowAnyMethod();
         builder.AllowAnyHeader();
      });
 });

app.UseCors();
    "Routes": {
      "user_route": {
        "ClusterId": "user_cluster",
       
        "CorsPolicy": "general_cores_policy", //自定义跨域策略
        "Match": {
          "Path": "{**catch-all}"
        }
      }
    },

Yarp结合服务发现Consul

由于Yarp没有像Ocelot那样原生支持Consul,所以只能自己去扩展

安装Consul

通过docker 安装

1. 通过docker 拉取

docker pull consul:1.15

2. 创建配置。在/opt/consul/config 文件夹下面创建server.json

{
  "server": true,
  "bootstrap_expect": 1,
  "ui": true,
  "client_addr": "0.0.0.0",
  "bind_addr": "0.0.0.0",
  "data_dir": "/consul/data",
  "acl": {
    "enabled": true,
    "default_policy": "deny",
    "down_policy": "extend-cache",
    "tokens": {
      "master": "master-token-123",
"agent": "master-token-123"#设置了token一定要设置这个,否则会出现服务的示例掉线超过设置的清理时间,仍然出现在列表无法被清理的情况 } } }

注意:

server: 启用服务端,如果是服务端才是true

bootstrap_expect: 集群数量,服务端如果是1,则不会等其他集群都加入了才启动,如果是配置的其他数量(通常是3,5),例如3,要等其他两个集群启用完成了才会成功启动。(其他两个集群会多配置一个加入的地址 -retry-join=)

data_dir 指定的路径就是将数据中心数据挂载出来,否则重启容器之前的配置都会清空。

tokens配置的是超管秘钥,master用于UI界面登录,如果不配置并且UI暴露在公网下面,就不安全,所以这里要启用ACL并且设置token。 agent 用于consul 自动删除,如果不配置会出现服务的示例掉线超过设置的清理时间,仍然无法清理的情况。

 

bind=0.0.0.0:绑定地址(容器内所有网卡可访问)

-client=0.0.0.0:允许外部访问 API(可改成 127.0.0.1 保安全)

3. 启动容器

docker run -d --name=consul-server --restart=always \
  -p 8500:8500 \
  -p 8600:8600/udp \
  -p 8600:8600 \
  -v /opt/consul/data:/consul/data \
  -v /opt/consul/config:/consul/config \
  consul:1.15 agent

4. 访问地址:http://ip:8500, 输入设置的token就行了

143ce931-185a-4e96-8083-4158f4c0be5f

通过RPM拉取安装包直接安装

1. 拉取安装包

sudo yum install -y yum-utils 
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo 
sudo yum -y install consul

2. 安装完后,更改consul配置文件 /etc/consul.d/consul.hcl 里面配置注释掉了,直接取消注释并修改为自己的配置就行了

#数据保存的文件夹
data_dir = "/opt/consul"   
#配置acl
acl {
  enabled        = true
  default_policy = "deny"
  down_policy    = "extend-cache"
  tokens {
    initial_management = "123456",
    agent="123456"
  }
}
#绑定客户端接口(包括 HTTP 和 DNS 服务器)的地址,默认就行了
client_addr = "0.0.0.0"
#启用UI
ui_config{
  enabled = true
}
#服务端启用
server = true
#服务端的IP,这里和docker启动有区别,要手动配置IP
bind_addr = "192.168.230.130" # Listen on all IPv4
#单实例
bootstrap_expect=1

3. 设置开机自启

创建服务文件

sudo tee /etc/systemd/system/consul.service <<EOF 
> [Unit] 
> Description=Consul Agent 
> After=network.target > 
> [Service] 
> ExecStart=/usr/bin/consul agent -config-dir=/etc/consul.d> Restart=on-failure 
> LimitNOFILE=65536 
> 
> [Install] 
> WantedBy=multi-user.target 
> EOF

启动consul 并设置自启动

sudo systemctl start consul
sudo systemctl enable consul
journalctl -u consul -f  #查看日志

Consul作为配置中心

这里是通过代码作为客户端直接连接服务端,会有一个问题,就是如果服务端做集群,那么本地这里连接就有问题,因为本地只能配置一个服务端地址,当集群中配置的服务端地址那个服务挂掉之后,就算新选出了leader,还是会连接不上。

所以出现服务端集群时:每个服务会部署一个本地client agent ,这个agent 会负责连接集群上面每个服务,代码中连接的consul地址始终是本地的client agent 地址,就算服务端集群挂了一个也不影响代码正常运行。这里演示就直接通过代码直连服务端,具体的可以自行去chatgpt 扩展

1. 引入包

Consul
Winton.Extensions.Configuration.Consul   //用于后面自动读取consul中kv的配置

2. 添加封装的注册和自动读取KV方法

public class ConsulConfig
{
    /// <summary>
    /// 服务唯一ID
    /// </summary>
    public string Id { get; set; }
    /// <summary>
    /// 服务名称
    /// </summary>
    public string Name { get; set; }
    /// <summary>
    /// consul地址
    /// </summary>
    public string Address { get; set; } = "http://127.0.0.1:8500";
    /// <summary>
    /// token
    /// </summary>
    public string Token { get; set; }
    /// <summary>
    /// 数据中心
    /// </summary>
    public string Datacenter { get; set; } = "dc1";
}

 

{
  "ConsulConfig": {
    "Name": "userapi",
    "Address": "http://192.168.0.237:8500",
    "Token": "123456"
  }
}

 

 public static class ConsulExtensions
 {
     /// <summary>
     /// 初始化Consul
     /// </summary>
     /// <param name="app"></param>
     public static void AddConsul(this WebApplication app)
     {
         var config = app.Configuration.GetSection("ConsulConfig").Get<ConsulConfig>();
         var uosoLogService = app.Services.GetRequiredService<IUosoLogService>();
         ConsulClient consulClient=new ConsulClient();
         var serviceId = Guid.NewGuid().ToString();
         app.Lifetime.ApplicationStarted.Register(() =>
         {
             // 获取监听的端口(取第一个地址来解析端口)
             var server = app.Services.GetRequiredService<IServer>();
             var addressesFeature = server.Features.Get<IServerAddressesFeature>();
             var address = addressesFeature?.Addresses.First();
             var port = new Uri(address!).Port;

             // 获取本机可用的 IPv4 地址(排除 127.0.0.1)
             var hostIp = Dns.GetHostAddresses(Dns.GetHostName())
                             .First(ip => ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork &&
                                          !IPAddress.IsLoopback(ip));
             //这里默认为内网IP,如果内网不互通,或者跨公网就主动调用获取外网ip或者配置文件里面配置外网ip,这里直接组装端口
             var serviceAddress = $"http://{hostIp}:{port}";
             var uri = new Uri(serviceAddress);

             serviceId = $"{config.Name}-{uri.Host}-{uri.Port}";

             consulClient = new ConsulClient(consulConfig =>
             {
                 consulConfig.Address = new Uri(config.Address);
                 if (!string.IsNullOrWhiteSpace(config.Token))
                 {
                     consulConfig.Token = config.Token;
                 }
                 consulConfig.Datacenter = config.Datacenter;
             });
             var registration = new AgentServiceRegistration()
             {
                 ID = serviceId ?? $"{config.Name}-{uri.Host}-{uri.Port}",
                 Name = config.Name,
                 Address = uri.Host,
                 Port = uri.Port,
                 Tags = new[] { "api" },
                 Check = new AgentServiceCheck
                 {
                     //consul健康检查超时间
                     Timeout = TimeSpan.FromSeconds(3),
                     //服务停止5秒后注销服务
                     DeregisterCriticalServiceAfter = TimeSpan.FromSeconds(5),
                     //consul健康检查地址
                     HTTP = $"{uri.Scheme}://{uri.Host}:{uri.Port}/api/Health",
                     //consul健康检查间隔时间
                     Interval = TimeSpan.FromSeconds(2)
                 }
             };
             
             try
             {
                 consulClient.Agent.ServiceRegister(registration).GetAwaiter().GetResult();
             }
             catch (Exception ex)
             {
                 uosoLogService.LogError("Consul注册失败", ex);
             }
         });
         // 在应用停止时注销服务
         app.Lifetime.ApplicationStopping.Register(() =>
         {
             consulClient.Agent.ServiceDeregister(serviceId).Wait();
             consulClient.Dispose();
             uosoLogService.LogWarning($"Consul已经注销: {serviceId}");
         });
         //健康检测
         app.MapGet("/api/Health", () =>  Results.Ok());
     }
     /// <summary>
     /// 加载Consul配置文件
     /// </summary>
     /// <param name="app"></param>
     public static void AddConsulKVConfig(this WebApplicationBuilder builder,bool isLoadCommon=true)
     {
         var env = builder.Environment;
         var config = builder.Configuration.GetSection("ConsulConfig").Get<ConsulConfig>();
         if (string.IsNullOrEmpty(config?.Address)) return;

         string envJson= env.EnvironmentName switch
         {
             "Development" => "dev.json",
             "Production" => "pro.json",
             _ => "json"
         };

         // 调用SetBasePath 就会完全用KV上面配置
         //builder.Configuration
         //    .SetBasePath(Directory.GetCurrentDirectory());  //会清空掉程序中appsettings.json的配置
         // 这里只追加配置文件,保留本地的appsettings.json 配置


         //加载公共配置
         if (isLoadCommon)
         {
             builder.Configuration.AddConsul(
                 "common.json",
                 options =>
                 {
                     options.ConsulConfigurationOptions = cco =>
                     {
                         cco.Address = new Uri(config.Address);
                         if (!string.IsNullOrWhiteSpace(config.Token))
                         {
                             cco.Token = config.Token;
                         }
                     };
                     options.Optional = true; // Consul 上没有该 Key 也不会报错
                     options.ReloadOnChange = true; // KV 变更时热更新
                     options.OnLoadException = ctx => ctx.Ignore = true; // 忽略错误,继续启动
                 });
         }
         //加载服务配置
         if (!string.IsNullOrEmpty(config?.Name))
         {
             builder.Configuration.AddConsul(
                 $"{config?.Name}.{envJson}",
                 options =>
                 {
                     options.ConsulConfigurationOptions = cco =>
                     {
                         cco.Address = new Uri(config.Address);
                         if (!string.IsNullOrWhiteSpace(config.Token))
                         {
                             cco.Token = config.Token;
                         }
                     };
                     options.Optional = true; // Consul 上没有该 Key 也不会报错
                     options.ReloadOnChange = true; // KV 变更时热更新
                     options.OnLoadException = ctx => ctx.Ignore = true; // 忽略错误,继续启动
                 });
         }
     
         // 支持环境变量覆盖
         builder.Configuration.AddEnvironmentVariables();

         
     }



 }

注意:加载的json文件后加载会覆盖之前加载的配置。

 

3. Program 中引入

builder.AddConsulKVConfig();
....
app.AddConsul();
....

4. 测试

在consul中添加common.json 文件和user.dev.json 文件,每个json文件中都加入配置"test":"user"

741daca6-2b5f-4999-9144-feeb4bf7bc99

 

ef7cd6fb-ec0f-4964-9a8c-651ee1a479ca

        [HttpGet("testYarp")]
        public async Task<IActionResult> TestYarp(int parme)
        {
            return Ok("当前test值为:" + _configuration.GetSection("test").Value);
        }

8a2f6eb4-1f04-4074-acb8-ecc3036a0660

这里可以看到输出的是userapi.dev.json的test值,因为userapi比common后加载

 

Yarp扩展自动读取Consul

Yarp扩展实现根据Consul服务的实例变更自动更新配置

1. program注册服务

#region Consul
//读取配置文件,最好是先读取
builder.AddConsulKVConfig();
//添加日志,这里日志是我自己封装的日志,如果需要记录日志,那就改成自己程序的日志
//builder.AddELKSerilog();
var consulConfig = builder.Configuration.GetSection("ConsulConfig").Get<ConsulConfig>();
builder.Services.AddSingleton<IConsulClient>(_ =>
{
    var addr = consulConfig?.Address;
    var token = consulConfig?.Token;
    return new ConsulClient(c =>
    {
        c.Address = new Uri(addr);
        if (!string.IsNullOrWhiteSpace(token)) c.Token = token;
    });
});
//注册Provider
builder.Services.AddSingleton<IProxyConfigProvider, ConsulDynamicConfigProvider>();
#endregion

2. 添加自定义实现的Provider

using Consul;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using Uoso.Log;
using Yarp.ReverseProxy.Configuration;
using DestinationConfig=Yarp.ReverseProxy.Configuration.DestinationConfig;
using RouteConfig = Yarp.ReverseProxy.Configuration.RouteConfig;

namespace ShopGateway.Infrastructure
{

    public class ConsulDynamicConfigProvider : IProxyConfigProvider, IDisposable
    {
        private ConfigSnapshot _current;
        private readonly CancellationTokenSource _cts = new();
        private readonly IConsulClient _consul;
        private readonly IUosoLogService _logger;
        private readonly string _serviceConfigKey;
        private readonly object _lock = new();

        // 用于缓存最新的KV配置(Routes + Clusters模板)
        private Dictionary<string, RouteConfig> _routesTemplate = new();
        private Dictionary<string, ClusterConfig> _clustersTemplate = new();
        //线程安全的异步队列 ,用于 唤醒服务节点轮询的信号
        //private readonly Channel<bool> _serviceUpdateSignal = Channel.CreateUnbounded<bool>();
        private CancellationTokenSource _signalCts = new();
        public ConsulDynamicConfigProvider(
            IWebHostEnvironment env,
            IConsulClient consul,
            IUosoLogService logger)
        {
            _consul = consul;
            _logger = logger;
            var envJson = env.EnvironmentName switch
            {
                "Development" => "dev.json",
                "Production" => "pro.json",
                _ => "json"
            };
            _serviceConfigKey = $"gateway.{envJson}";

            _current = new ConfigSnapshot(new List<RouteConfig>(), new List<ClusterConfig>());
            // 构造函数里先同步拉一次 KV
            var routes = new Dictionary<string, RouteConfig>();
            var clusters = new Dictionary<string, ClusterConfig>();
            LoadConfigFromKV_LongPoll(_serviceConfigKey, routes, clusters, 0, _cts.Token).GetAwaiter().GetResult();
            _routesTemplate = routes;
            _clustersTemplate = clusters;
            // 启动两个长轮询
            Task.Run(() => WatchKVLoop(_cts.Token), _cts.Token);
            Task.Run(() => WatchServicesLoop(_cts.Token), _cts.Token);
        }

        public IProxyConfig GetConfig() => _current;

        #region KV 配置长轮询
        private async Task WatchKVLoop(CancellationToken token)
        {
            ulong lastIndexCommon = 0;
            ulong lastIndexService = 0;

            while (!token.IsCancellationRequested)
            {
                try
                {
                    var routes = new Dictionary<string, RouteConfig>();
                    var clusters = new Dictionary<string, ClusterConfig>();

                   // lastIndexCommon = await LoadConfigFromKV_LongPoll(_commonConfigKey, routes, clusters, lastIndexCommon, token);
                    lastIndexService = await LoadConfigFromKV_LongPoll(_serviceConfigKey, routes, clusters, lastIndexService, token);

                    // 检测 cluster 是否变化
                    bool clustersChanged = !clusters.Keys.SequenceEqual(_clustersTemplate.Keys);

                    // 更新模板
                    _routesTemplate = routes;
                    _clustersTemplate = clusters;

                    //if (clustersChanged)
                    //{
                    // 触发服务节点轮询线程提前执行
                    Signal();
                    //var a=  _serviceUpdateSignal.Writer.TryWrite(true);
                    //}

                    // 结合最新节点生成快照
                    // await RebuildSnapshotFromTemplates(token);

                }
                catch (TaskCanceledException) { }
                catch (Exception ex)
                {
                    _logger.LogError("KV 长轮询异常,5秒后重试",ex);
                    await Task.Delay(5000, token);
                }
            }
        }

        private async Task<ulong> LoadConfigFromKV_LongPoll(
            string key,
            Dictionary<string, RouteConfig> routes,
            Dictionary<string, ClusterConfig> clusters,
            ulong lastIndex,
            CancellationToken token)
        {
            var queryOptions = new QueryOptions()
            {
                WaitTime = TimeSpan.FromMinutes(15),
                WaitIndex = lastIndex
            };

            var kv = await _consul.KV.Get(key, queryOptions, token);
            if (kv?.Response != null && kv.Response.Value != null)
            {
                var json = System.Text.Encoding.UTF8.GetString(kv.Response.Value);
                using var doc = JsonDocument.Parse(json);
                if (doc.RootElement.TryGetProperty("ReverseProxy", out var proxy))
                {
                    if (proxy.TryGetProperty("Routes", out var routesElement))
                    {
                        var dict = JsonConvert.DeserializeObject<Dictionary<string, RouteConfig>>(routesElement.GetRawText());
                        if (dict != null)
                            foreach (var kvp in dict) routes[kvp.Key] = kvp.Value with { RouteId = kvp.Key };
                    }

                    if (proxy.TryGetProperty("Clusters", out var clustersElement))
                    {
                        //var dict = JsonSerializer.Deserialize<Dictionary<string, ClusterConfig>>(clustersElement.GetRawText());
                        var dict = JsonConvert.DeserializeObject<Dictionary<string, ClusterConfig>>(clustersElement.GetRawText());
                        if (dict != null)
                            foreach (var kvp in dict) clusters[kvp.Key] = kvp.Value with { ClusterId = kvp.Key };
                    }
                }
                return kv.LastIndex;
            }
            return lastIndex;
        }
        #endregion

        #region 服务节点长轮询
        private async Task WatchServicesLoop(CancellationToken token)
        {
            var clusterIndexes = new Dictionary<string, ulong>();

            while (!token.IsCancellationRequested)
            {
                try
                {
                    if (_clustersTemplate.Count == 0)
                    {
                        await Task.Delay(2000, token);
                        continue;
                    }

                    // 等待 Consul WaitTime 或
                    var waitTask = WaitForConsulChange(clusterIndexes, token);
                    // 等待consul KV更新就会唤醒任务,刷新服务节点
                    var signalTask = Task.Delay(Timeout.Infinite, _signalCts.Token);
                    //var signalTask = _serviceUpdateSignal.Reader.ReadAsync(token).AsTask();

                    var completed = await Task.WhenAny(waitTask, signalTask);
                    bool updated = false;

                    if (completed == waitTask)
                    {
                        updated = await waitTask;
                    }
                    else if (completed == signalTask)
                    {
                        _logger.LogInformation("收到 KV 更新信号,立即刷新服务节点");
                        updated = await RefreshAllClustersDestinations(clusterIndexes, token, force: true);

                        // 消费掉信号后,重新建一个新的 token
                        _signalCts.Dispose();
                        _signalCts = new CancellationTokenSource();
                    }

                    if (updated)
                    {
                        await RebuildSnapshotFromTemplates(token);
                    }
                }
                catch (TaskCanceledException) { }
                catch (Exception ex)
                {
                    _logger.LogError( "服务节点长轮询异常,5秒后重试", ex);
                    await Task.Delay(5000, token);
                }
            }
        }

        private async Task<bool> WaitForConsulChange(Dictionary<string, ulong> clusterIndexes, CancellationToken token)
        {
            return await RefreshAllClustersDestinations(clusterIndexes, token, force: false);
        }

        private async Task<bool> RefreshAllClustersDestinations(Dictionary<string, ulong> clusterIndexes, CancellationToken token, bool force)
        {
            bool updated = false;

            foreach (var clusterId in _clustersTemplate.Keys)
            {
                var index = clusterIndexes.TryGetValue(clusterId, out var idx) ? idx : 0;

                var queryOptions = new QueryOptions()
                {
                    WaitTime = force ? TimeSpan.Zero : TimeSpan.FromMinutes(30),
                   // WaitTime = force ? TimeSpan.Zero : TimeSpan.FromSeconds(30),
                    WaitIndex = force ? 0 : index
                   // WaitIndex =index
                };

                var result = await _consul.Health.Service(clusterId, "", true, queryOptions, token);
                //这里长轮询会等待服务节点变化,如果没有变化,会一直阻塞在这里,直到超时
                //如果有变化,会更新 clusterIndexes 并返回 true
                //注意:这里变更会返回两次,拿新增服务节点A举例,第一次是新增节点A并且状态是Critical,第二次是节点A状态变为Passing。这里长轮询是每次服务节点变更都会返回
                if (force || result.LastIndex > index)
                {
                    if(!force)
                        clusterIndexes[clusterId] = result.LastIndex;
                    updated = true;

                    var dests = new Dictionary<string, DestinationConfig>();
                    int i = 0;
                    foreach (var ep in result.Response)
                    {
                        var addr = $"http://{ep.Service.Address}:{ep.Service.Port}";
                        dests[$"dest-{i++:00}"] = new DestinationConfig { Address = addr };
                    }
                    _clustersTemplate[clusterId] = _clustersTemplate[clusterId] with { Destinations = dests };
                }
            }

            return updated;
        }
        #endregion
        private Task RebuildSnapshotFromTemplates(CancellationToken token)
        {
            var routes = _routesTemplate.Values.ToList();
            var clusters = _clustersTemplate.Values.ToList();
            UpdateSnapshot(routes, clusters);
            return Task.CompletedTask;
        }

        private void UpdateSnapshot(IReadOnlyList<RouteConfig> routes, IReadOnlyList<ClusterConfig> clusters)
        {
            lock (_lock)
            {
                var oldSnapshot = _current;
                var newSnapshot = new ConfigSnapshot(routes, clusters);
                _current = newSnapshot;
                oldSnapshot.SignalChanged();
            }
            _logger.LogInformation($"YARP 配置已更新 => Routes: {JsonConvert.SerializeObject(routes)}, Clusters: {JsonConvert.SerializeObject(clusters)}");
        }

        public void Dispose()
        {
            _cts.Cancel();
            _cts.Dispose();
        }
        private void Signal()
        {
            // 取消旧的
            _signalCts.Cancel();
        }

        private sealed class ConfigSnapshot : IProxyConfig
        {
            private readonly CancellationTokenSource _cts = new();
            public ConfigSnapshot(IReadOnlyList<RouteConfig> routes, IReadOnlyList<ClusterConfig> clusters)
            {
                Routes = routes;
                Clusters = clusters;
                ChangeToken = new CancellationChangeToken(_cts.Token);
            }
            public IReadOnlyList<RouteConfig> Routes { get; }
            public IReadOnlyList<ClusterConfig> Clusters { get; }
            public IChangeToken ChangeToken { get; }
            public void SignalChanged() => _cts.Cancel();
        }
    }

}

注意:这里日志我用的自己封装的,需要改成你们平台的日志。

这里开启了两个长轮询,用于监控KV配置变更和服务发现的实例变更,变更后就会触发,马上更新当前网关的配置和下游地址

 

posted @ 2025-08-28 15:01  Joni是只狗  阅读(104)  评论(0)    收藏  举报