沒有Kubernetes怎麼玩Dapr?

語言: CN / TW / HK

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 ,前者為核心執行時,後者為社群驅動貢獻得元件。我們將克隆下來的原始碼放在同一個目錄下。

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