沒有 Kubernetes 還能玩 Dapr ?

騰訊雲原生最佳實踐

作者:蔣金楠
原文:https://www.cnblogs.com/artech/p/dapr-custom-name-resolution.html

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

一、NameResolution 組件

雖然 Dapr 提供了一系列的編程模型,比如服務調用、發佈訂閱和 Actor 模型等,被廣泛應用的應該還是服務調用。我們知道微服務環境下的服務調用需要解決服務註冊與發現、負載均衡、彈性伸縮等問題,其實 Dapr 在這方面什麼都沒做,正如上面所說,Dapr 自身不提供基礎設施,它將這些功能交給具體的部署平臺(比如 K8S)來解決。Dapr 中於此相關唯有一個簡單得不能再簡單的 NameResolution 組件而已。

從部署的角度來看,Dapr 的所有功能都體現在與應用配對的 Sidecar 上。我們進行服務調用得時候只需要指定服務所在得目標應用的 ID(AppID)就可以了。服務請求(HTTP 或者 gRPC)從應用轉到 sidecar,後者會將請求 “路由” 到合適的節點上。如果部署在 Kubernetes 集羣上,如果指定了目標服務的標識和其他相關的元數據(命名空間和集羣域名等),服務請求的尋址就不再是一個問題。實際上 NameResolution 組件體現的針對 “名字(Name)” 的“解析(Resolution)”解決的就是如將 Dapr 針對應用的標識 AppID 轉換成基於部署環境的應用標識的問題。從 dapr 提供的代碼來看,它目前註冊瞭如下 3 種類型的 NameResolution 組件:

二、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 的輸出上。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/dNE77qHDZeynLjL61x1PBg