Phoenix Web应用在Docker Swarm上的部署

Phoenix web应用和其余语言/框架实现的web应用相比最大的不一样点在于,Phoenix应用能在有状态的状况下依然保持很好的横向扩展能力,这得益于其底层的Erlang OTP支持。为了能在集群中的各节点之间共享状态,各节点只需相互认识便可,并不须要单独开一个状态容器(如Redis),这也使得Phoenix应用的架构更为简单明了。Elixir 1.9更加入了对release的支持,也使得打包部署更加方便。可是,这些都仅限于传统的、预先知道集群容量和各节点IP的部署方式。如何能在Docker Swarm上,在不预先知道各节点IP的状况下部署Phoenix应用并作到动态扩容成了下一个挑战。今天就来尝试部署一下最典型的有状态web应用——基于WebSocket的聊天室。node

写一个聊天室应用chitchat

由于不是重点因此不写了。若是你不会写,直接去GitHub上拉代码python

Release准备

这里只作最简单的准备。git

$ mix release.init

咱们还须要在config/prod.secret.exs里加一行代码让咱们release出来的包(artifact)知道要启动全部相关的application。github

config :chitchat, ChitchatWeb.Endpoint, server: true

建立Docker镜像

在项目的根目录下建立Dockerfile,并加入如下内容:web

FROM elixir:1.9.1-alpine as build

# install build dependencies
RUN apk add --update git build-base nodejs npm yarn python

# prepare build dir
RUN mkdir /app
WORKDIR /app

# install hex + rebar
RUN mix local.hex --force && \
    mix local.rebar --force

# set build ENV
ENV MIX_ENV=prod

# install mix dependencies
COPY mix.exs mix.lock ./
COPY config config
RUN mix deps.get
RUN mix deps.compile

# build assets
COPY assets assets
RUN cd assets && npm install && npm run deploy
RUN mix phx.digest

# build project
COPY priv priv
COPY lib lib
RUN mix compile

# build release
COPY rel rel
RUN mix release

# prepare release image
FROM alpine:3.9 AS app
RUN apk add --update bash openssl

RUN mkdir /app
WORKDIR /app

COPY --from=build /app/_build/prod/rel/chitchat ./
RUN chown -R nobody: /app
USER nobody

ENV HOME=/app

这是从Phoenix官方文档里直接复制过来的,除了改了一下应用名称和在apk add里添加了npm外什么都没改。docker

这是一个multi-stage的Dockerfile,为了使最终生成的镜像尽量小,咱们把Elixir、Mix、node.js等运行时不须要的东西全都留在了build阶段的镜像里,只把最终release出来的东西(包含Erlang运行时)放进了最终镜像。我这里构建出来的docker镜像约35MB。虽然如今已经能构建了,但我暂时不构建。数据库

建立Swarm

为了图方便,我只作了单节点swarm:npm

$ docker swarm init

若是你手上有3台以上的电脑,你也能够作全尺寸swarm。这不是重点因此略过。若是你不知道怎么作,参考官方教程浏览器

本地化Docker Registry

因为部署到swarm集群里的服务必须使用预先构建好的镜像(若是每一个节点各自构建镜像又慢又耗资源),而实际生产环境下每一个镜像可能会很大(上G),因此咱们须要一个在内网里的Docker Registry来注册并在各个节点上共享镜像。bash

在任意manager节点上运行

$ docker service create --name registry -p 5000:5000 registry:2

这一句会在你的swarm里建立一个名为registry的服务,用的镜像是Docker官方的registry:2,公开5000端口。它只有一个replica。

构建镜像并推上Registry

运行命令

$ docker build --tag 127.0.0.1:5000/chitchat:0.1.0 .

便可构建出镜像。版本号最好和mix.exs里的保持一致。注意,Docker Registry貌似不会覆盖已有镜像(待考证),因此版本号最好不要用latest。127.0.0.1:5000是registry的IP地址和端口号,根据你的swarm的实际状况改之。构建完后运行命令

$ docker push 127.0.0.1:5000/chitchat:0.1.0

就能将这个镜像推到本地的registry上了。

docker-compose.yml

在项目的根目录下建立docker-compose.yml,并添加下列内容:

version: '3.7'

services:
  app:
    image: 127.0.0.1:5000/chitchat:0.1.0
    ports:
      - 80:4000
    entrypoint: ./bin/chitchat start
    deploy:
      mode: replicated
      replicas: 3

除了deploy项以外,这能够算是最简单的docker-compose配置文件了。先跑跑看

$ docker-compose up

它应该能直接跑起来(虽然会有警告说deploy项无效),访问80端口、链接ws应该都没问题。

接着咱们尝试部署到swarm上(单节点的同窗记得把刚才的试运行关掉哦):

$ docker stack  deploy -c ./docker-compose.yml chitchat

确认服务都起来了

$ docker stack services chitchat

应该看到以下内容:

ID                  NAME                MODE                REPLICAS            IMAGE                           PORTS
x2eym27lc2b8        chitchat_app        replicated          3/3                 127.0.0.1:5000/chitchat:0.1.0   *:80->4000/tcp

若是看到REPLICAS是3/3,说明部署成功,若是一直是0/3,则检查你的代码有没有问题。

为了接下来的调试,先跟踪一下日志:

$ docker service logs -f chitchat_app

而后打开两个浏览器窗口/标签,访问一下 http://127.0.0.1/rooms/1 ,看一下日志确保ws链接到了不一样的replica上,若是连在了同一个上面,则刷新其中一个窗口,直到它连到了不一样的replica为止。发一条消息试试,你会发现 另外一个窗口收不到消息!

问题出在哪儿了?问题出在各个节点上的epmd(Erlang Process Manager)各自为政,没有链接到一块儿。因此下一步就是想办法把它们连起来。

咱们知道Elixir有一个函数Node.connect/1能够链接到其余节点,只要它们有相同的cookie。问题在于,这种链接方式须要预先知道对方的IP或域名或主机名。可是在一个容器编排系统(container orchestration system)里,容器的IP、域名和主机名都是动态分配的,尤为是在容器宕掉重启后,它的IP、域名和主机名极可能会改变。在这种动态的集群里,怎么才能让容器找到本身的兄弟呢?

思路是利用Docker的基于DNS的服务发现机制。在Docker Swarm里,每一个服务都带有一个服务发现用的域名,它是tasks.<服务名>,在咱们的这套配置里,它是tasks.chitchat_app。若是你在任意一个replica容器里运行nslookup tasks.chitchat_app,你会看到全部replica的IP地址。有了IP地址,接下来只要知道节点的基本名称(节点名称@前面的部分)就好了。这个名称很容易找,由于Elixir的release启动时,环境变量$RELEASE_NAME已经设好了这个名称。

看起来不错,先试一下。让咱们先登上1号容器(把那个xxx换成实际值,其实只须要敲Tab就好了):

$ docker exec -it chitchat_app.1.xxx sh

得到其余容器的IP地址:

$ nslookup tasks.chitchat_app

而后attach到正在运行的chitchat进程,并尝试链接其余节点(假定它的IP是10.0.0.3):

$ ./bin/chitchat remote
iex> Node.connect(:"chitchat@10.0.0.3")

你会发现连不上。问题在哪儿?看看当前节点的名称是啥:

iex> Node.self()
:"nonode@nohost"

问题就在这儿。咱们的节点没有名称!为了让每一个节点有本身的名称,咱们须要修改rel/env.sh.eex。

修改rel/env.sh.eex

放开下面两行:

export RELEASE_DISTRIBUTION=name
export RELEASE_NODE="<%= @release.name %>@127.0.0.1"

这个文件用于生成env.sh,而env.sh会在每次应用启动的时候运行,用来设置环境变量。

还有一个问题,怎么把127.0.0.1替换成真正的容器的IP?若是你在某个容器里运行hostname -i,你会获得当前的IP(比较有意思的是,若是你在本身的PC上运行这句命令,你只能拿到127.0.1.1)。因此咱们只要把RELEASE_NODE那一行改为

export RELEASE_NODE="<%= @release.name %>@$(hostname -i)"

就一切OK了。顺带一提,rel/env.bat.eex能够不改,由于咱们的容器跑的不是Windows而是Alpine Linux。

从新部署一下,再尝试一下链接其余节点,能够看到此次就能连上了。

下一个问题就是怎么让它自动连,并且周期性地反复连。这里我用了一个第三方库Peerage。

集成Peerage

安装方式请自行看官网。我只将个人配置贴出来:

# config/prod.exs
config :peerage, via: Peerage.Via.Dns,
  dns_name: "tasks.chitchat_app",
  app_name: {:system, "RELEASE_NAME"}

这里的dns_name就是Peerage去访问的DNS域名。而app_name则是节点名称@前面的部分。{:system, "RELEASE_NAME"} 告诉Peerage这个名称要去环境变量$RELEASE_NAME里找。Peerage会周期性地访问DNS获取IP,并在每一个IP前面加上<app_name>@,而后尝试链接这些节点。

从新部署一下,而后在某个replica上运行

$ ./bin/chitchat rpc "IO.inspect Node.list"

你会看到其余节点的名称,这代表全部节点都已连上了。

你还能够尝试扩张/缩水当前的服务(参考docker service scale),杀掉某个容器(docker kill)等操做,看看行为是否和预期同样。

至此,整个实验成功结束。其他的问题(如何部署数据库、如何作全局惟一进程等)留待下次再作实验。

相关文章
相关标签/搜索