Elixirのconfigファイルの落とし穴

4 min読了の目安(約4100字TECH技術記事

まえがき

先日Elixirで書いたプログラムをデプロイしようとしてconfigファイルの書き方でハマってなかなか回答が見つかりませんでした。それどころかデプロイ方法自体あまり記事がないので、それも合わせて覚書のために残しておきます。

Elixirのデプロイ

Elixirをデプロイする方法はいくつかあるようですが、Elixir1.9からmix releaseというコマンドが導入されたのでそれを使うのが良いようです。ですが、mix releaseでデプロイをするとiex -S mixなどで立ち上げる場合には気づかないつまづきポイントがあるので注意が必要です。

コンパイル時に評価されるConfigファイル

ElixirのconfigファイルはElixir1.9からconfigディレクトリ配下に置くことになったようです。コンパイル時にconfig/config.exsがまず読み込まれ、そこからMix.env/0で取得されるデプロイ環境に合わせてconfig/dev.exsconfig/prod.exsが読み込まれます。

Mix.env/0で取得されるデプロイ環境はデフォルトではmix.exsproject内にstart_parmanentに書かれたものが使用されます。環境変数のMIX_ENVprodなどをセットするとそれが優先されて使用されます。よっぽどしっかりした開発体制を整えてテストなどをするというシチュエーションでもなければMIX_ENVprodを指定するのとセットでmix releaseを使うことになるでしょう。つまり、以下みたいなコマンドでリリース作業を行います。

$ MIX_ENV=prod mix release

例えばphx.newなどでPhoenixのプロジェクトを作った場合にはconfigディレクトリの中にdev.exsprod.exsprod.secret.exstest.exsが作られます。dev.exsprod.exstest.exsについては上記の通りで、prod.secret.exsは名前の通りシークレットなどをリポジトリに含めないようprod.exsと分けるために使用するようです。なので実際にはprod.secret.exsprod.exsから呼ばれるようになっています。
大事なポイントは、これらのファイルがコンパイル時に評価されるという点です。つまり、プログラムの実行時に値を変えたいといったニーズに対応することができず、毎回コンパイルをし直すことになってしまいます。

実行時に評価されるConfigファイル

prod.exsprod.secret.exsだけでは、コンパイルをしたときにreleases.exsがないよとwarningが表示されてしまいます。releases.exsもElixirのスクリプトの形になっているので、ほかのconfigファイルと同じように書くことができます。しかし1行目だけ他と書き方が異なります。

dev.exsの例:

use Mix.Config

...

releases.exsの例:

import Config

...

(詳しくelixirの挙動をハックしているわけではないので想像ですが)
これはコンパイル時にはコンパイルがMixタスクとして実行されるのでmixモジュールが存在しているのに対して、プログラムの実行時にはバイナリを叩くことになるので、mixモジュールがバイナリに含まれていないのだと思います。
これはコンパイル時のconfigではuseを使い、実行時のconfigではimportを使っているという違いが想定の根拠になっています。useを使用する場合、コンパイル時に__using__マクロ内に定義されたquoteが展開されるという挙動をします。つまり、実行時にMixモジュールを呼んでいるのではなく、Mixモジュール内に定義されたコードをコンパイル時にそこへ展開しているというわけです。

ようやく本題(実際に躓いたところ)

以上のデプロイについてはドキュメントを読めばmix releaseconfigについて公式ドキュメントを読めば書いてあるのですが、これのとおり書いてかつiex -S mix時には発生しない問題が発生しました(この記事を書くに至った原因)。

前提

やろうとしていたことは、PhoenixでAPIサーバーを開発してあるタスクを定期実行しようとしていました。タスクを定期実行しようとした場合、僕の知る限りでは以下の3つのうちどれかの方法になるかと思います。

  • Mixタスクを定義してCronから実行させる
  • quantumを使って定期実行ジョブを定義
  • 自前で定期実行用のGenServerを実装

実際にはCronやquantum以上にカスタマイズしたいパターンは無いと思うので(結果で次の時刻をカスタムするとかならやる意味はあるかも?)実質2択になるかと思います。今回の実装ではquantumを選択しました。理由としては、プロジェクトが趣味の開発でやっていて、ひとつのEC2インスタンス上で複数のサービスをdockerで共存させていたためです。
Cronを使う場合、ホストマシンのCronを利用するかコンテナ上にCronをインストールして実行することになります。しかし1つのコンテナで走らせるプロセスは1つだけというのがdockerのベストプラクティスのようなので、docker上に実装してしまうのは好ましくありません。かといってホストマシンのCronを使う場合、コンテナをまたぐのが面倒なのに加えて、ホストマシンのCronにいろいろなタスクが混じってしまい、管理しづらくなってしまいます。このような理由から今回はquantumを利用することにしました。

quantumでコードの定期実行

quantumの使い方として、configファイル内に定期実行する関数を指定する必要があります。そこでとあるQiitaの記事を真似して以下のようにconfigを書きました。

config :my_app, MyApp.Scheduler,
  jobs: [
    {"*/15 * * * *", fn -> MyApp.Periodicaly.do_something([]) end}
  ]

この定義はquantumのサンプルにもあるため、iex -S mixで実行をする場合特に問題なく動作します。しかし、mix releaseを実行すると以下のようなコンパイルエラーが発生します。(ひょっとするとelixirのバグとして報告するべきなのかもしれません)

** (Mix) Could not read configuration file. It likely has invalid configuration terms such as functions, references, and pids. Please make sure your configuration is made of numbers, atoms, strings, maps, tuples and lists. Reason: {3, :erl_parse, ['syntax error before: ', 'Fun']}

erlang側が構文解釈エラーを起こしてしまうため、コンパイルができないということですね。このエラーが表示されてからまる一日、指摘されている3行目とはどこの3行目なのかもわからず、ggったりコミットを戻したり色々試して原因調査をしました。
configファイルを色々いじったところ、ようやく原因がconfigファイルにあると気が付きました。そもそも問題を起こしている行はelixirのコードでは3行目ですらなかったのです。

エラーを起こさないconfigの書き方

今回のエラーの原因の種明かしをすると、configファイルに無名関数を使っていたことです。どうやらelixirではconfigファイルに無名関数を使用してはいけないようです。なので、エラーを起こさないようにquantumのconfigを書くと以下のようになります。

config :my_app, MyApp.Scheduler,
  jobs: [
    {"*/15 * * * *", {MyApp.Periodicaly, :do_something, [[]]}}
  ]

あとがき

今回のエラーは今まで遭遇した中でもかなり難しい部類になっていました。問題発生当時の状況を思い出すと、表示されたエラー文でggっても同じ問題が出てこず(出てきても違う原因で報告されたelixirのissuesばっかりで、しかもすでにOTP修正したよみたいな感じでcloseされていました)、そもそもmix releaseについて取り扱っている記事も少なく(特に日本語は殆どない)、ようやく原因がquantumのconfigだということに気がついて調べたら英語の質問記事が1つだけヒットみたいな状況でした。
mix releaseでデプロイしてないのかなぁと思う今日この頃です。まぁまだあまりプロダクトレベルでelixirを採用している例があまりないからかもしれませんね。