elixir + rustで作ったアプリをdockerで動かす

こんばんは、今年は受験生なのでクリスマスでも勉強していたtechnoです。というか試験が年明けにあるので大学入ってからクリスマスとか正月なんてものはなくなっています。来年からは多分この状況から抜け出せると思うのですが。

前置きはともかく、elixirでアプリを作っていたのですが、httpoisonやhttpotionが特定のケースで変な挙動を起こして使うことができなかったため、rustlerを使ってrustのreqwestを使った時のノウハウです。

プロジェクトのコードは[こちら](https://github.com/techno-tanoC/rustler_docker)にあります。

# rustlerのセットアップ
mixでelixirのプロジェクトを作ったら(今回は"rustler_docker"という名前にしました)早速mix.exsを編集していきます。

```mix.exs
defmodule RustlerDocker.MixProject do
  use Mix.Project

  def project do
    [
      app: :rustler_docker, # ここのアプリ名は後で使う
      version: "0.1.0",
      elixir: "~> 1.7",
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end

  # Run "mix help compile.app" to learn about applications.
  def application do
    [
      extra_applications: [:logger]
    ]
  end

  # Run "mix help deps" to learn about dependencies.
  defp deps do
    [
      {:rustler, "~> 0.18.0"} # 追加
    ]
  end
end
```

deps.getしてからrustライブラリを生成します。

```sh
$ mix deps.get
$ mix rustler.new
This is the name of the Elixir module the NIF module will be registered to.
Module name > Client
This is the name used for the generated Rust crate. The default is most likely fine.
Library name (client) > client
```

この時のモジュールの名前は主にelixir側のモジュールの名前になります。
ライブラリの名前は主にrust側の名前になります。

この時点でrust側のサンプル実装は終わっているのでElixir側のコードを書いていきます。まずはmix.exsの変更

```mix.exs
defmodule RustlerDocker.MixProject do
  use Mix.Project

  def project do
    [
      app: :rustler_docker,
      version: "0.1.0",
      elixir: "~> 1.7",
      compilers: [:rustler] ++ Mix.compilers, # 変更
      rustler_crates: rustler_crates(), # 追加
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end

  # Run "mix help compile.app" to learn about applications.
  def application do
    [
      extra_applications: [:logger]
    ]
  end

  # Run "mix help deps" to learn about dependencies.
  defp deps do
    [
      {:rustler, "~> 0.18.0"}
    ]
  end

  # 追加
  defp rustler_crates() do
    [
      # rustライブラリの名前
      client: [
        path: "native/client", # native/#{rustライブラリの名前}
        mode: (if Mix.env == :prod, do: :release, else: :debug)
      ]
    ]
  end
end
```

```lib/client.ex
# mix rustler.newで入力したモジュールの名前
defmodule Client do
  # opt_appはmix.exsのproject.appの名前
  # crateはrustler.newで入力したライブラリの名前
  use Rustler, otp_app: :rustler_docker, crate: :client

  def add(_a, _b), do: exit(:nif_not_loaded)
end
```

`add`という関数は生成されたrustライブラリに定義してある関数を示しています。

この時点でiexからrustの`add`を呼び出せるはずです。

```elixir
iex(1)> Client.add(1, 3)
{:ok, 4}
```

これでrustlerが使えるようになりました。

# rustでやりたいことを書く

冒頭で書いた通り、rustでhttpリクエストを行うコードを書きます。

Cargo.tomlにreqwestの依存を追加して、ソースを変更します。

```rust
#[macro_use] extern crate rustler;
#[macro_use] extern crate rustler_codegen;
#[macro_use] extern crate lazy_static;
extern crate reqwest; // 追加

use rustler::{Env, Term, NifResult, Encoder};

mod atoms {
    rustler_atoms! {
        atom ok;
        atom error; // コメントイン
        //atom __true__ = "true";
        //atom __false__ = "false";
    }
}

rustler_export_nifs! {
    "Elixir.Client",
    [
        ("get_body", 1, get_body) // 変更
    ],
    None
}

// 追加
fn get_body<'a>(env: Env<'a>, args: &[Term<'a>]) -> NifResult<Term<'a>> {
    let url: String = try!(args[0].decode());

    match reqwest::get(&url) {
        Ok(mut res) => {
            let body = res.text().unwrap();
            Ok((atoms::ok(), body).encode(env))
        },
        Err(err) => {
            Ok((atoms::error(), err.to_string()).encode(env))
        }
    }
}
```

`reqwest::get`を呼んで結果を返しているだけですね。(簡単のために`unwrap`を使っているのであまり良くない)

rust側の実装が終わったらelixir側も変更します。

```lib/client.ex
defmodule Client do
  use Rustler, otp_app: :rustler_docker, crate: :client

  def get_body(_url), do: exit(:nif_not_loaded)
end
```

後はこの`Client`モジュールを使っていろいろすれば良いですね。

# dockerで動かす。

以上のプロジェクトをdockerで動かします。

elixir:alpineを使いたかったのですが、

- reqwestが使っているencoding_rsが比較的新しいrustcを要求している
- alpineでは新しいrustcはedgeを使わないといけない
- elixir:alpineはedgeではない
- alpine:edgeにelixirを入れるのは面倒そう

という問題があり、良い方法が思いつかなかったのでelixir:slimの上で動かしました。
alpine3.8にedgeのrustc, cargoを入れられれば解決しそうですが、今回は見送ります(追記: できました)。

rustライブラリのビルドに必要なものを入れるとイメージが肥大するので、最初のステージでrustライブラリのビルドだけをして、それをelixirプロジェクトに載せる方針でいきます。しかし、rustlerは実行時にrustプロジェクトのコンパイルをしようとするためそのままではうまく行きません。mix.exsに細工をして環境変数によってrustライブラリをコンパイルするか指定します。

```mix.exs
defmodule RustlerDocker.MixProject do
  use Mix.Project

  def project do
    [
      app: :rustler_docker,
      version: "0.1.0",
      elixir: "~> 1.7",
      compilers: compilers(), # 変更
      rustler_crates: rustler_crates(),
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end

  # Run "mix help compile.app" to learn about applications.
  def application do
    [
      extra_applications: [:logger]
    ]
  end

  # Run "mix help deps" to learn about dependencies.
  defp deps do
    [
      {:rustler, "~> 0.18.0"}
    ]
  end

  # 追加
  defp compilers do
    if System.get_env("NO_RUSTLER_COMPILE") do
      Mix.compilers
    else
      [:rustler] ++ Mix.compilers
    end
  end

  defp rustler_crates() do
    [
      client: [
        path: "native/client",
        mode: (if Mix.env == :prod, do: :release, else: :debug)
      ]
    ]
  end
end
```

```dockerfile

FROM elixir:1.7.4-slim

RUN apt update && apt upgrade -y
RUN apt install -y --no-install-recommends curl build-essential libssl-dev pkg-config ca-certificates
RUN apt clean
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable --no-modify-path

ENV PATH=/root/.cargo/bin/:$PATH

WORKDIR /work

COPY native/client .
RUN cargo rustc --release


FROM elixir:1.7.4-slim

RUN apt update && apt upgrade -y
RUN apt install -y ca-certificates libssl-dev

WORKDIR /app

ENV MIX_ENV=prod
ENV NO_RUSTLER_COMPILE=true

COPY mix.exs .
COPY mix.lock .
RUN mix local.hex --force
RUN mix local.rebar --force
RUN mix deps.get
RUN mix deps.compile

COPY . .
RUN mix compile

COPY --from=0 /work/target/release/libclient.so priv/native/libclient.so
```

イメージのサイズを削減するために最初のステージでrustコンパイラの導入とrustライブラリ(libclient.so)をビルドをして、それをelixirプロジェクトに載せています。記事を書いていて思ったのですが、Cargo.toml, Cargo.lockは先に追加して依存の解決だけしておいた方が良さそう。

```.dockerignore
/.git
/_build
/deps
/native/client/target
/priv/native
```

あとはできたイメージを実行してiexを起動し、`Client.get_body("https://google.co.jp")`とでも打ってみると動くと思います。

# 追記
elixir:alpineにalpine:edgeのcargo, rustを入れることができたので詳しくは[Dockerfile](https://github.com/techno-tanoC/rustler_docker/blob/alpine/Dockerfile)を見てください。この場合nightlyが使えないのでnightlyを使いたい場合は結局rustupを入れる必要がありそうです。

できたイメージの大きさは132MBでした。elixir:alpineのイメージサイズが86.9MBなので結構大きくなってしまいましたが、まぁこんなものでしょう。