为了账号安全,请及时绑定邮箱和手机立即绑定

Phoenix + WebSocket分布式部署验证

标签:
架构

摘要: 对于无状态的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应用能连起来相互通信。为此,我们需要给每个运行的实例一个名称(namesname)。

为了达到这个目的,我们需要一个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

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选项。

所有配置都准备好后,先清除掉上次构建的发布版,再重新构建一次:

$ 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


点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消