没有Kubernetes怎么玩Dapr?
Dapr 被设计成一个面向开发者的企业级微服务编程平台,它独立于具体的技术平台,可以运行在“任何地方”。Dapr本身并不提供“基础设施(infrastructure)”,而是利用自身的扩展来适配具体的部署环境。就目前的状态来说,如果希望真正将原生的Dapr应用与生产,只能部署在K8S环境下。虽然Dapr也提供针对Hashicorp Consul的支持,但是目前貌似没有稳定的版本支持。Kubernetes对于很多公司并非“标配”,由于某些原因,它们可以具有一套自研的微服务平台或者弹性云平台,让Dapr与之适配可能更有价值。这两周我们对此作了一些可行性研究,发现这其实不难,记下来我们就同通过一个非常简单的实例来介绍一下大致的解决方案。(拙著《ASP.NET Core 6框架揭秘》热卖中,首印送签名专属书签)。
NameResolution组件
虽然Dapr提供了一系列的编程模型,比如服务调用、发布订阅和Actor模型等,被广泛应用的应该还是服务调用。我们知道微服务环境下的服务调用需要解决服务注册与发现、负载均衡、弹性伸缩等问题,其实Dapr在这方面什么都没做,正如上面所说,Dapr自身不提供基础设施,它将这些功能交给具体的部署平台(比如K8S)来解决。Dapr中于此相关唯有一个简单得不能再简单的NameResolution组件而已。
从部署的角度来看,Dapr的所有功能都体现在与应用配对的Sidecar上。我们进行服务调用得时候只需要指定服务所在得目标应用的ID(AppID)就可以了。服务请求(HTTP或者gRPC)从应用转到sidecar,后者会将请求“路由”到合适的节点上。如果部署在Kubernetes集群上,如果指定了目标服务的标识和其他相关的元数据(命名空间和集群域名等),服务请求的寻址就不再是一个问题。实际上NameResolution组件体现的针对“名字(Name)”的“解析(Resolution)”解决的就是如将Dapr针对应用的标识AppID转换成基于部署环境的应用标识的问题。从dapr提供的代码来看,它目前注册了如下3种类型的NameResolution组件:
- mdns:利用mDNS(Multicast DNS)实现服务注册与发现,如果没有显式配置,默认使用的就是此类型。由于mDNS仅仅是在小规模网络中采用广播通信实现的一种DNS,所以根本不适合正式的生成环境。
- kubernetes:适配Kubernetes的名字解析,目前提供稳定的版本。
- consul: 适配HashiCorp Consul的名字解析,目前最新为Alpha版本。
Resolver
一个注册的NameResolution组件旨在提供一个Resolver对象,该对象通过如下的接口来表示。如下面的代码片段所示,Resolver接口提供两个方法,Init方法会在应用启动的时候调用,作为参数的Metadata会携带于当前应用实例相关的元数据(包括应用标识和端口,以及Sidecar的HTTP和gRPC端口等)和针对当前NameResolution组件的配置。对于每一次服务调用,目标应用标识和命名空间等相关信息会被Sidecar封装成一个ResolveRequest 接口,并最为参数调用Resolver对象的ReolveID方法,最终得到一个于当前部署环境相匹配的表示,并利用此标识借助基础设施的利用完整目标服务的调用。
package nameresolution
type Resolver interface {
Init(metadata Metadata) error
ResolveID(req ResolveRequest) (string, error)
}
type Metadata struct {
Properties map[string]string `json:"properties"`
Configuration interface{}
}
type ResolveRequest struct {
ID string
Namespace string
Port int
Data map[string]string
}
模拟服务注册与负载均衡
假设我们具有一套私有的微服务平台,实现了基本的服务注册、负载均衡,甚至是弹性伸缩的功能,如果希望在这个平台上使用Dapr,我们只需要利用自定义的NameResolution组件提供一个对应的Resolver对象就可以了。我们利用一个ASP.NET Core MVC应用来模拟我们希望适配的微服务平台,如下这个HomeController利用静态字段_applications维护了一组应用和终结点列表(IP+端口)。对于针对某个应用的服务调用,我们通过轮询对应终结点的方式实现了简单的负载均衡。便于后面的叙述,我们将该应用简称为“ServiceRegistry”。
public class HomeController: Controller
{
private static readonly ConcurrentDictionary<string, EndpointCollection> _applications = new();
[HttpPost("/register")]
public IActionResult Register([FromBody] RegisterRequest request)
{
var appId = request.Id;
var endpoints = _applications.TryGetValue(appId, out var value) ? value : _applications[appId] = new();
endpoints.TryAdd(request.HostAddress, request.Port);
Console.WriteLine($"Register {request.Id} =>{request.HostAddress}:{request.Port}");
return Ok();
}
[HttpPost("/resolve")]
public IActionResult Resolve([FromBody] ResolveRequest request)
{
if (_applications.TryGetValue(request.ID, out var endpoints) && endpoints.TryGet(out var endpoint))
{
Console.WriteLine($"Resolve app {request.ID} =>{endpoint}");
return Content(endpoint!);
}
return NotFound();
}
}
public class EndpointCollection
{
private readonly List<string> _endpoints = new();
private int _index = 0;
private readonly object _lock = new();
public bool TryAdd(string ipAddress, int port)
{
lock (_lock)
{
var endpoint = $"{ipAddress}:{port}";
if (_endpoints.Contains(endpoint))
{
return false;
}
_endpoints.Add(endpoint);
return true;
}
}
public bool TryGet(out string? endpoint)
{
lock (_lock)
{
if (_endpoints.Count == 0)
{
endpoint = null;
return false;
}
_index++;
if (_index >= _endpoints.Count)
{
_index = 0;
}
endpoint = _endpoints[_index];
return true;
}
}
}
HomeController提供了两个Action方法,Register方法用来注册应用,自定义Resolver的Init方法会调用它。另一个方法Resolve则用来完成根据请求的应用表示得到一个具体的终结点,自定义Resolver的ResolveID方法会调用它。这两个方法的参数类型RegisterRequest和ResolveRequest定义如下,后者和前面给出的同名接口具有一致的定义。两个Action都会在控制台输出相应的文字显示注册的应用信息和解析出来的终结点。
public class RegisterRequest
{
public string Id { get; set; } = default!;
public string HostAddress { get; set; } = default!;
public int Port { get; set; }
}
public class ResolveRequest
{
public string ID { get; set; } = default!;
public string? Namespace { get; set; }
public int Port { get; }
public Dictionary<string, string> Data { get; } = new();
}
自定义NameResolution组件
由于Dapr并不支持组件的动态注册,所以我们得将其源代码拉下来,修改后进行重新编译。这里涉及到两个git操作,dapr和components-contrib,前者为核心运行时,后者为社区驱动贡献得组件。我们将克隆下来的源代码放在同一个目录下。
我们将自定义的NameResolution组件命名为“svcreg”(服务注册之意),所我们在components-contrib/nameresolution目录(该目录下我们会看到上面提到的几种NameResolution组件的定义)下创建一个同名的目录,并组件代码定义在该目录下的svcreg.go文件中。如下所示的就是该NameResolution组件的完整定义。
package svcreg
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"github.com/dapr/components-contrib/nameresolution"
"github.com/dapr/kit/logger"
)
type Resolver struct {
logger logger.Logger
registerEndpoint string
resolveEndpoint string
}
type RegisterRequest struct {
Id, HostAddress string
Port int64
}
func (resolver *Resolver) Init(metadata nameresolution.Metadata) error {
var endpoint, appId, hostAddress string
var ok bool
// Extracts register & resolve endpoint
if dic, ok := metadata.Configuration.(map[interface{}]interface{}); ok {
endpoint = fmt.Sprintf("%s", dic["endpointAddress"])
resolver.registerEndpoint = fmt.Sprintf("%s/register", endpoint)
resolver.resolveEndpoint = fmt.Sprintf("%s/resolve", endpoint)
}
if endpoint == "" {
return errors.New("service registry endpoint is not configured")
}
// Extracts AppID, HostAddress and Port
props := metadata.Properties
if appId, ok = props[nameresolution.AppID]; !ok {
return errors.New("AppId does not exist in the name resolution metadata")
}
if hostAddress, ok = props[nameresolution.HostAddress]; !ok {
return errors.New("HostAddress does not exist in the name resolution metadata")
}
p, ok := props[nameresolution.DaprPort]
if !ok {
return errors.New("DaprPort does not exist in the name resolution metadata")
}
port, err := strconv.ParseInt(p, 10, 32)
if err != nil {
return errors.New("DaprPort is invalid")
}
// Register service (application)
var request = RegisterRequest{appId, hostAddress, port}
payload, err := json.Marshal(request)
if err != nil {
return errors.New("fail to marshal register request")
}
_, err = http.Post(resolver.registerEndpoint, "application/json", bytes.NewBuffer(payload))
if err == nil {
resolver.logger.Infof("App '%s (%s:%d)' is successfully registered.", request.Id, request.HostAddress, request.Port)
}
return err
}
func (resolver *Resolver) ResolveID(req nameresolution.ResolveRequest) (string, error) {
// Invoke resolve service and get resolved target app's endpoint ("{ip}:{port}")
payload, err := json.Marshal(req)
if err != nil {
return "", err
}
response, err := http.Post(resolver.resolveEndpoint, "application/json", bytes.NewBuffer(payload))
if err != nil {
return "", err
}
defer response.Body.Close()
result, err := ioutil.ReadAll(response.Body)
if err != nil {
return "", err
}
return string(result), nil
}
func NewResolver(logger logger.Logger) *Resolver {
return &Resolver{
logger: logger,
}
}
如上面的代码片段所示,我们定义核心的Resolver结构,该接口除了具有一个用来记录日志的logger字段,还有两个额外的字段registerEndpoint和resolveEndpoint,分别代表ServiceRegistry提供的两个API的URL。在为Resolver结构实现的Init方法中,我们从作为参数的元数据中提取出配置,并进一步从配置中提取出ServiceRegistry的地址,并在此基础上添加路由路径“/register”和“/resolve”对Resolver结构的registerEndpoint和resolveEndpoint字段进行初始化。接下来我们从元数据中提取出AppID、IP地址和内部gRPC端口号(外部应用通过此端口调用当前应用的Sidecar),它们被封装成RegisterRequest结构之后被序列化成JSON字符串,并作为输入调用对应的Web API完成对应的服务注册。
在实现的ResolveID中,我们直接将作为参数的ResolveRequest结构序列化成JSON,调用Resolve API。响应主体部分携带的字符串就是为目标应用解析出来的终结点(IP+Port),我们直接将其作为ResolveID的返回值。
注册自定义NameResolution组件
自定义的NameResolution组件需要显式注册到代表Sidecar的可以执行程序daprd中,入口程序所在的源文件为dapr/cmd/daprd/main.go。我们首先按照如下的方式导入svcreg所在的包”github.com/dapr/components-contrib/nameresolution/svcreg”。
// Name resolutions.
nr "github.com/dapr/components-contrib/nameresolution"
nr_consul "github.com/dapr/components-contrib/nameresolution/consul"
nr_kubernetes "github.com/dapr/components-contrib/nameresolution/kubernetes"
nr_mdns "github.com/dapr/components-contrib/nameresolution/mdns"
nr_svcreg "github.com/dapr/components-contrib/nameresolution/svcreg"
在main函数中,我们找到用来注册NameResolution组件的那部分代码,按照其他NameResolution组件注册那样,依葫芦画瓢完成针对svcreg的注册即可。注册代码中用来提供Resolver的NewResolver函数定义在上述的svcreg.go文件中。
runtime.WithNameResolutions(
nr_loader.New("svcreg", func() nr.Resolver {
return nr_svcreg.NewResolver(logContrib)
}),
nr_loader.New("mdns", func() nr.Resolver {
return nr_mdns.NewResolver(logContrib)
}),
nr_loader.New("kubernetes", func() nr.Resolver {
return nr_kubernetes.NewResolver(logContrib)
}),
nr_loader.New("consul", func() nr.Resolver {
return nr_consul.NewResolver(logContrib)
}),
),
编译部署daprd.exe
到目前为止,所有的编程工作已经完成,接下来我们需要重新编译代表Sidecar的daprd.exe。从上面的代码片段可以看出,dapr的包路径都以“github.com/dapr”为前缀,所以我们需要修改go.mod文件(dapr/go.mod)将依赖路径重定向到本地目录,所以我们按照如下的方式添加了针对“github.com/dapr/components-contrib”的替换规则。
replace (
go.opentelemetry.io/otel => go.opentelemetry.io/otel v0.20.0
gopkg.in/couchbaselabs/gocbconnstr.v1 => github.com/couchbaselabs/gocbconnstr v1.0.5
k8s.io/client => github.com/kubernetes-client/go v0.0.0-20190928040339-c757968c4c36
github.com/dapr/components-contrib => ../components-contrib
)
在将当前目录切换到“dapr/cmd/daprd/”后,以命令行的方式执行“go build”后会在当前目录下生成一个daprd.exe可执行文件。现在我们需要使用这个新的daprd.exe将当前使用使用的替换掉,该文件所在的目录在“%userprofile%.dapr\bin”。
配置svcreg
我们之间已经说过,Dapr默认使用的是基于mDNS的NameResolution组件(对于的注册名为为“mdns”)。若要使我们自定义的组件“svcreg”生效,需要修改Dapr的配置文件(%userprofile%.dapr\config.yaml)。如下面的代码片段所示,我们不仅将使用的组件名称设置为“svcreg”(在dapr/cmd/daprd/main.go中注册NameResolution组件时提供的名称),还将服务注册API的URL(http://127.0.0.1:3721)放在了配置中(Resolver的Init方法提取的URL就来源于这里)。
apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
name: daprConfig
spec:
nameResolution:
component: "svcreg"
configuration:
endpointAddress: http://127.0.0.1:3721
tracing:
samplingRate: "1"
zipkin:
endpointAddress: http://localhost:9411/api/v2/spans
测试效果
我们现在编写一个Dapr应用来验证一下自定义的NameResolution组件是否有效。我们采用《ASP.NET Core 6框架揭秘实例演示[03]:Dapr初体验》提供的服务调用的例子。具有如下定义的App2是一个ASP.NET Core应用,它利用路由提供了用来进行加、减、乘、除预算的API。
using Microsoft.AspNetCore.Mvc;
using Shared;
var app = WebApplication.Create(args);
app.MapPost("{method}", Calculate);
app.Run("http://localhost:9999");
static IResult Calculate(string method, [FromBody] Input input)
{
var result = method.ToLower() switch
{
"add" => input.X + input.Y,
"sub" => input.X - input.Y,
"mul" => input.X * input.Y,
"div" => input.X / input.Y,
_ => throw new InvalidOperationException($"Invalid method {method}")
};
return Results.Json(new Output { Result = result });
}
public class Input
{
public int X { get; set; }
public int Y { get; set; }
}
public class Output
{
public int Result { get; set; }
public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.Now;
}
具有如下定义的App1是一个控制台程序,它利用Dapr客户端SDK调用了上诉四个API。
using Dapr.Client;
using Shared;
HttpClient client = DaprClient.CreateInvokeHttpClient(appId: "app2");
var input = new Input(2, 1);
await InvokeAsync("add", "+");
await InvokeAsync("sub", "-");
await InvokeAsync("mul", "*");
await InvokeAsync("div", "/");
async Task InvokeAsync(string method, string @operator)
{
var response = await client.PostAsync(method, JsonContent.Create(input));
var output = await response.Content.ReadFromJsonAsync<Output>();
Console.WriteLine( $"{input.X} {@operator} {input.Y} = {output.Result} ({output.Timestamp})");
}
在启动ServiceRegistry之后,我们启动App2,控制台上会阐述如下的输出。从输出的NameResolution组件名称可以看出,我们自定义的svcreg正在被使用。
由于应用启动的时候会调用Resolver的Init方法进行注册,这一点也反映在ServiceRegistry如下所示的输出上。可以看出注册实例的AppID为”app2”,对应的终结点为“10.181.22.4:60840”。
然后我们再启动App1,如下所示的输出表明四次服务调用均成功完成。
启动的App1的应用实例同样会在ServiceRegistry中注册。而四次服务调用会导致四次针对Resolver的ResolveID方法的调用,这也体现在ServiceRegistry的输出上。