この記事は#NervesJP Advent Calendar 2020の13日目です。
12日目は@torifukukaiouさんの「「kentaro/mix_tasks_upload_hotswap」を試してみる! ご本人が参加していらっしゃるカレンダーにて」でした。さっそく使っていただき、ありがとうございます!この記事では、そのモジュールが何をやっているのかを見ていきたいと思います。
本記事では、mix upload.hotswap
というmixタスクを提供する拙作のモジュールkentaro/mix_tasks_upload_hotswapについて、裏側で何が行われているかを見ていきたいと思います。
mix upload.hotswap
とは?
mix upload.hotswap
とは、いわゆるHot Code Swapping1を行うためのmixタスクです。手元でコードに変更を施した後に、現に動いているElixirのアプリケーションを再起動することなく、その変更をデプロイし適用するという機能を提供しています。
そもそもなぜこれを作ったかということを説明するほうが、やりたいことの理解が進みそうです。このAdvent Calendarのお題であるNervesでIoTデバイスを使ったアプリケーションのコードを書いている時に、ちょっとした変更を適用したいというだけでも、基本的にはファームウェアをビルドしなおして実機にアップロードし、再起動が完了して動作確認ができるようになるまで1分以上かかる2のが面倒になってきました。そこで、Erlang/Elixirの提供するHot Code Swappingを使えばもっと楽できるのではないか?と思って作ったのがこのタスクです。実際、mix upload.hotswap
では、後述する制約はあるものの数秒でデプロイが完了します。
Hot Code Swappingそのものについては、@kikuyutaさんによる「はじめてな Elixir(30) プロセスのホットスワップをする」が、mix upload.hotswap
の使い方については前述の@torifukukaiouさんによる「「kentaro/mix_tasks_upload_hotswap」を試してみる! ご本人が参加していらっしゃるカレンダーにて」がわかりやすいので、ぜひご覧ください。
ElixirにおけるHot Code Swapping
そもそもHot Code SwappingはErlang/Elixirそのものが提供する機能です。その機能を利用したデプロイは、Elixir 1.9から提供されているmix release
というタスクによってデプロイに必要な成果物(これを「リリース」といいます。tar.gz
ファイルとしてパッケージングされています)を作成し、サーバに配置して実行することで、実現できもします3。
ではなぜmix upload.hotswap
を作ったのか。前述の通り、そもそもの目的はNervesによるIoTデバイス/アプリケーション開発効率を向上することです4。IoTデバイス上で動くコードを更新するには、一般に、施したコードの変更を元にファームウェアをビルドし直して、デバイス上のファームウェアを新しいもので置き換えて再起動するという工程があります。それはそれでよいのですが、前述の通り1回のデプロイで1分以上時間がかかります。そこで、リリースを用いたりファームウェアの更新をするのではない方法でHot Code Swappingを実現したかったのでした。
kentaro/mix_tasks_upload_hotswap
の実装
kentaro/mix_tasks_upload_hotswapの実装は、hotswap.exに含まれるコードが本質的な部分です。
def run(_) do
app_name = get_config_for(:app_name)
nodes = get_config_for(:nodes)
cookie = get_config_for(:cookie)
System.cmd("epmd", ["-daemon"])
{:ok, _} = Node.start(:me@localhost)
Node.set_cookie(cookie)
for node <- nodes do
handle_connect(Node.connect(node), node)
end
{:ok, modules} = :application.get_key(app_name, :modules)
for module <- modules do
for node <- nodes do
handle_load_module(IEx.Helpers.nl([node], module), module, node)
end
end
end
このコードでは、最初に設定ファイルから各種情報を取得します。次に、ノードを起動して、デプロイ先のIoTデバイスと通信できるようにします。また、IoTデバイスの方でも、前もってノードを起動しておく必要があります5。そして、IoTアプリケーションを実装するコードに含まれるモジュールを取得して、IoTデバイスで動いているモジュールをそれらで置き換えるということをしています6。
たったこれだけのことで、Hot Code Swappingが実現できているのは驚きですね。ただ、これだけだと何が起きているのかさっぱりわからないので、もう少し深堀りしてみましょう。
:application.get_key(APP_NAME, :modules)
まず、ローカルでコードの変更を施したモジュールをリストアップする必要があります。:application.get_key/2
7に、Applicationの名前を示すatomと、:modules
を渡してあげると取得できます。IExで試すと、以下のようになります。
$ iex -S mix
iex(1)> :application.get_key(:example, :modules)
{:ok, [Example, Example.Application, Example.Counter]}
lib/
ディレクトリ以下のモジュールが取得できているようです。
IEx.Helpers.nl/2
上記で取得したモジュールを、IEx.Helpers.nl/2
によってIoTデバイス上で立ち上がっているノードで読み込みます。ところで、このメソッドは何をしているのでしょうか?IEx.Helpers.nl/2のドキュメントには、以下のように書かれています。
Deploys a given module's BEAM code to a list of nodes.
This function is useful for development and debugging when you have code that has been compiled or updated locally that you want to run on other nodes.
受け取ったモジュールのBEAMコード(Erlang VMで実行するための内部表現。Javaのclassファイルみたいなものでしょうか)をノードにデプロイするということをするとのことです。また、開発時やデバッグ次に便利だという旨も書かれています。まさに、今回の目的に沿う機能であるといえます。
さらに深堀りしてみましょう。Elixir v1.11.2におけるIEx.Helpersの実装は以下の通りです。
def nl(nodes \\ Node.list(), module) when is_list(nodes) and is_atom(module) do
case :code.get_object_code(module) do
{^module, bin, beam_path} ->
results =
for node <- nodes do
case :rpc.call(node, :code, :load_binary, [module, beam_path, bin]) do
{:module, _} -> {node, :loaded, module}
{:badrpc, message} -> {node, :badrpc, message}
{:error, message} -> {node, :error, message}
unexpected -> {node, :error, unexpected}
end
end
{:ok, results}
_otherwise ->
{:error, :nofile}
end
end
まず、Erlangのcode.get_object_code/1
8により、BEAMコードのバイナリを得ています。次に、引数としてわたされたノードたちに対して、これまたErlangのrpc.call/4
9によりデプロイし、ノードのコンテキストでcode.load_binary/3
10を実行しています。そうしてモジュールをロードすることにより、Hot Code Swappingを実現していることがわかります。
それにしても、これだけの実装でHot Code Swappingができることには、あらためて驚きです。
Hot Code Swappingの2つの方式
Erlang/Elixirのモジュールには、以下の2種類があります。
Residence module - The module where a process has its tail-recursive loop function(s). If these functions are implemented in several modules, all those modules are residence modules for the process.
Functional module - A module that is not a residence module for any process.Erlang -- Release Handling > Release Handling Instructionsより
Erlang/Elixirでは、関数の最後で自分自身を呼び出すことで再帰を実現できます11。そして、再帰する関数をKernel.spawn/1
やKernel.spawn_link/1
でプロセスとして起動することで、並行実行されるプロセスとして処理をバックグラウンドで実行できます12。上記のResidence moduleとは、そうしたモジュールのことをいいます。一方で、Residence moduleでないモジュールのことをFunctional moduleといいます。
また、上記で解説したのは、Hot Code Swappingの2つの方式の内のsimple code replacementと呼ばれるものです。もうひとつは、synchronized code replacementと呼ばれるもので、前述の「はじめてな Elixir(30) プロセスのホットスワップをする」で解説されている方式です。
mix upload.hotswap
が対応しているのは、Functional moduleに対するsimple code replacementです13。
If a simple extension has been made to a functional module, it is sufficient to load the new version of the module into the system, and remove the old version. This is called simple code replacement and for this the following instruction is used:
Erlang -- Release Handling > Release Handling Instructionsより
一般のHot Code Swappingにおいては、たとえばGenServerで実装したプロセスの状態(state)の構造に変更を加えたときなどに、GenServer.code_change/3
14を用いてsynchronized code replacementを実現できます。
一方で、mix upload.hotswap
では、単にこのタスクを実行するだけでHot Code Swapingが実現できる利便性とsynchronized code replacementを実現できるうまい方法を思いつけていないために、現状ではsimple code replacementへの対応となっています15。
mix upload.hotswap
の制約と使いどころ
mix upload.hotswap
には、現状では以下の実装の詰めが甘いところや制約があります。
-
lib/
以下に含まれるモジュール全部を(更新があろうがなかろうが)デプロイするやり方になっています。本当は更新したコードだけデプロイできるといいのでしょうけれども……。 - 前述の通り、simple code replacementへの対応のみとなっています。そのため、GenServerで実装されたプロセスが持つ状態の構造を変更した場合は使えません。
- また、モジュール間で複雑に依存関係がある場合は、デプロイしたあとに不整合が起こる可能性があります16。
そのため、mix upload.hotswap
は使いどころを定めて利用するのがよいと思います。
- 依存モジュールを追加したり、GenServerで実装されたプロセスが持つ状態の構造を変更した場合などは、従来どおり
mix firmware && mix upload
によりファームウェアそのものを更新してください - その他、モジュールの細部を更新したものをIoTデバイスに素早く反映させたい時に
mix upload.hotswap
を使うとよいでしょう。
というわけで、ふだんの開発時やデバッグ時のように、早いサイクルでPDCAを回したい時に用いていただけるとよいと思います。
おわりに
本記事では、NervesでIoTデバイス/アプリケーションを開発するに際して感じたペインを解決する仕組みとして開発したkentaro/mix_tasks_upload_hotswapについて、それが実際にはどういうことをやっているのかという裏側を解説してみました。
ぜひお使いいただきフィードバックをいただけると幸いです。
#NervesJP Advent Calendar 2020の14日目は、これまた拙文の「『プログラミングElixir 第2版』を読んでいまこそElixirに入門しよう」です。引き続きお楽しみください。
-
GenServer.code_change/3のドキュメントではHot Code Swappingといわれていますが、ErlangのドキュメントだとCode Loadingだとか、Code Replacementだとかいわれています。 ↩
-
実際には、必ずしもファームウェアをまるごと更新する必要はなかったりします。Nervesの1.7.0より正式にサポートされたファームウェアパッチによる更新が可能です。 ↩
-
正確には、Elixirがオフィシャルにサポートしているわけではありません(mix release — Mix v1.11.2を参照のこと)。そのため、Hot Code Swappingの実現には、リリースを作るためのライブラリであるDistilleryの2.0以上のバージョンを使う必要があります。『プログラミングElixir 第2版』の382ページにそのあたりのことが書かれています。また、実際の利用のしかたはHow to perform Hot Code Swapping using Distillery— #2 — A (Live Demo) GenServer State updateを参照してください。 ↩
-
とはいえ、この仕組み自体はNervesでしか使えないというものではなく、汎用的に使えるものではあります。 ↩
-
そのあたりはドキュメントに書いてありますので、ご参照ください。また、この記事ではノード間通信については扱わないので、詳しく知りたい方は「はじめてな Elixir(27) ノード間の通信がどうなってるか調べる」を是非ご覧ください。 ↩
-
ちなみに
[node]
としている部分はnodes
をそのままわたすのでもいいのですが、ノード別・モジュール別にロードしたことを表示したかったので、冗長ですがそのように実装しています。 ↩ -
http://erlang.org/doc/apps/kernel/application.html#get_key-2 ↩
-
Schemeなどのような関数型言語同様に末尾最適化が施されており、再帰呼び出しが関数の先頭へのジャンプ命令に変換されるため、スタックオーバーフローが起きないようになっています。 ↩
-
具体的には、「ウェブチカでElixir/Nervesに入門する(2020年12月版)」で紹介したウェブチカのループを実装したコードにシンプルな実装例がありますので、参照してください。 ↩
-
Residence moduleでもできそうな気もするけど、できないのだろうか。試してみていないのでわからないです。 ↩
-
つまり、GenServerで実装されたプロセス持つ状態の構造を変更した場合には、従来どおり
mix firmware && mix upload
が必要であるということです。うまい実装をご存じの方は、ぜひ教えて下さい。 ↩ -
このあたりの解決が困難なので、mix releaseのドキュメントにある通り、Hot Code SwappingはElixirの公式サポートからは外されてしまったのでした。 ↩