Elixir Livebook で AI 画像識別アプリを実装、公開（本番リリース）する

## はじめに

前回の記事で [Livebook](https://livebook.dev/) から Web アプリをデプロイする方法を紹介しました

https://qiita.com/RyoWakabayashi/items/9cd0032d1bd4ddad5d6c

今回はその発展形で、[Bumblebee](https://github.com/elixir-nx/bumblebee) を使った AI Web アプリをインターネット上に公開してみます

デプロイ先は Fly.io です

https://fly.io/

少しのアプリを動かすだけであれば無料で利用できます

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/deploy/image_classification_app.livemd

## コンテナ実装

特に必要な依存モジュール等がなければ以下の Web サイトから簡単に Livebook を Fly.io にデプロイすることが可能です

https://fly.io/launch/livebook

しかし、今回は Livebook が 0.9 以上であることなど、必要な条件があるため、自分でカスタマイズしたコンテナを Fly.io にデプロイします

幸い私は普段から Livebook をコンテナで起動していたため、以下のリポジトリーにある Dockerfile をそのまま使えました

https://github.com/RyoWakabayashi/elixir-learning/blob/main/Dockerfile

```Dockerfile
FROM ghcr.io/livebook-dev/livebook:0.9.0

...

CMD ["/app/bin/livebook", "start"]
```

:::note info
この Dockerfile は以下の目的で作られています

- livebooks ディレクトリー配下の自作ノートブックを実行する
  - evision や Image などのモジュールがインストールできる
  - docker CLI を使える
- export して WSL にインポートする
- コンテナ内で Phoenix アプリのデモを行う

なので、 Bumblebee を動かすだけであれば上記 Dockerfile の全てが必要なわけではありません
:::

重要なのは冒頭 `FROM ghcr.io/livebook-dev/livebook:0.9.0` で Livebook 0.9.0 を指定していることと、末尾で `CMD ["/app/bin/livebook", "start"]` として Livebook を起動していることです

それ以外の箇所は目的に応じて変更してください

## .dockerignore の作成

これは環境によりますが、私の場合は `tmp` ディレクトリー配下に AI のモデルファイル等の巨大ファイルを大量に保存していたため、そのままデプロイしようとするとかなり時間がかかってしまいました

コンテナに不要なディレクトリー、ファイルは .dockerignore ファイルに書いておきましょう

.dockerignore 記載例

```text
tmp
```

## Fly.io へのデプロイ

### Fly.io のアカウント準備

まだ Fly.io を使ったことがない場合、以下の Speedrun に従って認証まで実行してください

https://fly.io/docs/speedrun/

macOS の場合は以下のようなコマンドになります

```bash
brew install flyctl
```

```bash
fly auth signup
```

### fly.toml の作成

以下のような内容で Dockerfile と同じディレクトリーに fly.toml を作ります

```toml
# fly.toml file generated for livebook-rwakabay on 2023-03-30T09:19:15+09:00

app = "livebook-rwakabay"
kill_signal = "SIGTERM"
kill_timeout = 5
primary_region = "nrt"
processes = []

[env]

[experimental]
  allowed_public_ports = []
  auto_rollback = true

[[services]]
  http_checks = []
  internal_port = 8080
  processes = ["app"]
  protocol = "tcp"
  script_checks = []
  [services.concurrency]
    hard_limit = 25
    soft_limit = 20
    type = "connections"

  [[services.ports]]
    handlers = ["http"]
    port = 80

  [[services.ports]]
    handlers = ["tls", "http"]
    port = 443

  [[services.tcp_checks]]
    grace_period = "30s"
    interval = "15s"
    restart_limit = 6
    timeout = "2s"
```

`livebook-rwakabay` の部分はデプロイするアプリ毎の ID なので、各人で変更してください

### アプリの作成

`fly launch` コマンドを実行し、質問には全てデフォルトのまま（何も入力せずに）Enter を押してください

```bash
$ fly launch
An existing fly.toml file was found for app livebook-rwakabay
? Would you like to copy its configuration to the new app? Yes
Creating app in /Users/oec/dx/elixir-learning
Scanning source code
Detected a Dockerfile app
? Choose an app name (leaving blank will default to 'livebook-rwakabay') 
automatically selected personal organization: Ryo Wakabayashi
Some regions require a paid plan (fra, maa).
See https://fly.io/plans to set up a plan.

? Choose a region for deployment: Tokyo, Japan (nrt)
Created app 'livebook-rwakabay' in organization 'personal'
Admin URL: https://fly.io/apps/livebook-rwakabay
Hostname: livebook-rwakabay.fly.dev
? Would you like to set up a Postgresql database now? No
? Would you like to set up an Upstash Redis database now? No
? Create .dockerignore from 5 .gitignore files? No
Wrote config file fly.toml
```

最後の `? Would you like to deploy now?` に `y` で変更すればそのままデプロイできます

### デプロイ

`fly deploy` コマンドでデプロイできます

しばらくして `deployed successfully` が表示されればデプロイ成功です

```bash
$ fly deploy --ha=false
==> Verifying app config
--> Verified app config
==> Building image
Remote builder fly-builder-polished-waterfall-3907 ready
==> Creating build context
--> Creating build context done
==> Building image with Docker
--> docker host: 20.10.12 linux x86_64
[+] Building 43.6s (0/1)                                                                                                              
[+] Building 162.9s (19/19) FINISHED                                                                                                  
 => [internal] load remote build context
...
==> Monitoring deployment
Logs: https://fly.io/apps/livebook-rwakabay/monitoring

 1 desired, 1 placed, 1 healthy, 0 unhealthy [health checks: 1 total, 1 passing]
--> v0 deployed successfully
```

:::note warn
2023年06月現在、 `fly deploy --ha=false` と指定しないと、勝手に２台構成で起動してしまい、認証できなくなります

https://qiita.com/RyoWakabayashi/items/6b8a2b3642829b004523
:::

### リソースの変更

`fly scale memory 2048` コマンドでリソースを 2GB に拡張します

デフォルトの 256 MB だと EXLA のインストール時にメモリ不足でエラーが発生します

```
$ fly scale memory 2048
Scaled VM Memory size to 2 GB
      CPU Cores: 1
         Memory: 2 GB
```

### ログの表示

`fly logs` でコンテナのログを参照できます

Livebook の場合、アクセスするためにトークンが必要なので、ログを参照してトークンを取得します

```
$ fly logs
2023-03-30T00:37:47Z runner[0ab58f2c] nrt [info]Starting instance
2023-03-30T00:37:47Z runner[0ab58f2c] nrt [info]Configuring virtual machine
2023-03-30T00:37:47Z runner[0ab58f2c] nrt [info]Pulling container image
2023-03-30T00:38:26Z runner[0ab58f2c] nrt [info]Unpacking image
2023-03-30T00:39:28Z runner[0ab58f2c] nrt [info]Preparing kernel init
2023-03-30T00:39:29Z runner[0ab58f2c] nrt [info]Configuring firecracker
2023-03-30T00:39:29Z runner[0ab58f2c] nrt [info]Starting virtual machine
2023-03-30T00:39:30Z app[0ab58f2c] nrt [info]Starting init (commit: 8e03fa6)...
2023-03-30T00:39:30Z app[0ab58f2c] nrt [info]Preparing to run: `/app/bin/livebook start` as root
...
2023-03-30T00:39:33Z app[0ab58f2c] nrt [info][Livebook] Application running at http://0.0.0.0:8080/?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

ログで `[Livebook] Application running at http://0.0.0.0:8080/?token=<トークンの値>` となっている行からトークンの値をコピーしておきます

トークンの値はアプリを再起動するたびに変化するため、最後に表示されているものを使用してください（リソースの変更時に再起動されています）

### Livebook を開く

以下のコマンドを実行すると、ブラウザで Livebook が起動します

```bash
fly open
```

![スクリーンショット 2023-03-30 13.50.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/e973059b-fa36-51a7-1d1e-0a174aa15a4b.png)

取得しておいたトークンを入力します

![スクリーンショット 2023-03-30 13.51.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/835a49c5-410f-aae6-5cad-966b1200003e.png)

Livebook のトップページが表示されました

## Web アプリの実装

今回は画像識別 Web アプリを実装します

「New notebook」をクリックして新しいノートブックを開き、セルに以下のコードを入力、実行してください

### セットアップ

必要なモジュールをインストールします

```elixir
Mix.install(
  [
    {:bumblebee, "~> 0.2"},
    {:nx, "~> 0.5"},
    {:exla, "~> 0.5"},
    {:kino, "~> 0.9"}
  ],
  config: [nx: [default_backend: EXLA.Backend]]
)
```

- [Bumblebee](https://github.com/elixir-nx/bumblebee): AI
- [Nx](https://github.com/elixir-nx/nx/tree/main/nx): 行列演算
- [EXLA](https://github.com/elixir-nx/nx/tree/main/exla): 行列演算の高速化
- [Kino](https://github.com/livebook-dev/kino): UI/UX

### 画像識別サービスの作成

モデルファイルをダウンロードしてロードし、Bumblebee で画像識別のサービスを作成します

```elixir
cache_dir = "/tmp/bumblebee_cache"

{:ok, resnet} =
  Bumblebee.load_model({
    :hf,
    "microsoft/resnet-50",
    cache_dir: cache_dir
  })

{:ok, featurizer} =
  Bumblebee.load_featurizer({
    :hf,
    "microsoft/resnet-50",
    cache_dir: cache_dir
  })

serving = Bumblebee.Vision.image_classification(resnet, featurizer)
```

### フォームの作成

画像アップロードと実行用のボタンを作ります

```elixir
inputs = [
  image: Kino.Input.image("IMAGE", size: {224, 224})
]

form = Kino.Control.form(inputs, submit: "Classify")
```

![スクリーンショット 2023-03-30 14.15.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/ebcb7b3d-a6d2-8d65-ac39-9896a20ae4b0.png)

### 結果表示用フレームの作成

結果を表示するフレームを作成します

```elixir
frame = Kino.Frame.new()
```

![スクリーンショット 2023-03-30 14.18.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b9ce4fbb-3950-c567-02fa-0b04c9a4bf02.png)

### 画像識別処理の実装

入力フォームで Classify ボタンがクリックされたときの処理を定義します

```elixir
Kino.listen(form, fn %{data: %{image: image}, origin: origin} ->
  image =
    image
    |> then(fn input ->
      input.data
      |> Nx.from_binary(:u8)
      |> Nx.reshape({input.height, input.width, 3})
    end)

  serving
  |> Nx.Serving.run(image)
  |> Map.get(:predictions)
  |> Kino.DataTable.new()
  |> then(&Kino.Frame.render(frame, &1, to: origin))
end)
```

`&Kino.Frame.render(frame, &1, to: origin)` の `to: origin` を指定することで、クライアント（アプリを開いているブラウザ）毎に個別の結果を表示します

これを指定していない場合、複数人でこのアプリを使っていると全員で結果を共有することになります

### Livebook 上での画像識別実行

適当な画像をアップロードして Classify ボタンをクリックすると、表示用フレームに識別結果がテーブル表示されます

![スクリーンショット 2023-03-30 14.26.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/439b8412-5410-9fbd-1ef5-c3637041db33.png)

## アプリのデプロイ

Livebook の左メニューからロケットのアイコンをクリックすると、デプロイ用のメニューが表示されます

Slug （URLの末尾部分）に適当な値（画像の例では「classifier」）を入力して「Deploy」ボタンをクリックしましょう

![スクリーンショット 2023-03-30 14.27.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/16bdbf9a-fe6e-412a-9a0a-89f0f6288742.png)

DEPLOYMENTS の Status が Running になれば Web アプリが起動しています

![スクリーンショット 2023-03-30 14.30.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/6b1355f3-65a8-2844-b39f-eef0c142ae50.png)

`<Livebook の URL 先頭部分>/apps/<Slug に指定した値> `（今回の例では<https://livebook-rwakabay.fly.dev/apps/classifier>）をブラウザで開きましょう

入力フォームと結果表示用フレームだけの画面が開きます

![スクリーンショット 2023-03-30 14.33.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/971f5f9e-d905-ba40-8ba7-6cdd644f7005.png)

画像識別を実行してみます

![deploy_ai.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1584f3c3-5ef1-0ea2-890b-0ea9cb38110b.gif)

実行できました

他の人にアプリを共有する場合、アプリの URL とデプロイ用メニューの「Password-protected」に入っている値（パスワード）を伝えてください

## まとめ

ノートブックでアプリが実装できて、しかもデプロイまでできる、というのは本当に衝撃的です

しかも超短時間、超低コストで

今回のコードであれば実装からリリースまで数分でできてしまいます

Elixir の進化は計り知れないですね