運用と保守

この記事は英語の原文を日本語に翻訳したものです。原文: https://blog.stenmans.org/theBeamBook/#CH-Ops

翻訳元: happi/theBeamBook 7998e22e78417dbe20e5136b9aee862a1ecaa404(コミット 7998e22

ランタイムシステムの設計を支える原則の一つは、バグが多かれ少なかれ避けられないという前提です。たとえ膨大な労力をかけてバグのないアプリケーションを作り上げたとしても、世界やユーザーの側が変化し、アプリケーションを「直す」必要が生じることをやがて思い知るでしょう。

Erlangランタイムシステムは、変化を受け入れやすくし、バグの影響を最小限に抑えるように設計されています。

バグの影響は、区画化によって最小限に抑えられます。これは、個々のデータ構造が独立していて不変であるという最下層から、稼働中のシステムが複数のノードに分割されるという最上層まで、一貫して行われています。変化への対応は、コードのアップグレードや、稼働中のシステムとのやり取り・調査を容易にすることによって実現されています。

システムへの接続

稼働中のシステムを監視し保守する方法は数多くあります。利用できるツールや技法は豊富にありますが、その中でも最も基本的な道具、すなわちシェルと、シェルをノードに接続する能力を忘れてはいけません。

二つのノードを接続するには、両者がクッキーと呼ばれる秘密のパスフレーズを共有するか知っている必要があります。同じマシン上で同じユーザーが起動した二つのノードであれば、クッキーは自動的に共有されます(ファイル$HOME/.erlang.cookieに保存されます)。

これは、二つのノード、ErlangのノードとElixirのノードをそれぞれ起動して確認できます。まずnode1という名前のErlangノードを起動します。接続しているノードがないことがわかります。

$ erl -sname node1
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit] [smp:4:4]
              [async-threads:10]  [kernel-poll:false]

Eshell V8.1  (abort with ^G)
(node1@GDC08)1> nodes().
[]
(node1@GDC08)2>

続いて別のターミナルウィンドウで、node2という名前のElixirノードを起動します。(Elixirではコマンドラインフラグをダブルダッシュで指定する必要がある点に注意してください。)

$ iex --sname node2
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit] [smp:4:4]
              [async-threads:10]  [kernel-poll:false]

Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(node2@GDC08)1>

ElixirではNode.connect(name)コマンドを実行してノードを接続できます。Erlangではnet_kernel:connect_node(Name)で同じことができます。ノードの接続は双方向なので、どちらか一方のノードでコマンドを実行するだけで構いません。

iex(node2@GDC08)1> Node.connect :node1@GDC08
true
iex(node2@GDC08)2> Node.list
[:node1@GDC08]
iex(node2@GDC08)3>

Erlang側を見ると、両方のノードが互いを認識していることがわかります。(nodes()の結果にはローカルノード自身は含まれない点に注意してください。)

(node1@GDC08)2> nodes().
[node2@GDC08]
(node1@GDC08)3>

分散環境の場合はもう少し複雑になります。すべてのノードがクッキーを知っているか共有している必要があるからです。これには三つの方法があります。特定のノードと通信する際に使うクッキーを個別に設定する方法、起動時に-set_cookieパラメータですべてのシステムに同じクッキーを設定する方法、そして各マシンでシステムを実行しているユーザーのホームディレクトリに.erlang.cookieファイルをコピーする方法です。

最後の方法、つまりシステム内の各マシンのクッキーファイルに同じクッキーを置く方法が、通常は最良の選択です。ローカルのOSシェルからノードへ接続しやすくなるからです。VPNやsshなど、安全なログイン手段を用意するだけで済みます。

別のマシンにノードを作成し、すでに起動しているノードに接続してみましょう。三つ目のターミナルウィンドウを開き、ローカルのクッキーファイルの中身を確認してから、別のマシンにsshで接続します。

happi@GDC08:~$ cat ~/.erlang.cookie
pepparkaka
happi@GDC08:~$ ssh gds01
happi@gds01:~$

リモートマシン上でノードを起動し、同じクッキーのパスフレーズを使うよう指定すると、既存のノードに接続できるようになります。(Erlangがこれらのノードの居場所を把握できるよう、node1@GDC08を明示的に指定する必要がある点に注意してください。)

happi@gds01:~$ erl -sname node3 -setcookie pepparkaka
Erlang/OTP 18 [erts-7.3] [source-d2a6d81] [64-bit] [smp:8:8]
              [async-threads:10]  [kernel-poll:false]

Eshell V7.3  (abort with ^G)
(node3@gds01)1> net_kernel:connect('node1@GDC08').
true
(node3@gds01)2> nodes().
[node1@GDC08,node2@GDC08]
(node3@gds01)3>

ノード2に対して明示的に何も話しかけていなくても、ノード3が参加したことが自動的に伝わっていることがわかります。

iex(node2@GDC08)3> Node.list
[:node1@GDC08,:node3@gds01]
iex(node2@GDC08)4>

同様に、いずれかのノードを終了させると、残りのノードは自動的にそのノードをリストから取り除きます。そのノードが再起動すれば、単純にネットワークへ再度参加できます。

Erlang分散のデフォルトは、完全に接続されたネットワークを作ることです。つまり、すべてのノードがネットワーク内の他のすべてのノードと接続されます。各ノードが個別のクッキーを持っている場合、接続を試みる前に、各ノードへ他のすべてのノードのクッキーを伝える必要があります。フラグ-connect_all falseを付けてノードを起動すると、システムが完全に接続されたネットワークを作ろうとするのを防げます。あるいは、フラグ-hiddenを付けてノードを隠しノードとして起動すると、そのノードへの接続は非推移的になります。

ノード同士の接続方法、たとえ別々のマシン上にあっても接続できることがわかったので、次はシェルをノードに接続する方法を見ていきましょう。

シェル

ElixirのシェルもErlangのシェルも、コンピュータ上の通常のシェルやターミナルウィンドウとほぼ同じように動作しますが、ランタイムシステムの内部に直接つながるターミナルウィンドウを与えてくれる点が異なります。これにより、ランタイムへの完全なアクセス権を持つCLIという、非常に強力な道具が手に入ります。これは運用と保守にとって申し分のないものです。

本節では、シェル経由でノードに接続するさまざまな方法と、あまり知られていないかもしれないシェルの強力な機能をいくつか見ていきます。

シェルの設定

ElixirのシェルもErlangのシェルも、よく使う関数のショートカットを用意できるよう設定できます。

Elixirのシェルは、まずローカルディレクトリ、次にユーザーのホームディレクトリの順で.iex.exsファイルを探します。このファイルに書かれたコードはシェルのプロセス内で実行され、すべての変数束縛がシェルの中で利用できるようになります。

このファイルでは、構文の色付けや履歴のサイズといった項目を設定できます。完全なドキュメントはhexdocsを参照してください。

シェルのコンテキストで任意のコードを実行することもできます。

Erlangランタイムシステムが起動すると、まずErlangの設定ファイルに書かれたコードを解釈します。このファイルのデフォルトの場所は、ユーザーのホームディレクトリの~/.erlangです。任意のErlang式を、それぞれドットと改行で終端する形で記述できます。

このファイルは通常、Erlangモジュールを読み込むためのディレクトリをコードパスに追加するために使われます。

code:add_path("/home/happi/hacks/ebin").

また、Erlangシェルをユーザー定義の関数で拡張するために使う、独自のuser_defaultモジュール(.beamファイル)を読み込むためにも使われます。

code:load_abs("/home/happi/.config/erlang/user_default").

上記のパスは自分のシステムに合わせて置き換えてください。load_absコマンドには.beam拡張子を含めないでください。

モジュール名を指定せずにシェルから関数を呼び出した場合、たとえばfoo(bar)のような場合、まずモジュールuser_default(存在すれば)の中で、次にstdlibの一部であるモジュールshell_defaultの中でその関数が探されます。ls()help()といったシェルコマンドはこの仕組みで実装されており、自分でコマンドを追加したり、既存のコマンドを上書きしたりすることが自由にできます。

ノードへのシェルの接続

本番システムを運用する場合、run_erlを通じてデーモンモードでノードを起動したくなるでしょう。その方法や、デプロイと本番運用のベストプラクティスについては別途扱います。デーモンモードで起動したシステムにはデフォルトのシェルが存在しませんが、それでも別のシェルをシステムに接続できます。実際にはいくつかの方法があります。これらのほとんどは通常の分散の仕組みに依存しており、そのため前節で説明したように、両方のマシンで同じErlangクッキーを持っている必要があります。

リモートシェル(Remsh)

Erlangノードに接続する最も簡単で、おそらく最も一般的な方法は、名前付きノードを起動し、リモートシェル経由でシステムノードに接続することです。これはerlコマンドラインフラグ-remsh NodeNameで行います。-remshを使うには、別のノードに接続できるよう、自分自身も名前付きノードとして動作している必要がある点に注意してください。-remsh使用時に-name-snameフラグを指定しないと、Erlangは新しいノードにランダムな名前を生成します。いずれの場合も、シェルはリモートノードへ直接接続されるため、この名前が表示されることは通常ありません。例えば次のようになります。

$ erl -sname node4 -remsh node2
Erlang/OTP 18 [erts-7.3] [source-d2a6d81] [64-bit] [smp:8:8]
              [async-threads:10]  [kernel-poll:false]

Eshell V7.3  (abort with ^G)
(node2@GDC08)1>

あるいはElixirを使う場合は次のようになります。

$ iex --sname node4 --remsh node2
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit] [smp:4:4]
              [async-threads:10]  [kernel-poll:false]

Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(node2@GDC08)1>

Erlangノードは通常、Elixirノード(上のnode2)上でシェルを起動できますが、Erlangノード(node1)上でElixirシェルを起動しようとしてもうまくいきません。Erlangノードには必要なElixirのライブラリがないからです。

$ iex --remsh node1
Could not start IEx CLI due to reason: nofile
$
通常のErlang分散にも、リモートシェルの実装にも、セキュリティの仕組みは組み込まれていません。システムノードをインターネットに公開したままにするべきではありませんし、開発マシン上のノードから稼働中のノードへ直接接続することも避けるべきです。通常は、VPNトンネルや、踏み台ホスト経由のsshを使って本番環境にアクセスし、稼働中のノードの一つを実行しているマシンにログインしてから、そこでremshを使っていずれかのノードに接続します。

リモートシェルを起動すると、実際には二つのノードが関わっていることを理解しておくことが重要です。先ほどの例ではnode4という名前のローカルノードと、リモートノードのnode2です。これらのノードは同じマシン上にあることも、別々のマシン上にあることもあります。ローカルノードは、常にiexまたはerlコマンドをremsh付きで実行したマシン上で動作します。ローカルノードでは、ターミナルウィンドウとやり取りするttyプログラムを実行するプロセスが動いています。実際のシェルプロセスはリモートノード上で動作します。つまり、まず実行したいシェル(IExまたはErlangシェル)のコードがリモートノード上に存在している必要があるということです。また、コードがリモートノード上で実行されるということでもあります。そして、シェルのデフォルト設定がリモートマシンの設定から取られるということでもあります。

マシンGDC08のホームディレクトリに、次のような.erlangファイルがあるとします。

code:load_abs("/home/happi/.config/erlang/user_default").

io:format("ERTS is starting in ~s~n",[os:cmd("pwd")]).

そしてuser_default.erlファイルは次のようになっているとします。

-module(user_default).

-export([tt/0]).

tt() ->
  test.

続いて~/example/dir1~/example/dir2という二つのディレクトリを作成し、それぞれに異なる.iex.exsファイルを置きます。これらはプロンプトにそれぞれ<d1>または<d2>を表示するよう設定します。

# File 1
IO.puts "iEx starting in "
pwd()
IO.puts "iEx starting on "
IO.puts Node.self

IEx.configure(
  colors: [enabled: true],
  alive_prompt: [
    "\e[G",
    "(%node)",
    "%prefix",
    "<d1>",
  ] |> IO.ANSI.format |> IO.chardata_to_string
)

# File 2
IO.puts "iEx starting in "
pwd()
IO.puts "iEx starting on "
IO.puts Node.self

IEx.configure(
  colors: [enabled: true],
  alive_prompt: [
    "\e[G",
    "(%node)",
    "%prefix",
    "<d2>",
  ] |> IO.ANSI.format |> IO.chardata_to_string
)

これで、これらのディレクトリから四つの異なるノードを起動すると、シェルの設定がどのように読み込まれるかがわかります。まずnode1dir1から起動します。

GDC08:~/example/dir1$ iex --sname node1
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit]
              [smp:4:4] [async-threads:10]  [kernel-poll:false]

ERTS is starting in /home/happi/example/dir1
 on [node1@GDC08]
Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iEx starting in
/home/happi/example/dir1
iEx starting on
node1@GDC08
(node1@GDC08)iex<d1>

続いてnode2dir2から起動します。

GDC08:~/example/dir2$ iex --sname node2
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit]
              [smp:4:4] [async-threads:10]  [kernel-poll:false]

ERTS is starting in /home/happi/example/dir2
 on [node2@GDC08]
Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iEx starting in
/home/happi/example/dir2
iEx starting on
node2@GDC08
(node2@GDC08)iex<d2>

続いてnode3dir1から起動しますが、リモートシェルはnode2に対して開きます。

GDC08:~/example/dir1$ iex --sname node3 --remsh node2@GDC08
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit] [smp:4:4]
              [async-threads:10]  [kernel-poll:false]

ERTS is starting in /home/happi/example/dir1
 on [node3@GDC08]
Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iEx starting in
/home/happi/example/dir2
iEx starting on
node2@GDC08
(node2@GDC08)iex<d2>

見てのとおり、リモートシェルはdir2で起動しています。これはnode2のディレクトリだからです。最後に、Erlangノードを起動し、user_defaultモジュールで定義した関数がシェルから呼び出せることを確認します。

GDC08:~/example/dir2$ erl -sname node4
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit] [smp:4:4]
              [async-threads:10]  [kernel-poll:false]

ERTS is starting in /home/happi/example/dir2
 on [node4@GDC08]
Eshell V8.1  (abort with ^G)
(node4@GDC08)1> tt().
test
(node4@GDC08)2>

上の例からわかるように、これらのシェル設定は、シェルを実行しているノード側から読み込まれます。もし別のマシン上のノードに接続していたら、これらの設定は存在しないことになります。

起動時に-remshフラグを渡すことだけが、リモートシェルを起動する唯一の方法ではありません。ジョブ制御モードに入ることで、接続先のノードとシェルをその場で切り替えることもできます。

ジョブ制御モード

ctrl+gを押すとジョブ制御モード(JCL)に入ります。すると別のプロンプトが表示されます。

User switch command
 -->

hと入力して(続けてenter)、JCLで使える利用可能なコマンドのヘルプテキストを表示できます。

  c [nn]            - connect to job
  i [nn]            - interrupt job
  k [nn]            - kill job
  j                 - list all jobs
  s [shell]         - start local shell
  r [node [shell]]  - start remote shell
  q                 - quit erlang
  ? | h             - this message

ここで注目すべきなのはリモートシェルを起動するrコマンドです。実行したいシェルの名前を指定できます。デフォルトは標準のErlangシェルなので、Elixirシェルを起動したい場合はこれが必要になります。新しいジョブ(つまり新しいシェル)を起動したら、cコマンドでそのジョブに接続する必要があります。jですべてのジョブを一覧表示することもできます。

(node2@GDC08)iex<d2>
User switch command
 --> r node1@GDC08 'Elixir.IEx'
 --> c
Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iEx starting in
/home/happi/example/dir1
iEx starting on
node1@GDC08

sで新しいローカルシェルを起動し、代わりにそちらへ接続するのは、非常に時間のかかるコマンドを実行中で、その間に別の作業をしたい場合に便利です。実行中のコマンドが止まっているように見える場合は、iで割り込むか、kでそのシェルを終了させて新しいシェルを起動できます。

JCLモードの完全な説明については、Erlang Shellのマニュアルを参照してください。

ctrl+g q [enter]と入力すればセッションを終了できます。これによりローカルノードがシャットダウンします。q().halt()init:stop()System.haltのいずれでも終了してはいけません。これらはすべてリモートノードを落としてしまい、稼働中のサーバーに接続しているときにそれを望むことはまずないでしょう。代わりにctrl+\ctrl+c ctrl+cctrl+g q [enter]、またはctrl+c a [enter]を使ってください。

リモートシェルを使いたくない場合、つまりErlangランタイムシステムを二つ動かす必要をなくしたい場合、ノードに接続する方法は実はあと二つあります。Unixパイプ経由、またはssh経由で直接接続することもできますが、どちらの方法も、接続したいノードを特別な方法で起動しておくか、sshサーバーを起動しておく必要があります。

パイプ経由の接続

run_erlコマンドでノードを起動すると、IO用の名前付きパイプが作られ、新しいノードを起動することなく、そのパイプにシェルを接続できます。次の章で見るように、ただErlangをデーモンモードで起動するのではなくrun_erlを使うことには、標準入出力を失わないなどの利点があります。

run_erlコマンドは、パイプを実装しているUnix系OS上でのみ利用できます。run_erlでシステムを起動する場合は、次のようになります。

> run_erl -daemon log/erl_pipe log "erl -sname node1"

あるいは次のようになります。

> run_erl -daemon log/iex_pipe log "iex --sname node2"

その後、名前付きパイプ(run_erlの最初の引数)を通じてシステムに接続できます。

> to_erl dir1/iex_pipe

iex(node2@GDC08)1>

EOF(ctrl+d)を送るとシェルを終了でき、システムはバックグラウンドで動き続けます。

to_erlではターミナルが稼働中のノードに直接接続されるため、ctrl-cctrl-g q [enter]と入力すると、そのノードを落としてしまいます。これはおそらく望んでいないことでしょう。run_erlを使うときは、+Biフラグも一緒に使うとよいでしょう。このフラグはctrl-cシグナルを無効にし、ctrl-gメニューからqオプションを取り除きます。

ノードに接続する最後の方法は、ssh経由です。

SSH経由の接続

Erlangにはビルトインのsshサーバーが付属しており、ノード上でこれを起動すれば直接接続できます。これはErlangの分散の仕組みとは完全に別物なので、システムを-name-sname付きで起動する必要はありません。sshモジュールのドキュメントに詳細が説明されています。手早く試すだけなら、ssh-keygenで生成できるサーバー鍵があれば十分です。

> mkdir ~/ssh-test/
> ssh-keygen -t rsa -f ~/ssh-test/ssh_host_rsa_key

続いてErlangノード上でsshデーモンを起動します。

gds01> erl
Erlang/OTP 18 [erts-7.3] [source-d2a6d81] [64-bit] [smp:8:8]
              [async-threads:10]  [kernel-poll:false]

Eshell V7.3  (abort with ^G)
1> ssh:start().
{ok,<0.47.0>}
2> ssh:daemon(8021, [{system_dir, "/home/happi/ssh-test"},
                     {auth_methods, "password"},
                     {password, "pwd"}]).
system_dirのデフォルトは/etc/sshですが、そこにある鍵はrootユーザーにしか読み取れません。そのため、この例では自分自身の鍵を作成しています。

これで別のマシンから接続できます。素のままのsshコマンドで接続しているにもかかわらず、直接Erlangシェルに入ることに注意してください。

happi@GDC08:~> ssh -p 8021 happi@gds01
happi@gds01's password: [pwd]
Eshell V7.3  (abort with ^G)
1>

実運用では、ドキュメントに書かれている通りにサーバーとユーザーのsshキーを設定したくなるでしょう。最低限、もっとましなパスワードにしたいところです。

このシェルではJCLモード(ctrl+g)にもBREAKモード(ctrl+c)にもアクセスできません。q()halt()init:stop()を入力するとリモートノードが落ちます。シェルから切断するにはexit()と入力してシェルセッションを終了するか、ターミナルウィンドウを閉じます。

BREAKモードは、開発やプロファイリング、デバッグの際に非常に強力です。次はこれを見ていきましょう。

ブレークモードの出入り

ctrl+cを押すとBREAKモードに入ります。これは、a [enter]とタイプしてabortするか、もう一度ctrl+cを押すだけで、単純にノードを終了させる目的で使われることがほとんどです。しかし実は、このモードを使ってErlangランタイムシステムの内部をのぞき見ることもできます。

BREAKモードに入ると、短いメニューが表示されます。

BREAK: (a)bort (A)bort with dump (c)ontinue (p)roc info (i)nfo
       (l)oaded (v)ersion (k)ill (D)b-tables (d)istribution

c [enter](continue)はシェルに戻ります。A [enter]を使えば、(コアダンプとは異なる)強制的なクラッシュダンプを取ってノードを終了させ、デバッグ目的で使うこともできます(クラッシュダンプについてはデバッグの章を参照してください)。

p [enter]を押すと、システム内のすべてのプロセスに関する内部情報が得られます。この情報が何を意味するかは、プロセスの章で詳しく見ていきます。

i [enter]で、ノード内のメモリとメモリアロケータに関する情報も取得できます。メモリの章で、この情報の読み解き方を見ていきます。

l [enter]でロード済みのすべてのモジュールとそのサイズを、v [enter]でシステムバージョンを確認できます。k [enter]ではすべてのプロセスを順に確認しながら調査したり終了させたりできます。大文字のD [enter]はシステム内のすべてのETSテーブルに関する情報を、小文字のd [enter]は分散に関する情報(基本的にはノード名だけ)を表示します。

OPPROFやDEBUG付きでランタイムをビルドしていれば、さらに多くの情報を得られます。その方法はERTSのビルドの章で見ていきます。BREAKモードを実装しているコードはerts/emulator/beam/break.cにあります。

ctrl+cを押したときにBREAKメニューを表示せず、すぐにノードをシャットダウンさせたい場合は、erl+Bdオプションを渡します。これは、DockerなどでErlangを動かす際に一般的に望まれる挙動です。また、ctrl-cを現在のシェルコマンドの中断だけにする+Bcという変種もあり、対話的な作業には便利です。

BREAKモードに入るとノードが停止する点に注意してください。これは本番システムで行いたいことではありません。しかし、テストシステムでデバッグやプロファイリングを行う際には、このモードがバグやボトルネックを見つける助けになります。本書の後の部分でもこのことを見ていきます。