摘要: 对于无状态的web服务,要做分布式部署相对比较简单,很多时候只要架一个反向代理就行。但是对于有状态的web服务,尤其是包含WebSocket成分的web应用,要做分布式部署一直是一个麻烦。传统做法是搞一个中间层,例如Redis之类的做pubsub,但是这样做就不得不改动源码。Erlang/Elixir这种“面向并发编程”的语言在这方面会不会高人一筹?Phoenix框架是否已经支持WebSocket的横向扩展了呢?下面我们就来做个实验。
Phoenix + WebSocket分布式部署实验
前言
对于无状态的web服务,要做分布式部署相对比较简单,很多时候只要架一个反向代理就行。但是对于有状态的web服务,尤其是包含WebSocket成分的web应用,要做分布式部署一直是一个麻烦。传统做法是搞一个中间层,例如Redis之类的做pubsub,但是这样做就不得不改动源码。同时,系统复杂度也随之增加,运维的成本也相应提高。Erlang/Elixir这种“面向并发编程”的语言在这方面会不会高人一筹?Phoenix框架是否已经支持WebSocket的横向扩展了呢?下面我们就来做个实验。
资源
你可以去https://gitee.com/aetherus/gossipy下载本文涉及的源码。
目标
不添加其他服务(如Redis、RabbitMQ等),不改动项目源码,仅通过添加/修改配置文件来达到WebSocket服务的横向扩展。
实验器材
Ubuntu 16.04或其衍生发行版(我用的是Elementary OS Loki)
Docker
Docker compose
Elixir开发/运行环境
一个最基本的Phoenix聊天室,不含数据库,不含assets,不含brunch。
安装发布工具
Elixir社区目前比较推荐的发布工具是Distillery(蒸馏器),这次实验就用它。
安装只需要在项目根目录的mix.exs
里添加如下内容就行
defp deps do [ {:distillery, "~> 1.5", runtime: false} #<--- 这一行 ]end
这里的runtime: false
表示distillery不会在web应用中用到,只在发布的时候用一下。
添加完后只需mix deps.get
一下就行。
发布配置
首先让distillery生成一些最基本的发布配置:
$ mix release.init
你会看到项目根目录下多了个rel
目录,里面只有一个空的plugins
目录和一个config.exs
文件。这个文件的配置用来发布到单台服务器已经足够了,但是要做集群还是不太够,因为我们要让各台服务器上的Phoenix应用能连起来相互通信。为此,我们需要给每个运行的实例一个名称(name
或sname
)。
为了达到这个目的,我们需要一个vm.args
文件。这个文件记录了Erlang启动虚拟机时所需的命令行参数。但是这个文件长啥样?我们现release一个,让它自动生成一个vm.args
文件再说。
$ 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"endenvironment :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"endrelease :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
选项。
所有配置都准备好后,先清除掉上次构建的发布版,再重新构建一次:
$ MIX_ENV=prod mix release.clean$ MIX_ENV=prod mix release --env=prod
然后就可以准备部署了
创建Docker镜像
既然是部署到Docker,就要先创建一份Dockerfile,内容如下:
FROM ubuntu:xenial EXPOSE 4000ENV PORT=4000RUN 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端口。
作者:Aetherus
来源:https://my.oschina.net/u/3390582/blog/1631370
共同学习,写下你的评论
评论加载中...
作者其他优质文章