.。oO(さっちゃんですよヾ(〃l _ l)ノ゙☆)

2017-04-01、日曜日の東京でElixir Conf Japan 2017が艷やかに執り行われました。

  1. Elixir Conf Japan 2017 参加レポート vol.1 #elixirjp
  2. Elixir Conf Japan 2017 参加レポート vol.2 #elixirjp

その時にLTしたことを増補したものが続きとなります。それでは戦ってゆきましょう。

HedwigでBotkitを倒す (ElixirでNode.jsを倒す)

Hedwigでchat-botを作る

Elixir (Erlang) は並行に動く、同時に多量の処理すべきことが起きても破綻しないシステムを簡単に作れると言われています。例えばchat botであれば、多くの発言がある部屋でも取りこぼしなく動き続けるchat botを簡単に作れると言われています。本当でしょうか。真実を求めてプロセス世界に深く踏み込んだ我々が見たものは、並行性と並列性の壮絶な戦いでした。

それではElixirとNode.jsのよく使われるフレームワークでchat botを作り、負荷をかけてゆきましょう。

Elixir側ではHedwigというフレームワークを使ってみました。名の由来は、ハリー・ポッターさんの梟の名前らしいですね(ヘドウィグ | Harry Potter Wiki | Fandom powered by Wikia)。

hedwig-im/hedwig

Hedwigのbotを構成するのはAdapter(あだぷたー)とResponder(れすぽんだー)という二種類の部品です。

Adapterはchatサービスと通信するモジュールです。chatサービスを抽象化し、共通するインターフェイスをResponderに提供するモジュールです。

Responderはbotのロジックを記述するモジュールです。扱えるchatメッセージがあればAdapterがResponderを呼び出しますので、処理をして、返信したり発言したり発言しなかったり返信しなかったりします。

Botkitでchat-botを作る

並列性の側はNode.jsに受け持ってもらいます。Botkitというフレームワークを選びました。

Botkit

オブジェクト指向です。

// @flow
import SomeBot from './botkit/SomeBot'

const controller = SomeBot({debug: false, stats_optout: true})
controller.spawn()
controller.hears(
    ['ping'],
    'message_received',
    (bot, message) => bot.reply(message, 'pong')
)

そんな感じです。

以上です。

ベンチマークの構成

cf. node.js vs Erlang (ネタ) – kuenishi’s blog

Elixir(Hedwig)とNode.js(Botkit)とでping-pong botを作ります。botはHTTPサーバーで待ち受けます。そのサーバーに、POST /でpingという文字列を投げると、pongという文字列を返します。

Hedwigのadapterを作る

Hedwigに用意されているAdapterは、Slack、XMPP、コンソールだけです。HTTPサーバーとやり取りするAdapterは無いので、作ります。

まずHTTPサーバーを立てます。ninenines/cowboyをアプリケーションに登録します。cowboyを起動し、HTTPリクエストを受けるモジュールを書きます。

defmodule HedwigDemo.HttpHandler do
    def start do
        dispatch = :cowboy_router.compile([
            {
                :_,
                [{"/", __MODULE__, []}]
            }
        ])
        {:ok, _} = :cowboy.start_http(
            :hedwig_demo,
            1000,
            [port: 3000, max_connections: 2048],
            [env: [dispatch: dispatch]]
        )
    end

    def init({:tcp, :http}, req, opts), do: {:ok, req, opts}

    def handle(req, state) do
        {:ok, body, req} = :cowboy_req.body req
        HedwigDemo.Adapters.Http.message self(), body
        {:ok, req} =
        receive do
            msg ->
                :cowboy_req.reply 200, [{"content-type", "text/plain"}], msg, req
            after 500 ->
                :cowboy_req.reply 400, [{"content-type", "text/plain"}], "Bad Request", req
        end
        {:ok, req, state}
    end

    def terminate(_reason, _req, _state), do: :ok
end

リクエストが来たらhandle/2関数が呼ばれ、そこからHedwigDemo.Adapters.Http.message/2関数を呼びます。このHttpHandlerモジュールをアプリケーションにぶら下げます。

defmodule HedwigDemo.Application do
    use Application

    def start(_type, _args) do
        import Supervisor.Spec, warn: false
        children = [
            worker(HedwigDemo.Robot, []),
            worker(HedwigDemo.HttpHandler, [], function: :start), # ← これ
        ]
        opts = [strategy: :one_for_one, name: HedwigDemo.Supervisor]
        Supervisor.start_link(children, opts)
    end
end

# ←これです。

HttpHandlerから呼ぶmessage/2を実装した、HedwigのAdapterを作ります。

defmodule HedwigDemo.Adapters.Http do
    use Hedwig.Adapter

    def init({robot, opts}) do
        GenServer.cast self(), :after_init
        {:ok, %{robot: robot, opts: opts}}
    end

    @spec message(pid, binary) :: :ok
    def message(req, msg) do
        GenServer.cast __MODULE__, {:message, req, %{text: msg}}
    end

    def handle_cast(:after_init, %{robot: robot} = state) do
        Process.register self(), __MODULE__
        :ok = Hedwig.Robot.handle_connect robot
        {:noreply, state}
    end

    def handle_cast({:message, req, %{text: text}}, %{robot: robot} = state) do
        Hedwig.Robot.handle_in robot, %Hedwig.Message{
            ref: make_ref(),
            robot: robot,
            room: :erlang.term_to_binary(req),
            text: text,
            user: %Hedwig.User{}
        }
        {:noreply, state}
    end

    def handle_cast({:send, %{room: room, text: text}}, state) do
        Kernel.send :erlang.binary_to_term(room), text
        {:noreply, state}
    end

    def handle_cast({:reply, msg}, state) do
        GenServer.cast self(), {:send, msg}
        {:noreply, state}
    end

    def handle_cast({:emote, msg}, state) do
        GenServer.cast self(), {:send, msg}
        {:noreply, state}
    end
end

use Hedwig.Adapter、init/1、handle_cast({:send,〜/2、handle_cast({:reply,〜/2、handle_cast({:emote,〜/2、が必要です。

Hedwigで作ったbotのソースコードは https://github.com/ne-sachirou/exconfjp2017/tree/802b55120e00217399115d7ddaeedc622190b007/hedwig/lib にあります。

Botkitのadapterを作る

BotkitでもHTTPを受けられるようにしましょう。HTTPサーバーはNode.jsに組み込まれています。

// @flow
import http from 'http'

function listen (botkit, bot) {
    botkit.startTicking()
    const server = http.createServer((req, res) => {
        req.setEncoding('utf8')
        var data = ''
        req.on('data', chunk => { data += chunk })
        req.on('end', () => {
            const message = {
                text: data,
                user: 'user',
                channel: 'http',
                timestamp: Date.now(),
                response: res
            }
            botkit.receiveMessage(bot, message)
        })
    })
    server.on('clientError', (err, socket) => socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'))
    server.listen(3000)
}

HTTPリクエストが来たらを受けたらbotkit.receiveMessage()を呼びます。Botkitのコントローラーを作って、listen()を呼びます。

// @flow
import Botkit from 'botkit'

export default function HttpBot (config: Object = {}) {
    const botkit = Botkit.core(config)
    botkit.middleware.spawn.use((bot, next) => {
        listen(botkit, bot)
        next()
    })
    botkit.defineBot((botkit, config) => new Bot(botkit, config))
    return botkit
}

Botkitで作ったbotのソースコードは https://github.com/ne-sachirou/exconfjp2017/tree/802b55120e00217399115d7ddaeedc622190b007/botkit/src にあります。なんかそんな感じです。

ベンチマーク

クライアントは別途Crystalで作りましょう。1〜1,000のTCPコネクションを維持しつつ、pongが返ってきたらすぐにpingを投げ、繰り返します。サーバーがエラーを返すかリクエストがタイムアウト(1秒)したら、TCPコネクションを切断し張り直します。そして成功数、失敗数、成功した時のレスポンス時間平均を記録します。

1、10、50、100、500、1,000並列で、1秒、15秒、30秒pingを投げ続けます。

クライアントのソースコードは https://github.com/ne-sachirou/exconfjp2017/tree/802b55120e00217399115d7ddaeedc622190b007/pressure/src にあります。

それでは負荷をかけます。

比較

Elixir+cowboy+Hedwigと、Node.js+Botkitの差を見てゆきましょう。ベンチマークを実行すると、以下のような結果が表示されます。-tが秒数、-cがコネクション数です。

bin/pressure botkit -t 30 -c 1
r:21677 w:21677 e:0 t:0.024ms
bin/pressure hedwig -t 30 -c 1
r:21513 w:21513 e:0 t:0.051ms
bin/pressure botkit -t 30 -c 10
r:218248 w:218248 e:0 t:0.29ms
bin/pressure hedwig -t 30 -c 10
r:195060 w:195060 e:0 t:0.352ms
bin/pressure botkit -t 30 -c 50
r:287955 w:287955 e:0 t:4.158ms
bin/pressure hedwig -t 30 -c 50
r:362055 w:362055 e:0 t:2.756ms
bin/pressure botkit -t 30 -c 100
r:308443 w:308443 e:0 t:8.648ms
bin/pressure hedwig -t 30 -c 100
r:362620 w:362645 e:25 t:5.671ms
bin/pressure botkit -t 30 -c 500
r:308016 w:308016 e:0 t:46.885ms
bin/pressure hedwig -t 30 -c 500
r:298709 w:298709 e:0 t:36.301ms
bin/pressure botkit -t 30 -c 1000
r:320299 w:320299 e:0 t:91.25ms
bin/pressure hedwig -t 30 -c 1000
r:328230 w:328230 e:0 t:82.584ms

ベンチマークの結果は https://github.com/ne-sachirou/exconfjp2017/tree/802b55120e00217399115d7ddaeedc622190b007/log にあります。これをグラフにします。

まずはスループット(成功回数/1秒)を比較しましょう。

Node.js+Botkitは10並列まではスループットが上がっています。しかしそこから大きくスループットを落としています。Elixir+cowboy+Hedwigはスループットはよりゆるやかに下がっています。

レスポンス時間はよりわかりやすく変化しています。並列数を増やすとElixirもNode.jsもレスポンス時間が延びていますが、Node.jsのほうがわずかづつ遅くなっています。1秒だけ負荷をかけたグラフと、15秒や30秒負荷をかけ続けたグラフをグラフを比べると、Node.jsではV8のJITが効いたのでしょうか、Elixirの速度に近付いているのが見えます。

またグラフには出ていませんが、論理4コアのCPUで計測し、1コアの使用率を100%とした時、Node.jsは95%ほどしか使えていませんでしたが、Elixirは230%ほどまで伸び、複数のコアに分散できていたことが見えます。

もうひとつグラフには出ていないことがあります。負荷をかけてみるとElixir+cowboy+HedwigでもBotkitでもたまにエラーを返すことがあります。Elixir+cowboy+Hedwigではあるリクエストがエラーを返しても、次のリクエストは正常にレスポンスします。しかしNode.js+Botkitに負荷をかけてエラーが発生すると、数秒間なにも反応しなくなりエラーを返し続け、数秒後に回復します。

発表資料

Elixir Confで発表したスライド(を増補したもの)がこれです。

ElixirでNode.jsを倒す // Speaker Deck