背景
由於公司內部所有服務都是跑在阿里雲 k8s 上的,然後 dubbo 提供者預設向註冊中心上報的 IP 都是Pod IP
,這意味著在 k8s 叢集外的網路環境是呼叫不了 dubbo 服務的,如果本地開發需要訪問 k8s 內的 dubbo 提供者服務的話,需要手動把服務暴露到外網,我們的做法是針對每一個提供者服務暴露一個SLB IP+自定義埠
,並且通過 dubbo 提供的DUBBO_IP_TO_REGISTRY
和DUBBO_PORT_TO_REGISTRY
環境變數來把對應的SLB IP+自定義埠
註冊到註冊中心裡,這樣就實現了本地網路和 k8s dubbo 服務的打通,但是這種方式管理起來非常麻煩,每個服務都得自定義一個埠,而且每個服務之間埠還不能衝突,當服務多起來之後非常難以管理。
於是我就在想能不能像nginx ingress
一樣實現一個七層代理+虛擬域名
來複用一個埠,通過目標 dubbo 提供者的application.name
來做對應的轉發,這樣的話所有的服務只需要註冊同一個SLB IP+埠
就可以了,大大的提升便利性,一方調研之後發現可行就開擼了!
專案已開源:https://github.com/monkeyWie/dubbo-ingress-controller
技術預研
思路
- 首先 dubbo RPC 呼叫預設是走的
dubbo協議
,所以我需要先去看看協議裡有沒有可以利用做轉發的報文資訊,就是尋找類似於 HTTP 協議裡的 Host 請求頭,如果有的話就可以根據此資訊做反向代理
和虛擬域名
的轉發,在此基礎之上實現一個類似nginx
的dubbo閘道器
。 - 第二步就是要實現
dubbo ingress controller
,通過 k8s ingress 的 watcher 機制動態的更新dubbo 閘道器
的虛擬域名轉發配置,然後所有的提供者服務都由此服務同一轉發,並且上報到註冊中心的地址也統一為此服務的地址。
架構圖
dubbo 協議
先上一個官方的協議圖:
可以看到 dubbo 協議的 header 是固定的16個位元組
,裡面並沒有類似於 HTTP Header 的可擴充套件欄位,也沒有攜帶目標提供者的application.name
欄位,於是我向官方提了個issue,官方的答覆是通過消費者自定義Filter
來將目標提供者的application.name
放到attachments
裡,這裡不得不吐槽下 dubbo 協議,擴充套件欄位竟然是放在body
裡,如果要實現轉發需要把請求報文全部解析完才能拿到想要報文,不過問題不大,因為主要是做給開發環境用的,這一步勉強可以實現。
k8s ingress
k8s ingress 是為 HTTP 而生的,但是裡面的欄位夠用了,來看一段 ingress 配置:
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: user-rpc-dubbo
annotations:
kubernetes.io/ingress.class: "dubbo"
spec:
rules:
- host: user-rpc
http:
paths:
- backend:
serviceName: user-rpc
servicePort: 20880
path: /
配置和 http 一樣通過host
來做轉發規則,但是host
配置的是目標提供者的application.name
,後端服務是目標提供者對應的service
,這裡有一個比較特殊的是使用了一個kubernetes.io/ingress.class
註解,這個註解可以指定此ingress
對哪個ingress controller
生效,後面我們的dubbo ingress controller
就只會解析註解值為dubbo
的 ingress 配置。
開發
前面的技術預研一切順利,接著就進入開發階段了。
消費者自定義 Filter
前面有提到如果請求裡要攜帶目標提供者的application.name
,需要消費者自定義Filter
,程式碼如下:
@Activate(group = CONSUMER)
public class AddTargetFilter implements Filter {
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
String targetApplication = StringUtils.isBlank(invoker.getUrl().getRemoteApplication()) ?
invoker.getUrl().getGroup() : invoker.getUrl().getRemoteApplication();
// 目標提供者的application.name放入attachment
invocation.setAttachment("target-application", targetApplication);
return invoker.invoke(invocation);
}
}
這裡又要吐槽一下,dubbo 消費者首次訪問時會發起一個獲取 metadata 的請求,這個請求通過invoker.getUrl().getRemoteApplication()
是拿不到值的,通過invoker.getUrl().getGroup()
才能拿到。
dubbo 閘道器
這裡就要開發一個類似nginx
的dubbo閘道器
,並實現七層代理和虛擬域名轉發,程式語言直接選擇了 go,首先 go 做網路開發心智負擔低,另外有個 dubbo-go 專案,可以直接利用裡面的解碼器,然後 go 有原生的 k8s sdk 支援,簡直完美!
思路就是開啟一個TCP Server
,然後解析 dubbo 請求的報文,把attachment
裡的target-application
屬性拿到,再反向代理到真正的 dubbo 提供者服務上,核心程式碼如下:
routingTable := map[string]string{
"user-rpc": "user-rpc:20880",
"pay-rpc": "pay-rpc:20880",
}
listener, err := net.Listen("tcp", ":20880")
if err != nil {
return err
}
for {
clientConn, err := listener.Accept()
if err != nil {
logger.Errorf("accept error:%v", err)
continue
}
go func() {
defer clientConn.Close()
var proxyConn net.Conn
defer func() {
if proxyConn != nil {
proxyConn.Close()
}
}()
scanner := bufio.NewScanner(clientConn)
scanner.Split(split)
// 解析請求報文,拿到一個完整的請求
for scanner.Scan() {
data := scanner.Bytes()
// 通過dubbo-go提供的庫把[]byte反序列化成dubbo請求結構體
buf := bytes.NewBuffer(data)
pkg := impl.NewDubboPackage(buf)
pkg.Unmarshal()
body := pkg.Body.(map[string]interface{})
attachments := body["attachments"].(map[string]interface{})
// 從attachments裡拿到目標提供者的application.name
target := attachments["target-application"].(string)
if proxyConn == nil {
// 反向代理到真正的後端服務上
host := routingTable[target]
proxyConn, _ = net.Dial("tcp", host)
go func() {
// 原始轉發
io.Copy(clientConn, proxyConn)
}()
}
// 把原始報文寫到真正後端服務上,然後走原始轉發即可
proxyConn.Write(data)
}
}()
}
func split(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
buf := bytes.NewBuffer(data)
pkg := impl.NewDubboPackage(buf)
err = pkg.ReadHeader()
if err != nil {
if errors.Is(err, hessian.ErrHeaderNotEnough) || errors.Is(err, hessian.ErrBodyNotEnough) {
return 0, nil, nil
}
return 0, nil, err
}
if !pkg.IsRequest() {
return 0, nil, errors.New("not request")
}
requestLen := impl.HEADER_LENGTH + pkg.Header.BodyLen
if len(data) < requestLen {
return 0, nil, nil
}
return requestLen, data[0:requestLen], nil
}
dubbo ingress controller 實現
前面已經實現了一個dubbo閘道器
,但是裡面的虛擬域名轉發配置(routingTable
)還是寫死在程式碼裡的,現在要做的就是當檢測到k8s ingress
有更新時,動態的更新這個配置就可以了。
首先先簡單的說明下ingress controller
的原理,拿我們常用的nginx ingress controller
為例,它也是一樣通過監聽k8s ingress
資源變動,然後動態的生成nginx.conf
檔案,當發現配置發生了改變時,觸發nginx -s reload
重新載入配置檔案。
裡面用到的核心技術就是informers,利用它來監聽k8s資源
的變動,示例程式碼:
// 在叢集內獲取k8s訪問配置
cfg, err := rest.InClusterConfig()
if err != nil {
logger.Fatal(err)
}
// 建立k8s sdk client例項
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
logger.Fatal(err)
}
// 建立Informer工廠
factory := informers.NewSharedInformerFactory(client, time.Minute)
handler := cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
// 新增事件
},
UpdateFunc: func(oldObj, newObj interface{}) {
// 更新事件
},
DeleteFunc: func(obj interface{}) {
// 刪除事件
},
}
// 監聽ingress變動
informer := factory.Extensions().V1beta1().Ingresses().Informer()
informer.AddEventHandler(handler)
informer.Run(ctx.Done())
通過實現上面的三個事件來動態的更新轉發配置,每個事件都會攜帶對應的Ingress
物件資訊過來,然後進行對應的處理即可:
ingress, ok := obj.(*v1beta12.Ingress)
if ok {
// 通過註解過濾出dubbo ingress
ingressClass := ingress.Annotations["kubernetes.io/ingress.class"]
if ingressClass == "dubbo" && len(ingress.Spec.Rules) > 0 {
rule := ingress.Spec.Rules[0]
if len(rule.HTTP.Paths) > 0 {
backend := rule.HTTP.Paths[0].Backend
host := rule.Host
service := fmt.Sprintf("%s:%d", backend.ServiceName+"."+ingress.Namespace, backend.ServicePort.IntVal)
// 獲取到ingress配置中host對應的service,通知給dubbo閘道器進行更新
notify(host,service)
}
}
}
docker 映象提供
k8s 之上所有的服務都需要跑在容器裡的,這裡也不例外,需要把dubbo ingress controller
構建成 docker 映象,這裡通過兩階段構建優化,來減小映象體積:
FROM golang:1.17.3 AS builder
WORKDIR /src
COPY . .
ENV GOPROXY https://goproxy.cn
ENV CGO_ENABLED=0
RUN go build -ldflags "-w -s" -o main cmd/main.go
FROM debian AS runner
ENV TZ=Asia/shanghai
WORKDIR /app
COPY --from=builder /src/main .
RUN chmod +x ./main
ENTRYPOINT ["./main"]
yaml 模板提供
由於要在叢集內訪問 k8s API,需要給 Pod 進行授權,通過K8S rbac
進行授權,並以Deployment
型別服務進行部署,最終模板如下:
apiVersion: v1
kind: ServiceAccount
metadata:
name: dubbo-ingress-controller
namespace: default
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
name: dubbo-ingress-controller
rules:
- apiGroups:
- extensions
resources:
- ingresses
verbs:
- get
- list
- watch
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
name: dubbo-ingress-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: dubbo-ingress-controller
subjects:
- kind: ServiceAccount
name: dubbo-ingress-controller
namespace: default
---
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: default
name: dubbo-ingress-controller
labels:
app: dubbo-ingress-controller
spec:
selector:
matchLabels:
app: dubbo-ingress-controller
template:
metadata:
labels:
app: dubbo-ingress-controller
spec:
serviceAccountName: dubbo-ingress-controller
containers:
- name: dubbo-ingress-controller
image: liwei2633/dubbo-ingress-controller:0.0.1
ports:
- containerPort: 20880
後期需要的話可以做成Helm
進行管理。
後記
至此dubbo ingress controller
實現完成,可以說麻雀雖小但是五臟俱全,裡面涉及到了dubbo協議
、TCP協議
、七層代理
、k8s ingress
、docker
等等很多內容,這些很多知識都是在雲原生
越來越流行的時代需要掌握的,開發完之後感覺受益匪淺。
關於完整的使用教程可以通過github檢視。
參考連結:
我是MonkeyWie,歡迎掃碼??關注!不定期在公眾號中分享
JAVA
、Golang
、前端
、docker
、k8s
等乾貨知識。