Perl Hackers Hub

第38回Perlで作るシステム運用ツール(2)

前回の(1)こちらから。

Perlとシェルとの連携

ここからはデプロイスクリプトを例に、Perlで運用をサポートする勘所を見ていきます。

UNIX系システムの日々の運用では、コマンドを多用します。Perlは、プログラムからコマンドを呼ぶ複数の方法を持っています。今回はその中からsystem関数、``(バッククオート⁠⁠、IPC::Open3の3つを紹介します。

system関数でコマンドを呼ぶ

systemはPerl組込みの関数で、引数に渡されたコマンドを実行します。リスト1のようなプログラムを実行すると、カレントディレクトリのファイル一覧が表示されます。

リスト1 system関数によるコマンドの実行
use strict;
use warnings;

# カレントディレクトリのファイル名一覧を表示する
system "ls";

この関数に渡されたコマンドは内部的にはforkで作られた子プロセス上で実行され、プログラムはその子プロセスの終了を待ちます。⁠子プロセス」と何の前置きもなしに言ってしまいましたが、プロセスについてよくわからないという人は、拙著process-bookがWeb上で無料公開されていますので、ぜひ読んでみてください。

system関数は、引数が1つの場合と複数の場合と間接オブジェクト記法を利用した場合で挙動が異なります。

引数が1つの場合

引数が1つの場合で、なおかつその中にシェルのメタ文字が含まれている場合、その引数はシェルに渡され、展開されますリスト2⁠。

リスト2 シェルのメタ文字を含む文字列を引数にした場合のsystemの挙動
use strict;
use warnings;

# カレントディレクトリのファイル名一覧を表示する
system "echo ./*";

シェルのメタ文字が含まれていない場合は、空白によって分割され、それらがシェルを経由せずにexecvp(3)に直接渡されます。この場合はシェルを起動するコストがないため、より効率的に実行できます。

execvp(3)とは、いわゆるexecシステムコールの本体です。プログラムの中でexecシステムコールを呼び出すと、そのプロセスはexecの引数に渡されたプログラムで置き換えられ、実行されます。プロセスが置き換えられてしまっているため、execで実行されたプログラムが終了しても、呼び出し元のプログラムには処理が戻ることはありません。

前述の通り、system関数では子プロセス上でexecが実行されます。これにより、親プロセスが置き換えられることを防ぎ、systemで実行されたプログラムが終了したあとに、呼び出し元のプログラムに処理を戻しています。

引数が2つ以上の場合

引数が2つ以上ある場合も、それらの引数は直接execvp(3)に渡され、シェルを経由しません。そのため、シェルのメタ文字もシェルによって解釈されず、直接渡されます。リスト3ならば、./*がシェルで展開されず、そのまま./*という文字列が表示されます。

リスト3 引数が2つ以上ある場合のsystemの挙動
use strict;
use warnings;

# "./*"という文字列を表示する
system "echo", "./*";

引数が1つでシェルのメタ文字を含まない場合と、引数が2つ以上ある場合は、どちらもシェルを経由せずに直接execvp(3)に渡されるわけですが、その挙動は微妙に異なります。それを確認するために、まずは渡された引数の数を表示するだけのPerlプログラムを書いて、そのプログラムをsystem関数で実行してみましょうリスト4、リスト5⁠。

リスト4 引数の数を表示するプログラム(print_argc.pl)
#!/usr/bin/env perl
use strict;
use warnings;

print scalar @ARGV, "\n";
リスト5 systemでprint_argc.plを呼ぶ
use strict;
use warnings;

my @command = ("./print_argc.pl", "a", "hello world");
my $command_string = join(" ", @command);

system @command; # => 2
system $command_string; # => 3

リスト5を見るとわかるとおり、単一の引数の場合は、引数に「空白を含む文字列」を渡したときにその空白によって引数が分解され、結果として"hello world"という文字列は"hello""world"に分解されてしまいます。一方で、2つ以上の引数を取った場合、Perlで定義したままプログラムの引数として渡されていることが確認できます。

常にシェルを経由しない安全な呼び出しを行う

シェルを経由してコマンドを呼び出す場合、*が展開されてしまって意図しない挙動になるなど、安全でない呼び出しになってしまいそうです。そこで、常にシェルを経由せずにexecvp(3)に渡される方法はないのでしょうか。実は、間接オブジェクト記法というものを使うことで、常にシェルを経由せずにコマンドを呼び出せます。

間接オブジェクト記法とは、func a b, c;のように、最初の引数のあとにカンマを書かない関数の呼び出し方法です。systemに対してリスト6のように間接オブジェクト記法を利用することで、

  • シェルを経由せず最初の引数を実行する
  • そのプログラムの引数として、3つ目以降の引数が渡される
  • プログラムの「名前」psCMDに表示されるもの)は2つ目以降の引数を合わせたものとなる

という挙動を実現できます。この場合は、systemの最初の引数に"yes"を渡しているため、yesコマンドが実行されます。yesコマンドの引数には@commandsの2つ目以降、つまり"hello world"が渡されますので、このプログラムを実行するとコンソールに延々とhello worldが出力され続けます。そして、このプロセスを別のターミナルからpsなどで確認すると、CMDのところには!!!yes!!! hello worldと表示されます。

リスト6 確実にシェルを経由せずにコマンドを実行する
use strict;
use warnings;

my @commands = ("!!!yes!!!", "hello world");

system {"yes"} @commands;

コマンドの終了コードを取得する

system関数は、内部的にはforkした子プロセスでコマンドを実行するというのは前述のとおりですが、戻り値としてその終了コードを返します。正常に終了したコマンドは0を終了コードとして返すので、戻り値を見ることで、コマンドの成功/失敗を確認できるわけです。

注意点として、実際の終了コードを得るためには返された値を右に8ビットシフトする必要がありますが、0を右にビットシフトしても0ですので、コマンドが正常に成功したかどうかを見るだけならば、0であるかどうかを確認するだけで問題ありませんリスト7⁠。

リスト7 system関数の戻り値で終了コードを取得する
use strict;
use warnings;

my $failed = system "false";
print $failed . "\n"; # => 256

my $succeeded = system "true";
print $succeeded . "\n"; # => 0

system関数を利用してデプロイスクリプトを作る

さて、今回はデプロイスクリプトを例にとると言いましたが、紙幅の都合上、静的ファイルを複数のサーバに配るだけのデプロイスクリプトを考えます。rsyncで配ってもよいのですが、複数のサーバに配るという性格上、

  • どこかのサーバへのデプロイに失敗したらロールバックする
  • 複数のサーバのファイルをなるべく同じタイミングで更新する

という2点を重視し、静的ファイルをtarでアーカイブしたものを複数のサーバに配り、タイムスタンプから作ったディレクトリに対して中身を配置し、すべてのサーバに無事に配置できたらシンボリックリンクをそのディレクトリに対して張る、という戦略でいきます。

まずは特定のディレクトリをtarでアーカイブするスクリプトを書いてみましょうリスト8⁠。

リスト8  staticというディレクトリをアーカイブするスクリプト
use strict;
use warnings;
use File::Basename qw/dirname/;

my $static_file_dir = dirname(__FILE__) . "/static";
my $tar_command =
  ["tar", "zcvf", "static.tgz", $static_file_dir];

my $ret = system @$tar_command;
if ( $ret != 0 ) {
  die "archive failed";
}

次に、アーカイブしたファイルをサーバへ配る部分を書いていきたいところですが、Perlにはsystem関数以外にもコマンドを呼ぶ方法が存在します。そこで、それぞれの方法の違いを確認するためにも、ほかの方法について先に見ておきましょう。

バッククオートでコマンドを呼ぶ

前述のとおり、system関数はforkしてできた子プロセスでコマンドを実行します。そのため、そのままだと子プロセスが出力する標準出力や標準エラー出力をキャプチャできません。

単純にコマンドの標準出力の結果を得たい場合に一番お手軽なのは、バッククオートを利用することです。

標準出力をキャプチャする

実際にコマンドの標準出力を取得してみるコードがリスト9です。バッククオートによるコマンド実行は、system関数と違い必ずシェルによって解釈されることに気を付けてください。

リスト9 コマンドの標準出力を変数にキャプチャ
use strict;
use warnings;

my $out = `echo hello world`;
print $out . "\n"; # => hello world

コマンドの終了コードを取得する

バッククオートを使ったコマンド実行の終了コードを取りたいときは、$?という特殊変数を利用しますリスト10⁠。ここでも、実際の終了コードを得るためには右に8ビットシフトする必要がありますが、systemのときと同様に、コマンドが正常に成功したかどうかを見るだけならば、0であるかどうかを確認するだけでかまいません。

リスト10 バッククオートを利用した場合の終了コードの取得
use strict;
use warnings;

`false`;
print $?. "\n"; #=> 256

`true`;
print $?. "\n"; #=> 0

IPC::Open3でコマンドを呼ぶ

バッククオートを利用して取得できるのは標準出力だけです。標準エラー出力も取得したい場合は、IPC::Open3というモジュールを利用することで、より細かい制御を行えます。

IPC::Open3のopen3関数も子プロセスを生成しそこでコマンドを実行しますが、引数に、子プロセスの標準入力、標準出力、標準エラー出力それぞれへつながったファイルハンドルと、コマンドを渡すことができます。

標準出力、標準エラー出力の両方をキャプチャする

では、実際にその挙動を見てみましょうリスト11⁠。リスト11のプログラムを実行すると、

stdin
stdout
stderr
0

という文字列がコンソールに表示されます。

リスト11 標準エラーもキャプチャする
use strict;
use warnings;
use IPC::Open3;

my ($wtr, $rdr, $err);
$err = Symbol::gensym;  ━(1)

my $script = 'print <>; print "stdout\n"; warn "stderr\n";';  ━(2)
my $command = ['perl', '-e', $script];
my $pid = open3($wtr, $rdr, $err, @$command);  ━(3)

print $wtr "stdin\n";  
close($wtr);           ┛(4)
print <$rdr>;    
print <$err>;    ┛(5)

waitpid($pid, 0);
print $?;

リスト11(2)$script変数にはPerlのワンライナーが格納されています。その内容は、標準入力を読み込んでそれを標準出力に出力し、次に"stdout"という文字列を標準出力に出力し、そして"stderr"という文字列を標準エラー出力に出力するというものです。

リスト11は、このワンライナーをopen3で実行し、ワンライナー側のプログラムの標準入出力とパイプを経て通信しています。そのあと、ワンライナー側のプログラムの終了コード(今回ならば0)を取得し、表示しています。

少し複雑なプログラムなので、内容をもう少し詳しく見ていきましょう。

リスト11(3)では、open3関数に@$commandを渡すことで、ワンライナーを子プロセスで実行しています。その際、$wtr$rdr$errという変数も同時に渡していますが、$wtrは子プロセスの標準出力に書き込むためのファイルハンドル、$rdrには子プロセスの標準出力を受け取るためのファイルハンドル、$errには子プロセスの標準エラー出力を受け取るためのファイルハンドルが代入されます。

一点注意したいのが、open3に渡す前に、リスト11(1)$errSymbol::gensymを渡しているところです。open3関数は、第3引数に偽値を渡すと、子プロセスの標準エラー出力を子プロセスの標準出力にdup(3)します。もっとありていに言えば、標準エラー出力と標準出力をまとめて第2引数のファイルハンドルに書き込むようになる、ということです。

これを防ぐために、$errundefであってはいけません。Perlではスカラコンテキストにおけるundefは偽値ですので、undefを渡すと、子プロセスの標準エラー出力に出力されたものも第2引数に渡した変数のファイルハンドルに書き込まれてしまいます。そのため、$errにあらかじめSymbol::gensymを代入することで、$errundefと評価されないようにしています。逆に言うと、標準出力も標準エラー出力も一緒くたに扱ってよい場合は、第3引数には0undefを渡してあげるとよい、ということです。詳細は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)こちら。>

WEB+DB PRESS

本誌最新号をチェック!
WEB+DB PRESS Vol.130

2022年8月24日発売
B5判/168ページ
定価1,628円
(本体1,480円+税10%)
ISBN978-4-297-13000-8

  • 特集1
    イミュータブルデータモデルで始める
    実践データモデリング

    業務の複雑さをシンプルに表現!
  • 特集2
    いまはじめるFlutter
    iOS/Android両対応アプリを開発してみよう
  • 特集3
    作って学ぶWeb3
    ブロックチェーン、スマートコントラクト、NFT

おすすめ記事

記事・ニュース一覧