返回

ASP.NET Core gRPC 健康检查的探索与实现

各位朋友,大家好,欢迎大家关注我的博客。在上一篇 博客 中,博主和大家分享了gRPC的拦截器在日志记录方面的简单应用,今天我们继续来探索gRPC在构建微服务架构方面的可能性。其实,从博主个人的理解而言,不管我们的微服务架构是采用RPC方式还是采用RESTful方式,我们最终要面对的问题本质上都是一样的,博主这里将其归纳为:服务划分、服务编写 和 服务治理。首先,服务划分决定了每一个服务的上下文边界以及服务颗粒度大小,如果按照领域驱动设计(DDD)的思想来描述微服务,我认为它更接近于限界上下文(BoundedContext)的概念。其次,服务编写决定了每一个服务的具体实现方式,譬如是采用无状态的RESTful风格的API,还是采用强类型的、基于代理的RPC风格的API。最后,服务治理是微服务架构中永远避不开的话题,服务注册、服务发现、健康检查、日志监控等等一切的话题,其实都是在围绕着服务治理而展开,尤其是当我们编写了一个又一个的服务以后,此时该如何管理这些浩如“”海的服务呢?所以,在今天这篇博客中,博主想和大家一起探索下gRPC的健康检查,希望能给大家带来一点启发。

健康检查-服务注册-服务发现示意图
健康检查-服务注册-服务发现示意图

关于“健康检查”,大家都知道的一点是,它起到一种“防微杜渐”的作用。不知道大家还记不记得,语文课本里的经典故事《扁鹊见蔡桓公》,扁鹊一直在告知蔡桓公其病情如何,而蔡桓公讳疾忌医,直至病入骨髓、不治而亡。其实,对应到我们的领域知识,后端依赖的各种服务譬如数据库、消息队列、Redis、API 等等,都需要这样一个“扁鹊”来实时地“望闻问切”,当发现问题的时候及时地采取相应措施,不要像“蔡桓公”一样病入骨髓,等到整个系统都瘫痪了,这时候火急火燎地去“救火”,难免会和蔡桓公一样,发出“悔之晚矣”的喟叹。当我们决定使用gRPC来构建微服务架构的时候,我们如何确保这些服务一直是可用的呢?所以,提供一种针对gRPC服务的健康检查方案就会显得非常迫切。这里,博主主要为大家介绍两种实现方式,它们分别是:基于IHostedService的实现方式 以及 基于Consul的实现方式。

基于 IHostedService 的实现方式

第一种方式,主要是利用IHostedService可以在程序后台执行的特点,搭配Timer就可以实现定时轮询。在 gRPC官方规范 中,提供了一份Protocol Buffers的声明文件,它规定了一个健康检查服务必须实现Check()Watch()两个方法。既然是官方定义好的规范,建议大家不要修改这份声明文件,我们直接沿用即可:

 1syntax = "proto3";
 2
 3package grpc.health.v1;
 4
 5message HealthCheckRequest {
 6  string service = 1;
 7}
 8
 9message HealthCheckResponse {
10  enum ServingStatus {
11    UNKNOWN = 0;
12    SERVING = 1;
13    NOT_SERVING = 2;
14  }
15  ServingStatus status = 1;
16}
17
18service Health {
19  rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
20  rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
21}

接下来,我们需要实现对应的HealthCheckService:

 1public class HealthCheckService : Health.HealthBase
 2{
 3    public override Task<HealthCheckResponse> Check(
 4      HealthCheckRequest request, 
 5      ServerCallContext context
 6    )
 7    {
 8        // TODO: 在这里添加更多的细节
 9        return Task.FromResult(new HealthCheckResponse() { 
10            Status = HealthCheckResponse.Types.ServingStatus.Serving 
11        });
12    }
13
14    public override async Task Watch(
15      HealthCheckRequest request, 
16      IServerStreamWriter<HealthCheckResponse> responseStream, 
17      ServerCallContext context
18    )
19    {
20        // TODO: 在这里添加更多的细节
21        await responseStream.WriteAsync(new HealthCheckResponse(){
22            Status = HealthCheckResponse.Types.ServingStatus.Serving 
23        });
24    }
25}

接下来,我们需要实现HostedHealthCheckService,它实现了IHostedService接口,并在其中调用HealthCheckService:

 1public class HostedHealthCheckService : IHostedService
 2{
 3    private Timer _timer = null;
 4    private readonly ILogger<HostedHealthCheckService> _logger;
 5
 6    public HostedHealthCheckService(ILogger<HostedHealthCheckService> logger)
 7    {
 8        _logger = logger;
 9    }
10
11    public Task StartAsync(CancellationToken cancellationToken)
12    {
13        _logger.LogInformation($"{nameof(HostedHealthCheckService)} start running....");
14        _timer = new Timer(DoCheck, null, TimeSpan.Zero, TimeSpan.FromSeconds(5));
15        return Task.CompletedTask;
16    }
17
18    public Task StopAsync(CancellationToken cancellationToken)
19    {
20        _logger.LogInformation($"{nameof(HostedHealthCheckService)} stop running....");
21        _timer?.Change(Timeout.Infinite, 0);
22        return Task.CompletedTask;
23    }
24
25    private void DoCheck(object state)
26    {
27        using var channel = GrpcChannel.ForAddress("https://localhost:5001"); ;
28        var client = new Health.HealthClient(channel);
29        client.Check(new HealthCheckRequest() { Service = "https://localhost:5001" });
30    }
31}

接下来,是大家非常熟悉的依赖注入环节:

 1// ConfigureServices
 2public void ConfigureServices(IServiceCollection services)
 3{
 4    services.AddGrpc(options => options.Interceptors.Add<GrpcServerLoggingInterceptor>());
 5    services.AddHostedService<HostedHealthCheckService>();
 6}
 7
 8// Configure
 9public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
10{
11    app.UseEndpoints(endpoints =>
12    {
13        endpoints.MapGrpcService<HealthCheckService>();
14    });
15}

如果大家对上一篇博客中的拦截器还有印象,对于下面的结果应该会感到非常亲切:

基于 IHostedService 的 gRPC 健康检查
基于 IHostedService 的 gRPC 健康检查

除此以外,我们还可以直接安装第三方库:Grpc.HealthCheck。此时,我们需要继承HealthServiceImpl类并重写其中的Check()Watch()方法:

 1public class HealthCheckService : HealthServiceImpl
 2{
 3    public override Task<HealthCheckResponse> Check(
 4      HealthCheckRequest request, 
 5      ServerCallContext context
 6    )
 7    {
 8        // TODO: 在这里添加更多的细节
 9        return Task.FromResult(new HealthCheckResponse()
10        {
11            Status = HealthCheckResponse.Types.ServingStatus.Serving
12        });
13    }
14
15    public override async Task Watch(
16      HealthCheckRequest request, 
17      IServerStreamWriter<HealthCheckResponse> responseStream, 
18      ServerCallContext context
19    )
20    {
21        // TODO: 在这里添加更多的细节
22        await responseStream.WriteAsync(new HealthCheckResponse()
23        {
24            Status = HealthCheckResponse.Types.ServingStatus.Serving
25        });
26    }
27}

接下来,我们只需要在HostedHealthCheckService调用它即可,这个非常简单。

故,无需博主多言,相信屏幕前的你都能写得出来,如果写不出来,参考博主给出得实现即可(逃!

基于 Consul 的实现方式

Consul 是一个由 HashiCorp 提供的产品,它提供了服务注册、服务发现、健康检查、键值存储等等的特性。这里,我们通过集成它的SDK来实现gRPC服务的服务注册、服务发现、健康检查,从某种程度上来讲,它无形中帮助我们实现了客户端的负载均衡,因为我们可以将每一个服务的终结点都注册到Consul中,而Consul的健康检查则可以定时移除那些不可用的服务。所以,客户端获得的终结点实际上都是可用的终结点。

首先,我们需要安装第三方库:Consul。接下来,我们可需要通过Docker安装一下Consul:

1docker pull consul
2docker run --name consul -d -p 8500:8500 consul

默认情况下,Consul的端口号为:8500,我们可以直接访问:http://localhost:8500

Consul 界面效果展示
Consul 界面效果展示

接下来,为了让Startup类看起来清爽一点,首先,我们先来写一点扩展方法:

 1// 为指定的gRPC服务添加健康检查
 2public static void AddGrpcHealthCheck<TService>(this IServiceCollection services)
 3{
 4    var configuration = services.BuildServiceProvider().GetService<IConfiguration>();
 5
 6    // 注册ConsulClient
 7    services.AddSingleton<IConsulClient, ConsulClient>(_ => new ConsulClient(consulConfig =>
 8    {
 9        var baseUrl = configuration.GetValue<string>("Consul:BaseUrl");
10        consulConfig.Address = new Uri(baseUrl);
11    }));
12
13    // 注册gRPC服务
14    RegisterConsul<TService>(services).Wait();
15}

其中,RegisterConsul()方法负责告诉Consul,某个服务对应的 IP 和端口号分别是多少,采用什么样的方式进行健康检查。

不过,由于Consul默认不支持gRPC的健康检查,所以,我们使用了更为常见的基于TCP方式的健康检查。你可以认为,只要服务器连接畅通,gRPC服务就是健康的。

 1// 注册指定服务到Consul
 2private static async Task RegisterConsul<TService>(IServiceCollection services)
 3{
 4    var serverHost = GetLocalIP();
 5    var serverPort = services.BuildServiceProvider().GetService<IConfiguration>().GetValue<int>("gRPC:Port");
 6    await RegisterConsul<TService>(services, serverHost, serverPort);
 7}
 8
 9// 注册指定服务到Consul
10private static async Task RegisterConsul<TService>(
11  IServiceCollection services, 
12  string serverHost, 
13  int serverPort
14)
15{
16    var client = services.BuildServiceProvider().GetService<IConsulClient>();
17    var registerID = $"{typeof(TService).Name}-{serverHost}:{serverPort}";
18    await client.Agent.ServiceDeregister(registerID);
19    var result = await client.Agent.ServiceRegister(new AgentServiceRegistration()
20    {
21        ID = registerID,
22        Name = typeof(TService).Name,
23        Address = serverHost,
24        Port = serverPort,
25        Check = new AgentServiceCheck
26        {
27            TCP = $"{serverHost}:{serverPort}",
28            Status = HealthStatus.Passing,
29            DeregisterCriticalServiceAfter = TimeSpan.FromSeconds(10),
30            Interval = TimeSpan.FromSeconds(10),
31            Timeout = TimeSpan.FromSeconds(5)
32        },
33        Tags = new string[] { "gRpc" }
34    }) ;
35}

对于Consul中的健康检查,更常用的是基于HTTP的健康检查,简单来说,就是我们提供一个接口,供Consul来调用,我们可以去设置请求的头(Header)、消息体(Body)、方法(Method)等等。所以,对于这里的实现,你还可以替换为更一般的实现,即提供一个 API 接口,然后在这个接口中调用gRPC的客户端。除此以外,如果你擅长写脚本,Consul同样支持脚本级别的健康检查。

在这里,博主水平扩展(复制)了两套服务,它们分别被部署在50016001两个端口上,通过Consul能达到什么效果呢?我们一起来看一下:

 1// ConfigureServices
 2public void ConfigureServices(IServiceCollection services)
 3{
 4    services.AddGrpc(options => options.Interceptors.Add<GrpcServerLoggingInterceptor>());
 5    services.AddGrpcHealthCheck<GreeterService>();
 6    services.AddGrpcHealthCheck<CalculatorService>();
 7}
 8
 9// Configure
10public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
11{
12    app.UseEndpoints(endpoints =>
13    {
14        endpoints.MapGrpcService<GreeterService>();
15        endpoints.MapGrpcService<CalculatorService>();
16    });
17}

OK,此时,我们注意到Consul中有两个服务注册进去,它们分别是:GreeterServiceCalculatorService

gRPC 服务成功注册到 Consul 中
gRPC 服务成功注册到 Consul 中

以其中一个CalculatorService为例,我们可以注意到,它的确注册了50016001两个实例:

CalculatorService 的两个实例
CalculatorService 的两个实例

至此,我们就完成了基于Consul的健康检查,在这里,图中的绿色标记表示服务可用。

关于 gRPC 的引申话题

其实,写到这里的时候,这篇博客就该接近尾声啦,因为对于 gRPC 健康检查的探索基本都已找到答案,可我还是想聊一聊关于 gRPC 的引申话题。理由特别简单,就是在我看来,接下来要讲的这点内容,完全撑不起一篇博客的篇幅,索性就在这篇博客里顺带一提。我打算分享两个话题,其一,是 gRPC 客户端的负载均衡;其二,是 gRPC 接口的测试工具。

gRPC 客户端的负载均衡

截止到目前为止,结合Consul我们已经实现了服务注册和服务发现两个功能。通过调研我们可以发现,针对服务器端的gRPC的负载均衡,目前主要有NginxEnvoy两种方案,这两种相方案对要更复杂一点,博主目前所在的公司,在gRPC的负载均衡上感觉是个空白,这算是博主想要研究gRPC的一个主要原因。而在这里,由于Consul里注册了所有gRPC服务的终结点信息,所以,我们更容易想到的,其实是客户端的负载均衡,具体怎么实现呢?我们一起看一下:

 1// 从Consul中获取服务终结点信息
 2var consulClient = serviceProvider.GetService<IConsulClient>();
 3var serviceName = typeof(TGrpcClient).Name.Replace("Client", "Service");
 4var services = await consulClient.Health.Service(serviceName, string.Empty, true);
 5var serviceUrls = services.Response.Select(s => $"{s.Service.Address}:{s.Service.Port}").ToList();
 6if (serviceUrls == null || !serviceUrls.Any())
 7    throw new Exception($"Please make sure service {serviceName} is registered in consul");
 8
 9// 构造Channel和Client
10var serviceUrl = serviceUrls[new Random().Next(0, serviceUrls.Count - 1)];
11var channel = GrpcChannel.ForAddress($"https://{serviceUrl}");
12var client = new var client = new Calculator.CalculatorClient(channel);
13await client.CalcAsync(new CalculatorRequest() { Num1 = 10, Op = "+", Num2 = 12 });

可以看出,基本思路就是从Consul里拿到对应服务的终结点信息,然后构造出GrpcChannel,再通过GrpcChannel构造出 Client 即可。

不过,博主觉得这个过程有一点繁琐,我们有没有办法让这些细节隐藏起来呢?于是,我们有了下面的改进方案:

 1public static async Task<TGrpcClient> GetGrpcClientAsync<TGrpcClient>(
 2  this IServiceProvider serviceProvider
 3)
 4{
 5    var consulClient = serviceProvider.GetService<IConsulClient>();
 6    var serviceName = typeof(TGrpcClient).Name.Replace("Client", "Service");
 7    var services = await consulClient.Health.Service(serviceName, string.Empty, true);
 8    var serviceUrls = services.Response.Select(s => $"{s.Service.Address}:{s.Service.Port}").ToList();
 9    if (serviceUrls == null || !serviceUrls.Any())
10        throw new Exception($"Please make sure service {serviceName} is registered in consul");
11
12    var serviceUrl = serviceUrls[new Random().Next(0, serviceUrls.Count - 1)];
13    var channel = GrpcChannel.ForAddress($"https://{serviceUrl}");
14    var constructorInfo = typeof(TGrpcClient).GetConstructor(new Type[] { typeof(GrpcChannel) });
15    if (constructorInfo == null)
16        throw new Exception($"Please make sure {typeof(TGrpcClient).Name} is a gRpc client");
17
18    var clientInstance = (TGrpcClient)constructorInfo.Invoke(new object[] { channel });
19    return clientInstance;
20}

现在,有没有觉得简单一点?完美!

1var client = await serviceProvider.GetGrpcClientAsync<CalculatorClient>();
2await client.CalcAsync(new CalculatorRequest() { Num1 = 1, Num2 = 2, Op = "+" });

gRPC 接口的测试工具

我猜,大多数看到这个标题会一脸鄙夷,心里大概会想,就测试工具这种东西值得特地写出来吗?诚然,以前写 API 接口的时候,大家都是用 Postman 或者 Apifox 这样的工具来进行测试的,可是突然有一天你要调试一个gRPC的接口,你总不能每次都调用客户端啊,所以,这里要给大家推荐两个gRPC接口的测试工具,它们分别是: grpcurlgrpcui,它们都出自同一个人 FullStory 之手,基于 Go 语言开发,简单介绍下使用方法:

 1// 建议使用国内源
 2go env -w GO111MODULE=on
 3go env -w GOPROXY=https://goproxy.cn,direct
 4
 5// grpcurl
 6brew install grpcurl
 7
 8// grpcui
 9go get github.com/fullstorydev/grpcui/...
10go install github.com/fullstorydev/grpcui/cmd/grpcui
11
12// 安装后的路径为:C:\Users\<User>\go\bin\grpcui.exe
13grpcui -bind <Your-IP> -plaintext <Your-gRPC-Service>

虽然这个说明简单而直白,可我还是没能装好,我不得不祭出 Docker 这个神器,果然它不会令我失望:

1docker pull wongnai/grpcui
2docker run -e GRPCUI_SERVER=localhost:5001 -p 8080:8080 wongnai/grpcui

这里有两个重要的参数,其中,8080grpcui的服务地址,可以按个人喜好进行修改,GRPCUI_SERVERgRPC服务地址,该工具运行效果如下:

gRPCUI 接口测试工具
gRPCUI 接口测试工具

对于使用者来说,我们只需要选择服务(service)、方法(rpc)、然后填入参数即可,个人感觉非常方便。

本文小结

本文探索并实现了gRPC服务健康检查,主要提供了两种思路:基于IHostedService + Timer的轮询的方案 以及 基于Consul的集服务注册、服务发现、健康检查于一身的方案。特别地,对于后者而言,我们可以顺理成章地联想到客户端的负载均衡,其原理是:Consul中注册了所有gRPC服务的终结点信息,通过IConsulClient可以拿到所有可用的终结点信息,只要以此为基础来构建GrpcChannel即可。根据这个原理,我们引申出了gRPC客户端负载均衡的相关话题,这里我们采用的是随机选择一个终结点信息的做法,事实上,按照一般负载均衡的理论,我们还可以采取轮询、加权、Hash 等等的算法,大家可以按照自己的业务场景来选择合适的方法。最后,我们简单介绍了下gRPC接口测试方面的内容,它可以帮助我们更高效地编写、验证gRPC接口。好了,以上就是这篇博客的全部内容啦,欢迎大家在评论区留言、参与讨论,谢谢大家!

Built with Hugo
Theme Stack designed by Jimmy