Socket.io 使用 AWS ALB 建立 Load Balance 問題
socket.io
Lastmod: 2019-12-15

socket.io 是一套可以讓我們快速與簡單的建立一套,讓 client 與 server 可以雙向溝通的 Libary,而當我們使用它來建立一個 message server 後,通常在一定的使用量以後,會開始的考慮要加機器來進行擴展,同時間也會建立一台 load balance 的應用來分散請求。

而這時如果你選擇使用AWS ALB (Application Load Balancer)來建立 load balance 你會發現它有個很大的問題,那就是 :

使用非瀏覽器(未處理 cookie )的 client 無法使用 polling 來建立連線

接下來我們將慢慢的來探討原因為何,並且來想想是否有什麼解法呢 ?

本篇文章架構如下 :

  • 原因
  • 解法

原因

為什麼非瀏覽器的 Client 無法使用 polling 來建立連線呢 ?

這裡我們就要先從 socket.io 建立連線的流程開始說啟。

Socket.io 建立連線原理

假設我們在已經在 server 端使用 socket.io 來建立起 message server,然後接下來我們要在 client 端使用socket.io client來建立連線。

var socket = require('socket.io-client')('http://localhost');

而 socket.io client 這裡主要提供了兩種 transport 讓我們 client 與 server 可以互相的傳遞資料 :

  • polling
  • websocket

這兩者差別在於 polling 就是一直用 http 長連線去 server 看看有沒有資料,而 websocket 是有資料時 server 會自動的推送。

那如果我們在建立連線時,沒有設定要用那個,那它會選用那種方式呢 ?

它會先使用 polling 來進行連線與資料傳輸,然後等到確定可以升級 websocket 後,就會將它轉成使用 websocket,然後將原本 polling 的那條給移除。

建立連線的概念圖如下,而這個建立連程的核心就是 :

sid 也就是 session id

使用 AWS ALB Load Balance

接下來我們建立 aws alb 來檔我們的 load Balance 架構如下圖,這裡提醒一下,如果是使用 alb 來建立連線,那當連線是屬於 websocket 時,這條 websocket 連線實際上是,client 到 alb 一條,而 alb 在到 server 一條這樣,而不是 client 直接與 server 建立一條 websocket。

當然有人會說那這樣貧頸不是會在 alb 上,但目前已知,它有提供自動 auto scalling 的機制,這裡就先暫時的相信 aws。

然後接下來你會發現這連線有很高的機率連線無法建立。

當你使用下面的 client 來建立連線時,會發現很高的機率無法建立。

var socket = require('socket.io-client')('http://localhost');

問題就在於 sid

基本上建立一條完整可運行的 socket.io 連線時,至少可能會產生的三次請求。

  1. polling 連線建立,取得 sid。
  2. 升級為 ws (它會帶 sid 發送請求)。
  3. 使用 polling 看看是否有資料 (它會帶 sid 發送請求)。

然後你想想,如果你第一次建立連線時,是在 server A 取得 sid,那你第二、三次使用時,如果連到 server B 或 C 時,server 會知道你要連的 socket 是誰嗎 ? 然後接下來 client 就會以為連線斷了,就自動的幫你斷線了,這就是失敗的原因。

使用 AWS Sticky Session 的問題

然後有用過 aws alb 的人就會說,你可以使用它提供的 sticky session 功能,它可以讓某個 client 在一定的時間內的請求,都發送到同一台 server 上。

這樣應該是可以解決問題沒錯。

但是筆者那時一直試怎麼樣都無法成功建立。

後來發現,問題是出在 :

aws sticky session 是使用 cookie 來處理

而筆者是用 nodejs 加上 socket.io client 來建立連線,因此如果沒有像瀏覽器一樣的幫我們自動處理 cookie, 那這個 sticky session 就會失效。

解法

事實上從上面的原因探討可以得知問題的核心在於兩點 :

  1. polling 建立連線與請求資料,都會發送多條 http,並且用 sid 來標示這條連線,所以如果發送到沒有 sid 的那台 server 就會失敗。

  2. aws sticky session 在非瀏覽器情況(未處理 cookie) 時會失效。

這我們就來想一想假設我們真的需要支援非瀏覽器情況要如何解決呢 ?

筆者這裡有想到幾種解法,然後分為以下兩類。

第一類 : 還沒有用戶在開始使用時

1. 強制讓 Client 端使用 Websocket 連線

首先第一個方法,就是直接指定 client 端直接強制使用 websocket 連線,socket.io client 強制使用的方法如下 :

var socket = require('socket.io-client')('http://localhost',{
  'transports': ['websocket']
});

這樣在建立連線時它就直接使用 websocket 來建立,而由於 websocket 屬於永久連線,也就是說一但你在 server A 建立完連線後,你接下來傳送的請求,都是一定是透過那台 server 來進行處理,因此就不會有上述的問題。

假設如果你的 client 或 server 其中一方沒有辦法提供 websocket 協議,而你真的只能使用 polling 來處理,那你就需要想辦法讓 client 處理 cookie。

處理原理如下:

  1. server 在 http 表頭設定 set-cookie 表頭
  2. application 中如果收到 response 中有 set-cookie 就將它取出並存成某個暫存檔。
  3. 每當 application 要發送 http 請求時,就自動的將它帶到 header 中。

事實上,上面的過程就是瀏覽器運行 cookie 的原理,而我們就只是實作一次。

第二類 : 已經有用戶開始使用且無法強制所有人更新

1. 不要使用 AWS ALB

不要使用 aws alb 來處理 load Balance,而選用其它的應用如 nginx,因為它有提到 ip hash 的分配演算法,它可以讓同一個 ip 都打到同一台。

但是這種方案就有幾個麻煩處 :

  • 你要自已處理 nginx 的 HA 機制。
  • 你需要注意 nginx 單機的最大連線量 (也就是連線貧頸會出現在 nginx 這台)

別問我 alb 如何處理貧頸,反正官網說可以自動 scalling。

2. 使用 AWS ALB 分流 Websocket 與 Polling

但這種情況有個假設,那就的是 polling 的使用量不多。

這種情況的做法順序如下 :

  1. 將之後新出版本的 client 都強制使用 websocket。
  2. 在 alb 將 websocket 與 polling 進行分流 (alb 可以根據 path 或 header 分流)。
  3. 等到大部份的用啟都慢慢的升級到新版本後,那 polling 的那可以慢慢的下掉。

架構圖如下 :

但是這裡要注意,你的 polling 用的 server 只能有一台,這也是為什麼要假設 polling 使用量不多,因為它無法擴展。

結論

最後來說說感想。

socket.io client 使用了 polling 與 websocket 方法來幫助我們建立與 client 與 server 的即時溝通,即時不支援 websocket 它也可以用 polling 來處理,但是我覺得這同時間也是雙刃劍,因為這代表你在做任何事情時,你都要考慮兩種情況是否可以運行。

最後筆者建議,真的準備要開始使用 socketio 的人並且要支援不同 client 的人,請直接指定使用 websocket,這樣真的會省下不少麻煩,現階段大部份的 client 應該是都有提供 websocket 的處理,沒有就叫他滾 !

參考資料 Socket.io 建立連線實際過程

它的建立連線實際過程如下。

以下看到的資訊都是使用 socket.io 所提供的 debug log 所取得,開啟方式如下。

DEBUG=* node xxxx.js

首先他會發送以下的 url 來請求建立 socket 連線。

xhr open 
GET:http://localhost:8080/socket.io/EIO=3&transport=polling&t=MckJUI4&b64=1

接下來它會收到一段回應,其它他包含了以下幾個資訊 :

  • sid : session id,接下 polling 的 http 請求都會帶它。
  • uprades : 表示可以升級為 websocket。
  • pingInterval : 每幾 ms 會發送一次 ping packet,用來確定這條連線還存在。
  • pingTimeout : 如果超過此 ms 沒有收到 pong,這代表此連線已斷。
polling got data 

{
  "sid":"Va6Nnm8ia_8ioOAvAAAA",
  "upgrades":["websocket"],
  "pingInterval":25000,
  "pingTimeout":5000
}

然後執行到了這裡基本上這條連線的 socket 就算完成了。

socket receive: 
type "open", data "{"sid":"Va6Nnm8ia_8ioOAvAAAA","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":5000}"

socket open

然後接下來它會嘗試的進行升級,它事實上會發一個 ping 給 server。

engine.io-client:socket starting upgrade probes +1ms
engine.io-client:socket probing transport "websocket"
engine.io-client:socket creating transport "websocket"
engine.io-client:socket probe transport "websocket" opened
ws://localhost:8080/socket.io/?EIO=3&transport=websocket&sid=Va6Nnm8ia_8ioOAvAAAA

但是注意,在升級的中間,polling 還是會繼續的去 server 看看有沒有資料。

engine.io-client:polling-xhr xhr poll
  engine.io-client:polling-xhr xhr open GET: http://localhost:8080/socket.io/?EIO=3&transport=polling&t=MckJUJ3&b64=1&sid=Va6Nnm8ia_8ioOAvAAAA
  engine.io-client:polling-xhr xhr data null

然後接下偵測到 server 有回收到升級用的 pong 回來,因此正式的將 transport 轉換成 websocket,並且將 polling 關掉。到了這時基本上 websocket 連線就就算正式的完成了。

engine.io-client:socket probe transport "websocket" pong
engine.io-client:socket pausing current transport "polling"
engine.io-client:socket changing transport and sending upgrade packet
engine.io-client:socket setting transport websocket +0ms
engine.io-client:socket clearing existing transport polling
comments powered by Disqus