LiveViewでElixirを評価する(1)の続きです。
前回までのあらすじ
LiveviewでElixirを実行時評価すると楽しい。が、eval_stringは自由に書かれてしまうと厄介ななシロモノなので、できるだけ安全に実行する方法を模索します。
サンドボックス化
なぜ悪いことをしたいのか?悪いことをしてメリットがあるからやる訳ですね。
Elixir環境から抜け出しても何もない環境なら、無駄な労力を割く人はそうそう居ないでしょう。
という訳で、コードの評価はelixir-alpineなDockerコンテナ上で行いましょう。
Docker上でElixirを動かす
dockerは予め入れといてください。
# docker run -it elixir:1.9.4-alpine iex
iex(1)>
動きますね。コマンド一発で環境の整ったVMが手に入るなんて魔法のようですね。
Docker上のElixirと話す
Node.connect()で他のVM上のノードと話せるようです。やってみましょう。別ホストのVMと話す場合はVM毎に名前を付けつつ、Cookieとやらも指定しておく必要があるそうです。
あと、コンテナから見たホストのアドレスはhost.docker.internalで解決できるそうです。ちなみにアドレス指定でアクセスする場合、--sname でなく--nameで起動していないと怒られるっぽいです。
# docker run -it elixir:1.9.4-alpine iex --name e1 --cookie eval
iex(e1@d523eb311dbb.localdomain)1>
# iex --name server@host.docker.internal --cookie eval
iex(server@host.docker.internal)1>
iex(e1@d523eb311dbb.localdomain)1> Node.ping(:"server@host.docker.internal")
:pong
iex(server@host.docker.internal)1> Node.ping(:"e1@d523eb311dbb.localdomain")
:pong
話せてますね。実はこれ逆にするとうまくいきません。Host -> Dockerでは名前解決できません(ちゃんと名前つけてないので当然ですが)。
ですが、一度目のDocker -> HostにPingを投げた時にNode.connectが張られてるっぽくて一度話した相手のドメイン名は解決できるようになります。
という訳で、コンテナが立ち上がったらホストに繋ぎに行くようにしましょう。
以降、Dockerの上のコードをClient、ホスト側をServerとします。
サーバのコード
サーバの待ち受け側のコードです。
defmodule Server do
use GenServer
def init(init) do
:global.register_name(:server, self())
{:ok, init}
end
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def handle_call({:ready, client_name}, _from, state) do
:global.sync()
client_pid = :global.whereis_name(String.to_atom(client_name))
GenServer.cast(client_pid, :hello)
{:reply, client_pid, state}
end
end
Server.start_link()
サーバは単なるGenServerでcast :readyを待ち、:helloを投げ返します。
init時に自身のPIDをglobalに:serverとして登録しています。
Clientのコード
defmodule Client do
use GenServer
def init({name, server_name}) do
:global.register_name(String.to_atom(name), self())
Node.connect(String.to_atom(server_name))
:global.sync()
server_pid = :global.whereis_name(:server)
GenServer.call(server_pid, {:ready, name})
{:ok, server_pid}
end
def start_link(name, server_name) do
GenServer.start_link(__MODULE__, {name, server_name}, name: __MODULE__)
end
def handle_cast(:hello, server_pid) do
IO.puts("Hello, I'm Docker")
{:noreply, server_pid}
end
end
Client.start_link("client", "server@host.docker.internal")
GenServerのinit時に自身の名前の登録と:serverのpidの問い合わせをして、:readyを投げます。castで:helloを受け取ると返事します。
ちょっとお行儀が悪いですが、スクリプト的に動かしています。また、initの段階ではまだGenServerの準備が整っていなさそうなので、init内であれこれメッセージを投げるのもよろしくないです。(上記コードをcallにして、serverからcallされてもメッセージが届きません。)
ついでにDockerfileも作っておきましょう。
FROM elixir:1.9.4-alpine
RUN mkdir /home/eval/
WORKDIR /home/eval/
COPY client.exs /home/eval/client.exs
CMD elixir --name client@host.docker.internal --cookie eval client.exs
実行
まずdockerのイメージを作っておきます。
# docker build -t client .
これでclientというイメージができました。
次にサーバを動かしておきます、新しくターミナルを立てて
# iex --name server@host.docker.internal --cookie eval ーS server.exs
クライアント用のターミナルに戻ってclientを起動すると、通信できているのが分かります。
# docker run -it client
> Hello, I'm Docker
> client is ready
ServerからClientを立ち上げる
通信はできたので、今度はServerからClinetのコンテナを立ち上げてみましょう。
とりあえず荒っぽくいきましょう。さっきclient側で打ち込んでいたコマンドをSystem.cmdで叩きます。立ち上げまで時間が掛かるので、spawnで別プロセスにしてます。(ちゃんとやるなら、プロセスハンドルをちゃんと保持して、エラー処理やタイムアウトを設けて適切に管理しましょう。)
def init(init) do
:global.register_name(:server, self())
spawn(fn -> System.cmd("docker", ["run", "client"]) end) # <-- 追加
{:ok, init}
end
さて、再実行なのですが、クライアントのIO.putsが見えないですね。
# iex --name server@host.docker.internal --cookie eval ーS server.exs
> client is ready
もう少し会話させましょう。
# Server モジュールに以下追加
def handle_call({:hello, name}, _from, client_pid) do
IO.puts("Hello " <> name <> ", I'm server")
{:reply, client_pid, client_pid}
end
def handle_cast(:hello, server_pid) do
GenServer.call(server_pid, {:hello, "client"}) # <- 変更
{:noreply, server_pid}
end
今回は、Server->Clientは投げっぱなし(cast), Client→Serverはブロッキング(call)としています。Serverは止めたくないけど、Client側は待ってもいいよねという指針です。
次回
ElixirからDockerのコンテナ上のElixirを立ち上げて通信できました。
次は、前回作ったLiveEvalの環境に組み入れていきます。
(組合せるとあまりうまく動かなくなってしまったため、次回少し遅れるかもしれません。)