Fly.io に JS モジュールと Docker コンテナビルド時引数を渡すのに苦労した話

## はじめに

以前の記事に書いたように、 Petal Pro を使って Fly.io に Phoenix LiveView の Web アプリケーションを構築しようとしています

https://qiita.com/RyoWakabayashi/items/64caebfba579a7114e9c

https://qiita.com/RyoWakabayashi/items/c8666c2797ff5bafe611

Petal Pro では基本的に JS をできるだけ書かないため、JS の外部モジュール（assets/node_modules に入るものたち）をデフォルトでは使っていませんでした

しかし、JS 側で処理してしまった方が効率的なものがあり、外部モジュールを使うことになりました

また、JS 側に環境（開発環境、ステージング環境、本番環境など）によって変化する値を指定する必要が発生しました

この値はインフラ構築時に生成される値のため、固定ではなくビルド時の環境変数から渡す必要があります

つまり、以下の２点が課題になりました

- Fly.io にデプロイするコンテナに JS 外部モジュールを含む
- Fly.io でのコンテナビルド時、ローカル環境変数を渡す

どちらも一筋縄で行きませんでした、、、

という話です

## package.json の追加

まず、そもそも外部モジュールを一切使っていなかったため、以下のような package.json を追加しました

assets/package.json

```json
{
  "dependencies": {
    ...
  }
}
```

この状態で `cd assets && npm install && cd .. && mix assets.deploy` を実行することで、外部モジュールは**ローカルでは**問題なく動作しました

## node_modules のコンテナへの追加

これに気付くのが遅かったのが悪いのですが、 `.dockerignore` に以下の記載がありました

.dockerignore
```
...
# Static artifacts - These should be fetched and built inside the Docker image
/assets/node_modules/
/priv/static/assets/
/priv/static/cache_manifest.json
...
```

`.dockerignore` に書かれているものはコンテナビルド時に無視されます

以下は Fly.io でのコンテナビルド定義です

Dockerfile
```Dockerfile
...
# copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile

COPY priv priv

COPY lib lib

COPY assets assets
...
```

ここで `COPY assets assets` としてローカルの assets デイレクトリーをコンテナ内にコピーしていますが、 assets/node_modules 配下は無視されるためコピーされません

外部モジュールなどの環境に依存するものは、本番環境とはOSやアーキテクチャなどが異なるローカル環境ではなく、本番環境と同じようにしたビルド環境でインストールするのが鉄則だからです

なので、ローカルで `npm install` して作られた `assets/node_modules` はコンテナに含まれず、このまま `fly deploy` すると、 JS 側で実行時にモジュールが見つからない旨のエラーが発生します

:::note warn
ビルド時（`mix compile` や `mix assets.deploy` や `mix phx.server` 実行時）にも以下のようなエラーが表示されます

```
✘ [ERROR] Could not resolve "xxx"

    js/hooks/algolia-hook.js:1:26:
      1 │ import xxx from 'xxx'
        ╵                 ~~~~~

  You can mark the path "xxx" as external to exclude it from the bundle, which will remove this error.
```
:::

従って、コンテナビルド時にコンテナ内部で `npm install` する必要があります

今まで外部モジュールを使っていなかったため、この辺りが Dockerfile からマルっと抜け落ちていたのでした

参考記事

https://qiita.com/mnishiguchi/items/6f219be33384cd36e836

```diff_docker
...
# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
+   && apt-get install -y curl \
+   && curl -sL https://deb.nodesource.com/setup_19.x | bash \
+   && apt-get install -y nodejs \
    && apt-get clean && rm -f /var/lib/apt/lists/*_*
...

COPY assets assets

+# compile assets
+RUN cd assets && npm install && cd .. && mix assets.deploy

# Compile the release
RUN mix compile
...
```

これによって JS の外部モジュールが見つかるようになります

## ビルド時引数を Fly.io に渡す

例えば JS から直接 REST API を叩くときのエンドポイントなど、本番環境と開発環境では分けたい値があります

また、エンドポイントの URL が、例えばAPI Gatewayでランダムに生成されるものだったり、開発環境では開発者単位に変える値だったりすれば、この値を Git 管理下には持ちたくないケースがあります（ビルド実行時にどこかのストレージやサーバー、 Terrafrom の実行結果等から値を取得してくる、または各開発者が手動で指定するようなケース）

このようなとき、値を環境変数（.env や .envrc 等のファイル）に入れておき、JS ビルド時に渡すのが常套手段です

Petal Pro では esbuild で JS をビルドします

環境変数をビルド時に渡したい場合、以下のように esbuild の設定を変更します

config/config.exs
```exs
...
# Configure esbuild (the version is required)
config :esbuild,
  version: "0.15.5",
  default: [
    args: ~w(
      js/app.js \
        --bundle \
        --target=es2017 \
        --outdir=../priv/static/assets \
        --external:/fonts/* \
        --external:/images/* \
        --define:XXX="#{System.get_env("XXX")}" \
        --define:YYY="#{System.get_env("YYY")}"
    ),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ]
...
```

`--define:XXX="#{System.get_env("XXX")}"` というようにオプションを指定すると、ローカルの環境変数 XXX を JS 内で定数 XXX として使用できるようになります

これで**ローカルでは**環境変数の値を使えるようになりました

これをこのまま `fly deploy` すると、 XXX の値は空文字になってしまいます

Fly.io のコンテナビルドは Fly.io のクラウド環境上で実行されているため、ローカルの開発環境は使われません

## 実行時（Runtime）の環境変数を Fly.io に渡す

実行時の値（Elixir 側で使う値）であれば、以下のようにして渡すことができます（以下は Google 認証を追加する場合の例）

まず、実行時の設定を追加します

config/runtime.exs
```exs
...
config :ueberauth, Ueberauth.Strategy.Google.OAuth,
  client_id: System.get_env("GOOGLE_OAUTH_CLIENT_ID"),
  client_secret: System.get_env("GOOGLE_OAUTH_SECRET")
...
```

そして、環境変数を `.envrc` などのファイルに書いておきます

.envrc
```
export GOOGLE_OAUTH_CLIENT_ID="xxx"
export GOOGLE_OAUTH_SECRET="yyy"
```

`flyctl secrets import` でファイルの内容を Fly.io の実行環境上の環境変数に渡すことができます

```bash
flyctl secrets import < .envrc 
```

しかし、 JS はビルド時に渡さないといけないのでこれではいけません

## ビルド時引数（build-arg）を Fly.io に渡す

まず、 Dockerfile に引数を追加します

追加する位置が重要です

Dockerfile
```diff_docker
...
ARG ELIXIR_VERSION=1.14.3
ARG OTP_VERSION=25.2.2
ARG DEBIAN_VERSION=bullseye-20230109-slim

ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"

FROM ${BUILDER_IMAGE} as builder

+ARG XXX
+ARG YYY

# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
    && apt-get install -y curl \
    && curl -sL https://deb.nodesource.com/setup_19.x | bash \
    && apt-get install -y nodejs \
    && apt-get clean && rm -f /var/lib/apt/lists/*_*

# prepare build dir
WORKDIR /app

# install hex + rebar
RUN mix local.hex --force && \
    mix local.rebar --force

# set build ENV
ENV MIX_ENV="prod"
+ENV XXX=${XXX}
+ENV YYY=${YYY}
...
```

ここで結構ハマりました

先頭の方で `ARG ELIXIR_VERSION=1.14.3` など、引数を指定しているので、ついついこの流れで `ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"` の下あたりに ARG を追加したくなってしまいます

しかし、ARG を追加するのは `FROM ${BUILDER_IMAGE} as builder` から `FROM ${RUNNER_IMAGE}` の間にしないと、値が空になってしまいます

Dockerfile 内では `FROM` でスコープが分かれているためです

参考記事

https://freak-da.hatenablog.com/entry/2020/03/31/094140

そして、デプロイ時のコマンドで環境変数を渡します

```bash
fly deploy \
    --build-arg XXX=$XXX \
    --build-arg YYY=$YYY
```

これで Fly.io にデプロイしても JS 内の定数に環境変数の値が使えます

## まとめ

Docker の ARG の仕様をきちんと理解していなかったせいで結構時間を消費してしまいました