對於無狀態的web服務,要作分佈式部署相對比較簡單,不少時候只要架一個反向代理就行。可是對於有狀態的web服務,尤爲是包含WebSocket成分的web應用,要作分佈式部署一直是一個麻煩。傳統作法是搞一箇中間層,例如Redis之類的作pubsub,可是這樣作就不得不改動源碼。同時,系統複雜度也隨之增長,運維的成本也相應提升。Erlang/Elixir這種「面向併發編程」的語言在這方面會不會高人一籌?Phoenix框架是否已經支持WebSocket的橫向擴展了呢?下面咱們就來作個實驗。html
你能夠去https://gitee.com/aetherus/gossipy下載本文涉及的源碼。node
不添加其餘服務(如Redis、RabbitMQ等),不改動項目源碼,僅經過添加/修改配置文件來達到WebSocket服務的橫向擴展。python
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應用能連起來相互通訊。爲此,咱們須要給每一個運行的實例一個名稱(name
或sname
)。編程
爲了達到這個目的,咱們須要一個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
name
和sname
的區別很少說了。由於到時候咱們要部署到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,就要先建立一份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端口,加入同一個房間,而後你就能看見它們能對話了!
先殺掉ws2那個容器(端口4002)
$ docker-compose kill ws2
結果固然是連在ws2上的WebSocket鏈接所有斷開,而ws1上的鏈接依然正常工做。ws2的鏈接中斷很正常。在實際項目中,咱們不會把一個web服務分在多個端口號上,而是公用一個源(協議 + 域名 + 端口),這樣只要客戶端實現了合理的重連機制,很快就能和別的活着的服務器創建鏈接。
而後咱們再把ws2從新啓動起來
$ docker-compose start ws2
從新創建和ws2的鏈接後,兩臺服務器上的鏈接又能正常通訊了。
此次試的是在不重啓現有服務器集羣的前提下,向集羣中添加服務器。
爲此,咱們先在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(位置透明)給咱們帶來的好處。單機運行代碼和分佈式運行代碼徹底同樣!只是要用好這個位置透明,在沒人手把手教你的狀況下,你會嘗試錯誤好幾回。
<!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端口。