【GRPC】跨集群代理有哪些实现方式

362 阅读8分钟

在以往的几个项目中,都为边缘云架构,往往都需要云边通信的方式,本文章为云边通信实现多种方式的介绍。

分析

在云-边部署中,云和边缘节点在物理上是隔离的,但在业务上需要从云端下发指令或任务到边缘节点执行。数据交互场景下,主要有两种模式:Push(推送)和Pull(拉取)。

特性Push 模式Pull 模式
定义数据提供者主动将数据发送到接收者数据接收者主动从数据提供者拉取数据
延迟通常较低,因为数据一生成就立即发送可能较高,因为接收者需要定期检查是否有新数据
网络使用网络流量可能较高,尤其是当频繁发送数据时网络流量可能较低,因为只在需要时才拉取数据
实时性实时性较好,适合需要立即处理数据的应用实时性较差,适合对延迟要求不高的应用
资源消耗发送端需要保持连接并主动发送数据,资源消耗可能较高接收端定期轮询服务器,资源消耗可能较高
复杂性实现较简单,不需要复杂的请求响应机制实现较复杂,需要管理轮询和数据拉取频率
控制权发送端控制数据发送的时机和频率接收端控制数据拉取的时机和频率
扩展性扩展性较差,发送端需要管理更多的连接扩展性较好,接收端可以自行管理拉取频率和连接
应用场景实时消息推送、通知系统、流媒体传输定期数据同步、日志收集、批量数据处理
数据一致性更易保持数据一致性,因为数据变化立即同步数据一致性可能较差,因为拉取频率可能导致数据延迟

Pull模式的缺陷:

  1. 控制权管理问题:由于是轮询处理数据,需要在拉取数据时携带当前节点信息。如果节点消费了数据,排查问题会较为复杂。
  2. 连接资源占用:定期轮询会占用云端服务的连接资源。
  3. 任务控制不精细:无法对任务进行更精细化的控制。

边缘云场景下的通信规范:

  • 云上进行所有任务/流程的管理,边缘节点只进行计算处理工作。
  • 云上服务直接调用边缘节点服务,边缘节点通过公网反向回调云上服务。


问题:如果直接通过HTTP/GRPC调用,每个节点都需要配置公网(甚至加上域名),这将大大增加管控成本:

  • 每个节点都需要进行鉴权操作。
  • 每个节点都需要购买公网IP和域名。
  • 每个节点的网关都需要进一步管理。

因此,需要一种跨集群代理的手段来完成这些工作。

方案

目的:使用代理的需要从云端能够访问到节点内服务,主要是HTTP代理,如下图所示:

目前使用的方案:

  1. 使用libp2p实现代理

  2. SSH隧道

  3. GRPC Bidirectional Streaming实现节点连接管理

最终采用的方案:GRPC Bidirectional Streaming方式,理由如下:

  • SSH隧道为端口转发,依赖于运维人工管理及配置,不利于自动化。
  • libp2p是新技术,尚未深度使用。
  • GRPC的双向流更适合跨集群通信。

grpc实现跨集群代理

在GRPC使用过程中,大多数人使用的是请求-响应方式。GRPC提供的模式包括:

  • Simple RPC
  • Server-side streaming RPC(服务端流式)
  • Client-side streaming RPC(客户端流式)
  • Bidirectional streaming RPC(双向流)

使用参考:grpc.io/docs/langua…

使用双向流

  • 由于需要进行跨集群通信,可以采用双向流的方式。

双向流

拿官方进行举例说明:
server.go

func (s *routeGuideServer) RouteChat(stream pb.RouteGuide_RouteChatServer) error {
  for {
    in, err := stream.Recv()
    if err == io.EOF {
      return nil
    }
    if err != nil {
      return err
    }
    key := serialize(in.Location)
                ... // look for notes to be sent to client
    for _, note := range s.routeNotes[key] {
      if err := stream.Send(note); err != nil {
        return err
      }
    }
  }
}

client.go

stream, err := client.RouteChat(context.Background())
waitc := make(chan struct{})
go func() {
  for {
    in, err := stream.Recv()
    if err == io.EOF {
      // read done.
      close(waitc)
      return
    }
    if err != nil {
      log.Fatalf("Failed to receive a note : %v", err)
    }
    log.Printf("Got message %s at point(%d, %d)", in.Message, in.Location.Latitude, in.Location.Longitude)
  }
}()
for _, note := range notes {
  if err := stream.Send(note); err != nil {
    log.Fatalf("Failed to send a note: %v", err)
  }
}
stream.CloseSend()
<-waitc

从代码中可以看出,服务端-客户端都实现了send 以及 recv 函数,即都可以接受,发送数据。对于我们的需求上述代码肯定不满足,因为代理服务是并发的,不可能说按照上面串行的响应。
由此产生了以下问题:
是否可以在调用过程中,对于每一个请求,响应进行隔离?
grpc的双向流的send(), recv()的使用场景限制:不允许拿着 stream结构体,在不同goroutine中进行同一种操作,如
错误示范:不同goroutine中使用同一个stream的send,recv同理;

func xxx(){
   go func(){
       stream.Send(msg)
    }()
   go func(){
        stream.Send(msg)
    }
}

正确示范:

 go func(){
        // 用于发送命令
        stream.Send(msg)
    }
 go func() {
        // 用于接受命令
  for {
   msg, err := stream.Recv()
   if err == io.EOF {
    return
   }
   if err != nil {
    u.log.Errorf("get recv err msg: %s", err)
    return
   }
   // 将消息发送给通道, 使得调用方能够接收
   go ed.RecvToClient(msg)
  }
 }()
 for {
  select {
  case <-ed.Done():
   u.log.Infof("stop ed: %s", ed)
   return nil
  case <-stream.Context().Done():
   u.log.Infof("edge: %s, close", ed)
   return nil
  }
 }

由于在proxy的时候存在外部函数需要调用发送信息给目的节点,那么就可以通过channel实现:

func (p *pop) StartSender() {
 for {
  msg := <-p.sendChan
  if err := p.stream.Send(msg); err != nil {
   p.log.Warnf("send msg err: %s", err)
   p.ReleaseRecvChan(msg.TraceId)
   continue
  }
 }
}

到这里,双向流的通信就可以正常通信了。
但又产生了一个问题:并发的进行通信过程中,如何知道哪一条接受的消息是哪一条请求的响应?
从上面代码可以看出,每一个发送的消息都带有traceId,这里用到的技术:同步转异步请求的方式。 其实在开发中也经常用到:

在go语言中,天然就存在一种队列:Channel。所以解决的方式,也就显而易见了:
在发送消息时,同步创建一个channel,并使用mapchannel的方式,即:

func (p *Pop) Request(msg *v1.StreamMsg) *v1.StreamMsg {
    // 创建接收消息channel
 recvChan := make(chan *v1.StreamMsg)
 p.Lock()
 p.recvChan[msg.TraceId] = recvChan
 p.Unlock()
 p.sendChan <- msg
 timeout := int(msg.GetTimeout())
 if timeout == 0 {
  timeout = DefaultTimeout
 }
 ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
 defer cancel()
    // close channel
 defer p.ReleaseRecvChan(msg.TraceId) 
    // 接收响应消息
 for {
  select {
  case <-ctx.Done():
   e.log.Info("timeout return: ", msg.TraceId)
   return nil
  case ret, ok := <-recvChan:
   if !ok {
    return nil
   }
   return ret
  }
 }
}

那么当服务端接收到消息时,可以通过向对应traceId的channel发送消息即可。
以上长连接通道已经存在,接下来解决http代理的问题。

HTTP代理

HTTP是通过TCP进行通信的,简单的想法就是实现TCP端口用来捕获用户的HTTP请求,再通过HTTP Method的特殊字段来做分发。
实现方式可参考:github.com/junhaideng/…
其中需要在处理方法中,使用stream方式转发到对应的grpc流中。关键代码如下:
server.go

func handle(){
    ... 解析request
    reqMsg := &v1.StreamMsg{
  TraceId:   uuid.NewString(),
  Payload:   payload.Bytes(),
 }
    respMsg := stream.Send(reqMsg)
 // 使用 bytes.Reader 将 []byte 包装为一个读取器
 reader := bytes.NewReader(respMsg.Payload)

 // 解析 HTTP 响应
 resp, err := http.ReadResponse(bufio.NewReader(reader), req)
 if err != nil {
  // 如果解析失败返回错误
  w.WriteHeader(502)
  w.Write([]byte(err.Error()))
  return
 }
 // Copy any headers
 for k, v := range resp.Header {
  for _, s := range v {
   w.Header().Add(k, s)
  }
 }

 // Write response status and headers
 w.WriteHeader(resp.StatusCode)

 // 将resp的body拷贝到w中
 io.Copy(w, resp.Body)
 resp.Body.Close()
}

client.go

 var err error
 defer func(start time.Time) {
  c.log.Infof("time cost: %s ", time.Now().Sub(start).String())
 }(time.Now())
 // 建议Dial 转发一份host
 defer func() {
  if err != nil {
   var buf bytes.Buffer
   resp := http.Response{
    StatusCode: 500,
   }
   resp.Write(&buf)
   msg.Payload = buf.Bytes()
   msg.Operator = v1.Operator_response
   c.SendMsg(msg)
  }
 }()
 // 创建一个 bytes.Reader 来读取原始请求字节数据
 reader := bytes.NewReader(msg.Payload)

 // 使用 http.ReadRequest 从 bytes.Reader 中解析出 HTTP 请求
 req, err := http.ReadRequest(bufio.NewReader(reader))
 if err != nil {
  fmt.Println("Error reading request:", err)
  return
 }

 proxyHost := req.Header.Get("PROXY_HOST")
 proxyPort := req.Header.Get("PROXY_PORT")
 if proxyHost == "" || proxyPort == "" {
  err = errors.New("not found proxy_host or proxy_port")
  return
 }
 proxyURL := &url.URL{
  Scheme: "http",
  Host:   net.JoinHostPort(proxyHost, proxyPort),
 }
    // 创建一个反向代理器
 proxy := httputil.NewSingleHostReverseProxy(proxyURL)
 originalDirector := proxy.Director
 proxy.Director = func(r *http.Request) {
  originalDirector(r)
  r.Host = proxyURL.Host
 }

 rec := httptest.NewRecorder()

 proxy.ServeHTTP(rec, req)

 var buf bytes.Buffer
 err = rec.Result().Write(&buf)
 if err != nil {
  c.log.Errorf("Error writing response: %s", err)
  return
 }

 res := &v1.StreamMsg{
  TraceId:  msg.TraceId,
  Payload:  buf.Bytes(),
  Operator: v1.Operator_response,
 }
 c.SendMsg(res)

在这点上,server.go可以优化,如果直接使用TCP接口进行代理,那么在云上服务请求时,proxy中会接受到两次请求,第一次为TCP三次握手的空数据。
所以可以直接使用http.Server来直接进行代理。

经验点

  1. 如何将request转化成byte,进行透传:
var payload bytes.Buffer
if err := req.Write(&payload); err !=nil{
}
  1. 如何将byte转化成 request:
// 从conn中读取请求数据
 n, err := conn.Read(request)
 if err != nil {
  fmt.Println("read request error: ", err)
  return
 }
 reader := bytes.NewReader(request[:n])

 // 使用 http.ReadRequest 从 bytes.Reader 中解析出 HTTP 请求
 req, err := http.ReadRequest(bufio.NewReader(reader))
 if err != nil {
  fmt.Println("Error reading request:", err)
  return
 }
  1. 如何将response转化成byte:
// 使用 bytes.Reader 将 []byte 包装为一个读取器
 reader := bytes.NewReader(respMsg.Payload)

 // 解析 HTTP 响应
 resp, err := http.ReadResponse(bufio.NewReader(reader), req)
 if err != nil {
  // 如果解析失败返回错误
  w.WriteHeader(502)
  w.Write([]byte(err.Error()))
  return
 }
 // Copy any headers
 for k, v := range resp.Header {
  for _, s := range v {
   w.Header().Add(k, s)
  }
 }

 // Write response status and headers
 w.WriteHeader(resp.StatusCode)

 // 将resp的body拷贝到w中
 io.Copy(w, resp.Body)
 resp.Body.Close()
  1. 如何将byte转化成response:
proxy := httputil.NewSingleHostReverseProxy(proxyURL)
 originalDirector := proxy.Director
 proxy.Director = func(r *http.Request) {
  originalDirector(r)
  r.Host = proxyURL.Host
 }

 rec := httptest.NewRecorder()

 proxy.ServeHTTP(rec, req)

 var buf bytes.Buffer
 err = rec.Result().Write(&buf)   // <- 关键步骤
 if err != nil {
  c.log.Errorf("Error writing response: %s", err)
  return
 }

总结

当前方案在测试过程中可达到900qps,由于长连接服务存在连接状态,所以有单点故障的问题。所以后续打算使用一些分布式协议来将服务端进行去中心化。