Phoenix + WebSocket分佈式部署驗證

Phoenix + WebSocket分佈式部署實驗

前言

對於無狀態的web服務,要作分佈式部署相對比較簡單,不少時候只要架一個反向代理就行。可是對於有狀態的web服務,尤爲是包含WebSocket成分的web應用,要作分佈式部署一直是一個麻煩。傳統作法是搞一箇中間層,例如Redis之類的作pubsub,可是這樣作就不得不改動源碼。同時,系統複雜度也隨之增長,運維的成本也相應提升。Erlang/Elixir這種「面向併發編程」的語言在這方面會不會高人一籌?Phoenix框架是否已經支持WebSocket的橫向擴展了呢?下面咱們就來作個實驗。html

資源

你能夠去https://gitee.com/aetherus/gossipy下載本文涉及的源碼。node

目標

不添加其餘服務(如Redis、RabbitMQ等),不改動項目源碼,僅經過添加/修改配置文件來達到WebSocket服務的橫向擴展。python

實驗器材

  • Ubuntu 16.04或其衍生髮行版(我用的是Elementary OS Loki)
  • Docker
  • Docker compose
  • Elixir開發/運行環境
  • 一個最基本的Phoenix聊天室,不含數據庫,不含assets,不含brunch。

安裝發佈工具

Elixir社區目前比較推薦的發佈工具是Distillery(蒸餾器),此次實驗就用它。git

安裝只須要在項目根目錄的mix.exs裏添加以下內容就行web

defp deps do
  [
    {:distillery, "~> 1.5", runtime: false}   #<--- 這一行
  ]
end

這裏的runtime: false表示distillery不會在web應用中用到,只在發佈的時候用一下。
添加完後只需mix deps.get一下就行。docker

發佈配置

首先讓distillery生成一些最基本的發佈配置:數據庫

$ mix release.init

你會看到項目根目錄下多了個rel目錄,裏面只有一個空的plugins目錄和一個config.exs文件。這個文件的配置用來發布到單臺服務器已經足夠了,可是要作集羣仍是不太夠,由於咱們要讓各臺服務器上的Phoenix應用能連起來相互通訊。爲此,咱們須要給每一個運行的實例一個名稱(namesname)。編程

爲了達到這個目的,咱們須要一個vm.args文件。這個文件記錄了Erlang啓動虛擬機時所需的命令行參數。可是這個文件長啥樣?咱們現release一個,讓它自動生成一個vm.args文件再說。ubuntu

$ MIX_ENV=prod mix release --env=prod

這裏的MIX_ENV=prod是指「用Phoenix的prod環境的配置來運行發佈任務」,這樣作可使項目的編譯獲得優化,好比去除debug信息等。而--env=prod指的是「按rel/config.exs文件裏:prod環境的配置去構建發佈版」。這個prod和Phoenix的prod的意義徹底不一樣,因此兩個都不能少。瀏覽器

既然說到了rel/config.exs裏定義的環境,就先看看它長什麼樣吧。

Path.join(["rel", "plugins", "*.exs"])
|> Path.wildcard()
|> Enum.map(&Code.eval_file(&1))

use Mix.Releases.Config,
    default_release: :default,
    default_environment: Mix.env()

environment :dev do
  set dev_mode: true
  set include_erts: false
  set cookie: :"<&9.`Eg/{6}.dwYyDOj>R6R]2IAK;5*~%JN(bKuIVEkr^0>jH;_iBy27k)4J1z=m"
end

environment :prod do
  set include_erts: true
  set include_src: false
  set cookie: :">S>1F/:xp$A~o[7UFp[@MgYVHJlShbJ.=~lI426<9VA,&RKs<RyUH8&kCn;F}zTQ"
end

release :gossipy do
  set version: current_version(:gossipy)
  set applications: [
    :runtime_tools
  ]
end

這就是一個完整的rel/config.exs文件內容(去掉了註釋)。咱們能夠看到裏面有個environment :prod塊,還有一個environment :dev塊,這兩個塊定義了兩種不一樣的構建策略。這裏比較重要的是set include_erts: true|false這一項。erts是「Erlang RunTime System」的縮寫,也就是整個Erlang運行環境。若是把這一項設置成true,則打出來的包裏包含整個Erlang運行環境,因而你的目標服務器上就能夠不用裝Erlang和Elixir了。

上述命令運行完後,會生成_build/prod/rel目錄及其下面全部的文件。在這裏面找到vm.args文件(具體位置忘了),把它複製到項目根目錄下的rel目錄裏,稍事修改:

# 刪除下面這一行
# -name gossipy@127.0.0.1
# 加入下面這一行
-sname gossipy

namesname的區別很少說了。由於到時候咱們要部署到docker上去,用IP或全限定域名不方便,因此就用主機名。

改完vm.args以後,咱們要讓distillery認識這個改動過的vm.args。咱們在rel/config.exs里加上一行:

environment :prod do
  ...
  set vm_args: "rel/vm.args"
end

除了這些,Distillery還要求在項目的config/prod.exs里加一些東西:

config :gossipy, GossipyWeb.Endpoint,
  ...
  check_origin: false,
  server: true,
  root: ".",
  version: Application.spec(:gossipy, :vsn)
  • check_origin: false只是作實驗的時候圖一時方便,正式上產品的時候千萬不要加這一行。
  • server: true的意思是這是一個web server,因此要用Cowboy去啓動,而不是直接從Application啓動。
  • root: "."表示靜態文件(CSS,JS之類)的根在哪兒。由於咱們此次沒有靜態文件,因此不配也OK。
  • version是發佈的版本號。它的值經過Application.spec(:gossipy, :vsn)獲取,也就是mix.exs裏那個版本號。

另外,咱們須要在這個配置文件裏列出全部的分佈式節點:

config :kernel,
  sync_nodes_optional: [:"gossipy@ws1", :"gossipy@ws2"],
  sync_nodes_timeout: 10000

sync_nodes_optional是指「若是在sync_nodes_timeout指定的時間範圍內沒有連上指定的節點,則忽略那個節點」。與之相對的還有一個sync_nodes_mandatory選項。

最後,爲了避免讓服務器因超時而主動切斷WebSocket,咱們須要在lib/gossipy_web/channels/user_socket.ex里加上一個配置,讓WebSocket永不超時:

transport :websocket, Phoenix.Transports.WebSocket,
  timeout: :infinity  # <---這裏,別忘了在上一行結尾加個逗號

全部配置都準備好後,先清除掉上次構建的發佈版,再從新構建一次:

$ MIX_ENV=prod mix release.clean
$ MIX_ENV=prod mix release --env=prod

而後就能夠準備部署了

建立Docker鏡像

既然是部署到Docker,就要先建立一份Dockerfile,內容以下:

FROM ubuntu:xenial

EXPOSE 4000

ENV PORT=4000

RUN mkdir -p /www/gossipy && \
    apt-get update && \
    apt-get install -y libssl-dev

ADD ./_build/prod/rel/gossipy/releases/0.0.1/gossipy.tar.gz /www/gossipy

WORKDIR /www/gossipy

ENTRYPOINT ./bin/gossipy foreground

由於發佈包內的Erlang運行環境要求服務器的OS和Distillery運行時的OS儘量同樣,因此這裏就用Ubuntu 16.04的服務器版。端口設爲4000(你喜歡其餘端口號也OK)。因爲WebSocket須要crypto.so,因此先裝一下libssl-dev,不然應用起不來。把打包出來的tar包扔進鏡像(docker會替你自動解壓),當docker啓動的時候把這個服務啓動起來就是了。

爲了能簡化命令行命令,再建一個docker-compose.yml

version: '3.2'

services:
  ws1:
    build: .
    hostname: ws1
    ports:
      - 4001:4000

  ws2:
    build: .
    hostname: ws2
    ports:
      - 4002:4000

我定義了兩個節點,分別將宿主的4001和4002端口NAT到了docker容器的4000端口。另外,這裏顯式聲明瞭每一個節點的主機名(hostname),方便和Phoenix應用對接。

一切OK後,docker-compose up

而後你就能夠想辦法搞兩個WebSocket客戶端(若是你不知道怎麼搞的話,能夠參考附錄1),分別鏈接宿主服務器的4001和4002端口,加入同一個房間,而後你就能看見它們能對話了!

額外實驗1. 殺節點

先殺掉ws2那個容器(端口4002)

$ docker-compose kill ws2

結果固然是連在ws2上的WebSocket鏈接所有斷開,而ws1上的鏈接依然正常工做。ws2的鏈接中斷很正常。在實際項目中,咱們不會把一個web服務分在多個端口號上,而是公用一個源(協議 + 域名 + 端口),這樣只要客戶端實現了合理的重連機制,很快就能和別的活着的服務器創建鏈接。

而後咱們再把ws2從新啓動起來

$ docker-compose start ws2

從新創建和ws2的鏈接後,兩臺服務器上的鏈接又能正常通訊了。

額外實驗2. 添加節點

此次試的是在不重啓現有服務器集羣的前提下,向集羣中添加服務器。

爲此,咱們先在docker-compose.yml中添加一個服務

ws3:
    build: .                   
    hostname: ws3              
    ports:                     
      - 4003:4000

而後修改一下config/prod.exs,把新的節點加進去

config :kernel,
  sync_nodes_optional: [:"gossipy@ws1", :"gossipy@ws2", :"gossipy@ws3"],  #<---- 注意新加ws3
  sync_nodes_timeout: 10000

從新發布一下,並啓動ws3容器

$ MIX_ENV=prod mix release.clean
$ MIX_ENV=prod mix release --env=prod
$ docker-compose up --build ws3

用瀏覽器測試至關成功!新加的節點立刻就連上老節點, 老節點也馬上就認識新節點了。

結論

正如所料,Phoenix能夠在不改動一行代碼的狀況下作到WebSocket的集羣化。這就是Erlang/Elixir的特點之一——Location Transparency(位置透明)給咱們帶來的好處。單機運行代碼和分佈式運行代碼徹底同樣!只是要用好這個位置透明,在沒人手把手教你的狀況下,你會嘗試錯誤好幾回。

附錄1. 測試用HTML

<!doctype html>
<html> 
  <head>
    <meta charset="utf-8">
    <title>Phoenix Channel Demo</title>     
  </head>

  <body> 
    <pre id="messages"></pre>  

    <input id="shout-content"> 
  
    <script>
      window.onload = function () {   
        var wsPort = window.location.search.match(/\bport=(\d+)\b/)[1];
        var messageBox = document.getElementById('messages');
        var ws = new WebSocket('ws://localhost:' + wsPort + '/socket/websocket');
  
        ws.onopen = function () {       
          ws.send(JSON.stringify({        
            topic: 'room:1',
            event: 'phx_join',
            payload: {},
            ref: 0
          }));
        };

        ws.onmessage = function (event) {
          var data = JSON.parse(event.data);
          if (data.event !== 'shout') return;
          messageBox.innerHTML += data.payload.message + "\n"; 
        }

        document.getElementById('shout-content').onkeyup = function (event) {
          if (event.which !== 13) return; 
          if (!event.target.value) return;
          ws.send(JSON.stringify({        
            topic: "room:1",
            event: "shout",
            payload: {message: event.target.value},
            ref: 0
          }));
          event.target.value = '';
        };
      }
    </script>
  </body>
</html>

你能夠用任何手段host它,使得瀏覽器能經過HTTP訪問到它(用file://協議不行)。例如,你能夠把它存入文件ws.html,而後用python -m SimpleHTTPServer來啓動一個簡易HTTP服務(默認端口號8000),而後用瀏覽器訪問http://localhost:8000/ws.html?port=4001。這裏的port參數指定鏈接到哪一個WebSocket端口。

相關文章
相關標籤/搜索