在以往的几个项目中,都为边缘云架构,往往都需要云边通信的方式,本文章为云边通信实现多种方式的介绍。
分析
在云-边部署中,云和边缘节点在物理上是隔离的,但在业务上需要从云端下发指令或任务到边缘节点执行。数据交互场景下,主要有两种模式:Push(推送)和Pull(拉取)。
| 特性 | Push 模式 | Pull 模式 |
|---|---|---|
| 定义 | 数据提供者主动将数据发送到接收者 | 数据接收者主动从数据提供者拉取数据 |
| 延迟 | 通常较低,因为数据一生成就立即发送 | 可能较高,因为接收者需要定期检查是否有新数据 |
| 网络使用 | 网络流量可能较高,尤其是当频繁发送数据时 | 网络流量可能较低,因为只在需要时才拉取数据 |
| 实时性 | 实时性较好,适合需要立即处理数据的应用 | 实时性较差,适合对延迟要求不高的应用 |
| 资源消耗 | 发送端需要保持连接并主动发送数据,资源消耗可能较高 | 接收端定期轮询服务器,资源消耗可能较高 |
| 复杂性 | 实现较简单,不需要复杂的请求响应机制 | 实现较复杂,需要管理轮询和数据拉取频率 |
| 控制权 | 发送端控制数据发送的时机和频率 | 接收端控制数据拉取的时机和频率 |
| 扩展性 | 扩展性较差,发送端需要管理更多的连接 | 扩展性较好,接收端可以自行管理拉取频率和连接 |
| 应用场景 | 实时消息推送、通知系统、流媒体传输 | 定期数据同步、日志收集、批量数据处理 |
| 数据一致性 | 更易保持数据一致性,因为数据变化立即同步 | 数据一致性可能较差,因为拉取频率可能导致数据延迟 |
Pull模式的缺陷:
- 控制权管理问题:由于是轮询处理数据,需要在拉取数据时携带当前节点信息。如果节点消费了数据,排查问题会较为复杂。
- 连接资源占用:定期轮询会占用云端服务的连接资源。
- 任务控制不精细:无法对任务进行更精细化的控制。
边缘云场景下的通信规范:
- 云上进行所有任务/流程的管理,边缘节点只进行计算处理工作。
- 云上服务直接调用边缘节点服务,边缘节点通过公网反向回调云上服务。
问题:如果直接通过HTTP/GRPC调用,每个节点都需要配置公网(甚至加上域名),这将大大增加管控成本:
- 每个节点都需要进行鉴权操作。
- 每个节点都需要购买公网IP和域名。
- 每个节点的网关都需要进一步管理。
因此,需要一种跨集群代理的手段来完成这些工作。
方案
目的:使用代理的需要从云端能够访问到节点内服务,主要是HTTP代理,如下图所示:
目前使用的方案:
-
使用libp2p实现代理:
-
SSH隧道:
-
GRPC Bidirectional Streaming实现节点连接管理。
最终采用的方案:GRPC Bidirectional Streaming方式,理由如下:
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来直接进行代理。
经验点
- 如何将request转化成byte,进行透传:
var payload bytes.Buffer
if err := req.Write(&payload); err !=nil{
}
- 如何将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
}
- 如何将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()
- 如何将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,由于长连接服务存在连接状态,所以有单点故障的问题。所以后续打算使用一些分布式协议来将服务端进行去中心化。