前回の(1)はこちらから。
Perlとシェルとの連携
ここからはデプロイスクリプトを例に、Perlで運用をサポートする勘所を見ていきます。
UNIX系システムの日々の運用では、コマンドを多用します。Perlは、プログラムからコマンドを呼ぶ複数の方法を持っています。今回はその中からsystem
関数、``
(バッククオート)、IPC::Open3の3つを紹介します。
system関数でコマンドを呼ぶ
system
はPerl組込みの関数で、引数に渡されたコマンドを実行します。リスト1のようなプログラムを実行すると、カレントディレクトリのファイル一覧が表示されます。
この関数に渡されたコマンドは内部的にはfork
で作られた子プロセス上で実行され、プログラムはその子プロセスの終了を待ちます。「子プロセス」と何の前置きもなしに言ってしまいましたが、プロセスについてよくわからないという人は、拙著「process-book」がWeb上で無料公開されていますので、ぜひ読んでみてください。
system
関数は、引数が1つの場合と複数の場合と間接オブジェクト記法を利用した場合で挙動が異なります。
引数が1つの場合
引数が1つの場合で、なおかつその中にシェルのメタ文字が含まれている場合、その引数はシェルに渡され、展開されます(リスト2)。
シェルのメタ文字が含まれていない場合は、空白によって分割され、それらがシェルを経由せずにexecvp(3)
に直接渡されます。この場合はシェルを起動するコストがないため、より効率的に実行できます。
execvp(3)
とは、いわゆるexec
システムコールの本体です。プログラムの中でexec
システムコールを呼び出すと、そのプロセスはexec
の引数に渡されたプログラムで置き換えられ、実行されます。プロセスが置き換えられてしまっているため、exec
で実行されたプログラムが終了しても、呼び出し元のプログラムには処理が戻ることはありません。
前述の通り、system
関数では子プロセス上でexec
が実行されます。これにより、親プロセスが置き換えられることを防ぎ、system
で実行されたプログラムが終了したあとに、呼び出し元のプログラムに処理を戻しています。
引数が2つ以上の場合
引数が2つ以上ある場合も、それらの引数は直接execvp(3)
に渡され、シェルを経由しません。そのため、シェルのメタ文字もシェルによって解釈されず、直接渡されます。リスト3ならば、./*
がシェルで展開されず、そのまま./*
という文字列が表示されます。
引数が1つでシェルのメタ文字を含まない場合と、引数が2つ以上ある場合は、どちらもシェルを経由せずに直接execvp(3)
に渡されるわけですが、その挙動は微妙に異なります。それを確認するために、まずは渡された引数の数を表示するだけのPerlプログラムを書いて、そのプログラムをsystem
関数で実行してみましょう(リスト4、リスト5)。
リスト5を見るとわかるとおり、単一の引数の場合は、引数に「空白を含む文字列」を渡したときにその空白によって引数が分解され、結果として"hello world"
という文字列は"hello"
と"world"
に分解されてしまいます。一方で、2つ以上の引数を取った場合、Perlで定義したままプログラムの引数として渡されていることが確認できます。
常にシェルを経由しない安全な呼び出しを行う
シェルを経由してコマンドを呼び出す場合、*が展開されてしまって意図しない挙動になるなど、安全でない呼び出しになってしまいそうです。そこで、常にシェルを経由せずにexecvp(3)
に渡される方法はないのでしょうか。実は、間接オブジェクト記法というものを使うことで、常にシェルを経由せずにコマンドを呼び出せます。
間接オブジェクト記法とは、func a b, c;
のように、最初の引数のあとにカンマを書かない関数の呼び出し方法です。system
に対してリスト6のように間接オブジェクト記法を利用することで、
- シェルを経由せず最初の引数を実行する
- そのプログラムの引数として、3つ目以降の引数が渡される
- プログラムの「名前」(
psの
CMD
に表示されるもの)は2つ目以降の引数を合わせたものとなる
という挙動を実現できます。この場合は、system
の最初の引数に"yes"
を渡しているため、yes
コマンドが実行されます。yes
コマンドの引数には@commands
の2つ目以降、つまり"hello world"
が渡されますので、このプログラムを実行するとコンソールに延々とhello world
が出力され続けます。そして、このプロセスを別のターミナルからps
などで確認すると、CMD
のところには!!!yes!!! hello world
と表示されます。
コマンドの終了コードを取得する
system
関数は、内部的にはfork
した子プロセスでコマンドを実行するというのは前述のとおりですが、戻り値としてその終了コードを返します。正常に終了したコマンドは0を終了コードとして返すので、戻り値を見ることで、コマンドの成功/失敗を確認できるわけです。
注意点として、実際の終了コードを得るためには返された値を右に8ビットシフトする必要がありますが、0を右にビットシフトしても0ですので、コマンドが正常に成功したかどうかを見るだけならば、0であるかどうかを確認するだけで問題ありません(リスト7)。
system関数を利用してデプロイスクリプトを作る
さて、今回はデプロイスクリプトを例にとると言いましたが、紙幅の都合上、静的ファイルを複数のサーバに配るだけのデプロイスクリプトを考えます。rsync
で配ってもよいのですが、複数のサーバに配るという性格上、
- どこかのサーバへのデプロイに失敗したらロールバックする
- 複数のサーバのファイルをなるべく同じタイミングで更新する
という2点を重視し、静的ファイルをtarでアーカイブしたものを複数のサーバに配り、タイムスタンプから作ったディレクトリに対して中身を配置し、すべてのサーバに無事に配置できたらシンボリックリンクをそのディレクトリに対して張る、という戦略でいきます。
まずは特定のディレクトリをtarでアーカイブするスクリプトを書いてみましょう(リスト8)。
次に、アーカイブしたファイルをサーバへ配る部分を書いていきたいところですが、Perlにはsystem
関数以外にもコマンドを呼ぶ方法が存在します。そこで、それぞれの方法の違いを確認するためにも、ほかの方法について先に見ておきましょう。
バッククオートでコマンドを呼ぶ
前述のとおり、system
関数はfork
してできた子プロセスでコマンドを実行します。そのため、そのままだと子プロセスが出力する標準出力や標準エラー出力をキャプチャできません。
単純にコマンドの標準出力の結果を得たい場合に一番お手軽なのは、バッククオートを利用することです。
標準出力をキャプチャする
実際にコマンドの標準出力を取得してみるコードがリスト9です。バッククオートによるコマンド実行は、system
関数と違い必ずシェルによって解釈されることに気を付けてください。
コマンドの終了コードを取得する
バッククオートを使ったコマンド実行の終了コードを取りたいときは、$?
という特殊変数を利用します(リスト10)。ここでも、実際の終了コードを得るためには右に8ビットシフトする必要がありますが、system
のときと同様に、コマンドが正常に成功したかどうかを見るだけならば、0であるかどうかを確認するだけでかまいません。
IPC::Open3でコマンドを呼ぶ
バッククオートを利用して取得できるのは標準出力だけです。標準エラー出力も取得したい場合は、IPC::Open3というモジュールを利用することで、より細かい制御を行えます。
IPC::Open3のopen3
関数も子プロセスを生成しそこでコマンドを実行しますが、引数に、子プロセスの標準入力、標準出力、標準エラー出力それぞれへつながったファイルハンドルと、コマンドを渡すことができます。
標準出力、標準エラー出力の両方をキャプチャする
では、実際にその挙動を見てみましょう(リスト11)。リスト11のプログラムを実行すると、
という文字列がコンソールに表示されます。
リスト11(2)の$script
変数にはPerlのワンライナーが格納されています。その内容は、標準入力を読み込んでそれを標準出力に出力し、次に"stdout"
という文字列を標準出力に出力し、そして"stderr"
という文字列を標準エラー出力に出力するというものです。
リスト11は、このワンライナーをopen3
で実行し、ワンライナー側のプログラムの標準入出力とパイプを経て通信しています。そのあと、ワンライナー側のプログラムの終了コード(今回ならば0)を取得し、表示しています。
少し複雑なプログラムなので、内容をもう少し詳しく見ていきましょう。
リスト11(3)では、open3
関数に@$command
を渡すことで、ワンライナーを子プロセスで実行しています。その際、$wtr
、$rdr
、$err
という変数も同時に渡していますが、$wtr
は子プロセスの標準出力に書き込むためのファイルハンドル、$rdr
には子プロセスの標準出力を受け取るためのファイルハンドル、$err
には子プロセスの標準エラー出力を受け取るためのファイルハンドルが代入されます。
一点注意したいのが、open3
に渡す前に、リスト11(1)で$err
にSymbol::gensym
を渡しているところです。open3
関数は、第3引数に偽値を渡すと、子プロセスの標準エラー出力を子プロセスの標準出力にdup(3)
します。もっとありていに言えば、標準エラー出力と標準出力をまとめて第2引数のファイルハンドルに書き込むようになる、ということです。
これを防ぐために、$err
はundef
であってはいけません。Perlではスカラコンテキストにおけるundef
は偽値ですので、undef
を渡すと、子プロセスの標準エラー出力に出力されたものも第2引数に渡した変数のファイルハンドルに書き込まれてしまいます。そのため、$err
にあらかじめSymbol::gensym
を代入することで、$err
がundef
と評価されないようにしています。逆に言うと、標準出力も標準エラー出力も一緒くたに扱ってよい場合は、第3引数には0
やundef
を渡してあげるとよい、ということです。詳細はIPC::Open3のドキュメントを確認してください。
さて、open3
関数で子プロセスで実行されたPerlワンライナーは、まずは標準入力を読み込もうとしてブロックします。これに対して親プロセスが書き込みを行っているのがリスト11(4)です。親プロセスのほうでprint $wtr "stdin\n";
とすることで、そこに"stdin\n"
という文字列を書き込みます。そして、close($wtr);
することで子プロセスの標準入力へのファイルハンドルをcloseし、子プロセスは入力待ちのブロック状態から抜け、"stdin"
を出力します。
リスト11(5)は子プロセスで動いているワンライナーからの出力を読み込む部分です。そのあと子プロセスは標準出力に対して"stdout\n"
という文字列を書き込みます。子プロセスの標準出力は親プロセスの$rdr
とつながっているので、親プロセスではこれを<$rdr>
で読み込み、そのまま出力します。同様に、子プロセスは"stderr\n"
を標準エラー出力に書き込み、親プロセスは<$err>
として$err
を読み込んでいます。
このように、open3
関数を利用することで、コマンドの標準入出力および標準エラー出力を細かく制御できます。プログラムでコマンドの出力結果から何かを判断したり加工したりしたい場合は、open3
関数を利用するとよいでしょう。
標準出力、標準エラー出力の両方を扱う場合の注意点
注意点として、リスト11は標準出力も標準エラー出力も少量しかなかったため問題にはなりませんが、たとえばリスト11で標準エラー出力に延々と何かを書き込むような子プロセスを実行するとデッドロックが起こります。これは、パイプのバッファには上限があり、そのバッファを超えて書き込もうとすると書き込み側がブロックするためです(このようなしくみをバックプレッシャーと呼びます)。
それを理解したうえでリスト11を見てみると、仮に子プロセスで延々と標準エラー出力に書き込み続けた場合は、親プロセスは子プロセスの標準出力の読み込み待ちでブロックし、子プロセスは標準エラー出力の書き込み待ちでブロックしてしまいます。
そのような場合は、IO::Selectなどを利用して「読み込めるようになっているファイルハンドルから読み込む」というような動きにする必要があるでしょう。
コマンドの終了コードを取得する
コマンドの終了ステータスを知るためには、waitpid
でそのプロセスの終了を待ち、$?
特殊変数を調べます。ここでも実際の終了コードを知るためには右に8ビットシフトする必要がありますが、正常に終了したかどうかを調べるためには0かどうかを見るだけでかまいません。今回は正常に子プロセスが終了しているため、0が入っています。
<続きの(3)はこちら。>
- 特集1
イミュータブルデータモデルで始める
実践データモデリング
業務の複雑さをシンプルに表現!
- 特集2
いまはじめるFlutter
iOS/Android両対応アプリを開発してみよう
- 特集3
作って学ぶWeb3
ブロックチェーン、スマートコントラクト、NFT