沒有Kubernetes怎麼玩Dapr?

Dapr 被設計成一個面向開發者的企業級微服務編程平台,它獨立於具體的技術平台,可以運行在「任何地方」。Dapr本身並不提供「基礎設施(infrastructure)」,而是利用自身的擴展來適配具體的部署環境。就目前的狀態來說,如果希望真正將原生的Dapr應用與生產,只能部署在K8S環境下。雖然Dapr也提供針對Hashicorp Consul的支持,但是目前貌似沒有穩定的版本支持。Kubernetes對於很多公司並非「標配」,由於某些原因,它們可以具有一套自研的微服務平台或者彈性雲平台,讓Dapr與之適配可能更有價值。這兩周我們對此作了一些可行性研究,發現這其實不難,記下來我們就同通過一個非常簡單的實例來介紹一下大致的解決方案。(拙著《ASP.NET Core 6框架揭秘》熱賣中,首印送簽名專屬書籤)。

目錄
一、從NameResolution組件說起
二、Resolver
三、模擬服務註冊與負載均衡
四、自定義NameResolution組件
五、註冊自定義NameResolution組件
六、編譯部署daprd.exe
七、配置svcreg
八、測試效果

一、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操作,daprcomponents-contrib,前者為核心運行時,後者為社區驅動貢獻得組件。我們將克隆下來的源代碼放在同一個目錄下。

image

我們將自定義的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」。

image

七、配置svcreg

我們之間已經說過,Dapr默認使用的是基於mDNS的NameResolution組件(對於的註冊名為為「mdns」)。若要使我們自定義的組件「svcreg」生效,需要修改Dapr的配置文件(%userprofile%.dapr\config.yaml)。如下面的代碼片段所示,我們不僅將使用的組件名稱設置為「svcreg」(在dapr/cmd/daprd/main.go中註冊NameResolution組件時提供的名稱),還將服務註冊API的URL(//127.0.0.1:3721)放在了配置中(Resolver的Init方法提取的URL就來源於這裡)。

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: daprConfig
spec:
  nameResolution:
    component: "svcreg"
    configuration:
      endpointAddress: //127.0.0.1:3721
  tracing:
    samplingRate: "1"
    zipkin:
      endpointAddress: //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("//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正在被使用。

image

由於應用啟動的時候會調用Resolver的Init方法進行註冊,這一點也反映在ServiceRegistry如下所示的輸出上。可以看出註冊實例的AppID為」app2」,對應的終結點為「10.181.22.4:60840」。

image

然後我們再啟動App1,如下所示的輸出表明四次服務調用均成功完成。

image

啟動的App1的應用實例同樣會在ServiceRegistry中註冊。而四次服務調用會導致四次針對Resolver的ResolveID方法的調用,這也體現在ServiceRegistry的輸出上。

image