LiveViewでElixirを評価する(2)：Dockerを使ってElixirの実行をサンドボックス化する


[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上
# 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>
```
```:docker上
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とします。
## サーバのコード
サーバの待ち受け側のコードです。

```elixir:server.exs
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のコード
```elixir:eval.exs
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も作っておきましょう。

```dockerfile: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のイメージを作っておきます。

```:client
# docker build -t client .
```
これでclientというイメージができました。

次にサーバを動かしておきます、新しくターミナルを立てて

```:server
# iex --name server@host.docker.internal --cookie eval ーS server.exs
```
クライアント用のターミナルに戻ってclientを起動すると、通信できているのが分かります。

```:client
# docker run -it client
> Hello, I'm Docker
```
```:server
> client is ready
```

## ServerからClientを立ち上げる
通信はできたので、今度はServerからClinetのコンテナを立ち上げてみましょう。
とりあえず荒っぽくいきましょう。さっきclient側で打ち込んでいたコマンドをSystem.cmdで叩きます。立ち上げまで時間が掛かるので、spawnで別プロセスにしてます。（ちゃんとやるなら、プロセスハンドルをちゃんと保持して、エラー処理やタイムアウトを設けて適切に管理しましょう。）

```elixir:server.exs
  def init(init) do
    :global.register_name(:server, self())
    spawn(fn -> System.cmd("docker", ["run", "client"]) end) # <-- 追加
    {:ok, init}
  end
```
さて、再実行なのですが、クライアントのIO.putsが見えないですね。

```:server
# iex --name server@host.docker.internal --cookie eval ーS server.exs
> client is ready
```
もう少し会話させましょう。

```elixir:server.exs
  # Server モジュールに以下追加
  def handle_call({:hello, name}, _from, client_pid) do
    IO.puts("Hello " <> name <> ", I'm server")
    {:reply, client_pid, client_pid}
  end
```
```elixir:eval.exs
  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の環境に組み入れていきます。

(組合せるとあまりうまく動かなくなってしまったため、次回少し遅れるかもしれません。)