使用 Envoy 做为 socket.io 的前端代理

示例 socket.io 项目

  • 编译

    go get github.com/tangxinfa/envoy-socket.io-example
    cd $GOPATH/src/github.com/tangxinfa/envoy-socket.io-example
    glide install
    go build
    
  • 运行

    ./envoy-socket.io-example -addr 127.0.0.1:8001 -logtostderr
    
  • 用浏览器打开 http://localhost:8001

    正常情况下,会收到 welcome 消息,表示 socket.io 连接成功,可以在下方的编辑框输入内容,服务器会 echo 回来。这是客户端直接 socket.io 服务,接下来将展示使用 Envoy 做为前端代理来访问后端的 socket.io 服务。

修复 Envoy 的 Websocket 相关 Bug

之前的 Envoy 版本为

commit 3e43c2225c8882918b36b4b7c7bb55c6af2db929
Author: Greg Greenway <ggreenway@users.noreply.github.com>
Date:   Wed Nov 15 14:48:38 2017 -0800

Fix v2 TcpProxy config (#2065)

Signed-off-by: Greg Greenway <ggreenway@apple.com>

存在两个问题导致 Websocket 不可用:

  • Connection 请求头包含多个值时未能正确处理,导致未正确判断出 Websocket 请求

    Firefox 发起的 Websocket 请求 Connection 头的值为: keep-alive, Upgrade

  • Envoy 向 Upstream 发起的 Websocket 请求多了一个 transfer-encoding: chunked 请求头

    由于请求体是空的,所以是一个无效的 HTTP 请求。

我提交了 Pull Request #2070 ,已合到 master 分支,该问题已修复。

将 Envoy 做为 socket.io 服务前端代理

  • 启动 Envoy

    使用是的修复 BUG 后的 Envoy。

    ~/Opensource/envoy/bazel-bin/source/exe/envoy-static --log-level trace --config-path ./envoy.json
    
  • 用浏览器打开 http://localhost:9001

    正常情况下,会收到 welcome 消息,表示 socket.io 连接成功,可以在下方的编辑框输入内容,服务器会 echo 回来。

将 Envoy 做为 socket.io 服务集群前端代理

在后台服务为集群的情况下,Envoy 会通过负载均衡将请求调度到所有服务结点,对于无状态服务,不管 Envoy 使用什么样的负载均衡策略都可以正常工作,但是对于有状态的服务,则要求将一个用户的请求总是调度到同一个服务结点。

Socket.IO never assumes that WebSocket will just work, because in practice there’s a good chance that it won’t. Instead, it establishes a connection with XHR or JSONP right away, and then attempts to upgrade the connection to WebSocket. Compared to the fallback method which relies on timeouts, this means that none of your users will have a degraded experience.

引用自 Socket.IO Polling vs. WebSocket Transport – on Balancing Methods

socket.io 的 transports 选项默认值为 ['polling', 'websocket'] ,也就是说首先发一个 HTTP 轮询请求,根据响应决定是否升级到使用 websocket ,这样在 Websocket 可用的情况下,会有两次 HTTP 交互,需确保两次 HTTP 交互调度到同一个服务结点,socket.io 连接才能建立成功。

Envoy 本身支持多种负载均衡策略,适合这里的场景是 Ring hash

Ring hash

The ring/modulo hash load balancer implements consistent hashing to upstream hosts. The algorithm is based on mapping all hosts onto a circle such that the addition or removal of a host from the host set changes only affect 1/N requests. This technique is also commonly known as “ketama” hashing. The consistent hashing load balancer is only effective when protocol routing is used that specifies a value to hash on. Currently the only implemented mechanism is to hash via HTTP header values in the HTTP router filter.

遗憾的是 Envoy 的 Websocket 实现过于简陋,当检测到 Websocket 升级请求时,它以 TcpProxy 的方式连接上游服务器,检测到非 Websocket 升级请求时,会正常地做为 HTTP 请求进行处理。由于 TcpProxy 只支持随机 Hash 算法选择上游服务结点,会导致默认情况下 socket.io 连接无法建立。

通过指定 socket.io 的 transports 选项值为 ['websocket', 'polling'] ,也就是首先尝试建立 websocket 连接,失败时再降级为 polling ,能够解决这个问题。

Envoy 的 Ring hash 是根据配置的请求头来计算 Hash 值的,刚刚有一个按特定 Cookie 计算 Hash 值的 Pull Request Implement cookie hashing for v2 API #1766 已经合并到 master 分支,官方文档方面还没有更新,本文暂不采用。

一般 Web 前端都是通过自定义请求头或者 Cookie 中的特定字段来标识一个用户,由于 Cookie 可能会在交互过程中发生变化,因此不适合用于计算 Hash 值,而 Websocket 升级请求又不支持自定义请求头,因此这两种常用的方式失效了。

另一个合理的选择是通过 Referer 请求头来计算 Hash,这是客户端网页的地址,不会在交互过程中变化,只要想办法将用户的标识(如 ID)附在 URL 上,如:http://www.example.com/chat?uid=1234 ,就可以保证用户请求能够均衡地分布到所有服务结点上,同一用户的请求也会调度到同一个服务结点。

如下所示。

  • 启动服务集群

    ./envoy-socket.io-example -127.0.0.1:8002 -logtostderr &
    ./envoy-socket.io-example -127.0.0.1:8003 -logtostderr &
    ./envoy-socket.io-example -127.0.0.1:8004 -logtostderr &
    
  • 启动 Envoy

    使用是的修复 BUG 后的 Envoy。

    ~/Opensource/envoy/bazel-bin/source/exe/envoy-static --log-level trace --config-path ./envoy2.json
    
  • 用浏览器打开多个 http://localhost:9002/index2.html 页面

    正常情况下,每个页面都会收到 welcome 消息,表示 socket.io 连接成功,可以在下方的编辑框输入内容,服务器会 echo 回来。