Posted at

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

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

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

プロジェクトのコードはこちらにあります。


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ライブラリを生成します。

$ 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を呼び出せるはずです。

iex(1)> Client.add(1, 3)

{:ok, 4}

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


rustでやりたいことを書く

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

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

#[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



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は先に追加して依存の解決だけしておいた方が良さそう。

/.git

/_build
/deps
/native/client/target
/priv/native

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


追記

elixir:alpineにalpine:edgeのcargo, rustを入れることができたので詳しくはDockerfileを見てください。この場合nightlyが使えないのでnightlyを使いたい場合は結局rustupを入れる必要がありそうです。

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