Perl Hackers Hub

第25回cron周りのベストプラクティス(3)

(1)こちら⁠2)こちらから。

App::RunCron

(3)では、前回(2)に出てきたcron実行結果の通知処理やエラーハンドリングを統一的に行うことができる拙作のフレームワークApp::RunCronを紹介します。

App::RunCronとは?

cronにおけるログとエラーハンドリングの問題点

cronで実行したコマンドのエラー処理は悩ましいところです。パイプで出力を後続のコマンドに渡したところで、コマンドの成否自体は後続のコマンドからは知るすべはなく、出力結果から推測するしかありません。ログ処理とも共通することですが、コマンド終了コードがわからないまま、成功時も失敗時の別なく出力が流れてくることから、ログが埋もれてしまうというジレンマをcronは抱えています。

かといって、ジョブごとにエラー処理を書くのは、正しく書くのも難しく、書けたところで各ジョブ内に同じようなコードが書かれてしまいます。

App::RunCronによる解決

こういった背景を踏まえ、コマンドの終了コードを見て処理を切り分けるためのしくみが外部的に必要ではないかと思い、筆者が作ったのがApp::RunCronです。App::RunCronが持っている機能は次のとおりです。

  • ラッパスクリプトによって指定したコマンドの終了コードを判別する
  • 実行コマンドの終了コードに応じて処理の分岐や出力先の切り替えを行う
  • 複数の出力先を指定できる
  • メールやIRC(Internet Relay Chat)などの独自の出力先を指定できる

たとえば、コマンドの成功時にはIRCの通知にとどめ、失敗した際にはそれに加えてアラートメールを投げたり、それに加えて成功時も失敗時も出力を一律Fluentdに投げるといったことが実現できます。

cronの実行結果に応じて処理を分岐すると言えば、奥一穂さんのcronlogが知られています。実際、App::RunCronのコア部分はcronlogの実装をかなり参考にしています。cronlogは監視用途に寄った安定した一枚岩のPerlスクリプトですが、App::RunCronは独自の処理をプラガブルに記述できる点が異なります。

App::RunCronの一般的な使い方

インストール

App::RunCronはCPANに上がっているので、cpanmコマンドでインストールできます。

% cpanm App::RunCron

runcronコマンド

App::RunCronをインストールすると、runcronコマンドがインストールされます。次のように引数として実行するコマンドを受け取ります。

% runcron -- perl -E 'say "Hello"'

runcronは標準ではcronlogと同じ動きをします。つまり、コマンドにエラーがなかった場合は何も出力せず、エラーがあったときのみ出力を行います。先ほどの例では何も出力されなかったと思いますが、次のようにすれば出力されるはずです。

% runcron -- perl -E 'die "Hello"'
example.local tag:[] starting: perl -E die "Hello"
Hello at -e line 1.
command exited with code:255

正常終了した場合も出力したい場合は、runcron--reporterオプションを指定します。

% runcron --reporter=Stdout -- perl -E 'say "Hello"'
example.local tag:[] starting: perl -E say "Hello"
Hello
command exited with code:0

runcron.yml

runcronにはさまざまなオプションを指定できますが、指定が長くなってしまうため、直接オプションを指定するよりYAMLYAML Ain't Markup Language形式のconfigファイルを渡すほうがよいでしょう。configは-cオプションで渡すこともできますが、実行ディレクトリにruncron.ymlがある場合にはそれを自動的に読み出してくれます。以下にruncron.ymlのサンプルを記載します。

% cat runcron.yml
timestamp: 1
common_reporter:
  - Fluentd
  - File
  - file: tmp/log/cron%Y%m%d.log
  - "+MyApp::Reporter::IRC"
error_reporter:
  - "+MyApp::Reporter::AlertMail"

timestampを付けると、ミリ秒単位で実行時間をログに書き出してくれます。

common_reportererror_reporterそれと上記には記述されていませんがreporterオプションにそれぞれ、共通の通知先、エラー時の通知先、正常終了時の通知先を設定できます。先頭に+があるものは独自レポーターモジュールで、そうではないものはApp::RunCron::Reporter::*以下のモジュールが参照されます。

上記のruncron.ymlは次の設定となります。

  • 出力にタイムスタンプを付ける
  • Fluentd、ファイル、IRCに常時出力する
  • エラー終了時はそれに加えてメールを飛ばす

独自レポーターの書き方

App::RunCronには標準でいくつかのレポーターが同梱されていますが、基本的な機能しかありません。プロジェクト固有の要件に合わせて独自のレポーターを書くことで、App::RunCronは本来の力を発揮します。

レポーターはPerlモジュール形式で記述します。レポーターモジュールには次の2つを定義してください。

  • コンストラクタメソッドnew
  • オブジェクトメソッドrun

以下がレポーターのひな型です。

package MyApp::Reporter::Sample;
use strict;
use warnings;
use utf8;

sub new {
    my $class = shift;
    my %args = @_ == 1 ? %{$_[0]} : @_;
    bless \%args, $class;
}
sub run {
    my ($self, $runcron) = @_;
    print 'sample';
}
1;

newメソッドに設定項目が渡されてReporterオブジェクトが作られます。引数として必須の属性を持ち、それが足りない場合には例外を投げるようにしておいたほうが、runcron.ymlのテストを行った場合にエラーを検知できるのでよいでしょう。

runメソッドにはcronの実行情報が格納されたApp::RunCron のオブジェクトが渡ってきます。App::RunCronの各種アクセサに関してはApp::RunCronのドキュメントを参照してください。

独自レポーターやruncron.ymlをテストする方法

runcron.ymlや独自レポーターに不備があってcronが動かなかったら大変です。runcronはその特性上、runcron.ymlや独自レポーターに不備があった場合も指定したコマンドは極力実行されるようになっています。しかしそれらは本来誤りなく動いてほしいものなので、テストなどで検知できるに越したことはありません。ですので、App::RunCronにはruncron.ymlと独自レポーターをテストするためのテストフレームワークTest::App::RunCronを同梱しています。

Test::App::RunCronをuseすると、runcron.ymlのテストを行うruncron_yml_ok関数と、カスタムレポーターをテストするためにrunメソッドに渡されるApp::RunCronオブジェクトのモックを生成するmock_runcron関数がインポートされます。Test::App::RunCronを利用すると次のようにテストを書くことができます。

use strict;
use warnings;
use Test::More;
use Test::App::RunCron;

subtest 'runcron.yml のテスト' => sub {
  runcron_yml_ok;
  runcron_yml_ok(other_runcron.yml);
};

subtest 'MyApp::Reporter::Sample のテスト' => sub {
  use_ok 'MyApp::Reporter::Sample';
  my $mock = mock_runcron;
  MyApp::Reporter::Sample->new->run($mock);
  pass 'run ok';
};
done_testing;

App::RunCronはまだユーザも少なく、発展途上のモジュールですが、Perl以外のユーザでも汎用的に使える便利なモジュールですので、ぜひ使ってみてフィードバックをいただけると幸いです。単にcronで実行するジョブに限らず、そのほかのジョブの終了の通知処理などをラップする場合にも便利です。

cron実行に寄与する小さなツール群

crontabで指定するジョブはUNIXコマンドなので、UNIXの哲学に従って小さなツールを組み合わせてやりたいことを実現していくのがよいでしょう。これまで取り上げられませんでしたが、cronを活用するうえで重宝する小さなツールを最後に2点紹介します。

timeout─⁠─タイムアウトの時間を設定する

実行コマンドのタイムアウト時間を設定したい場合に便利なのがtimeoutコマンドです。GNU Coreutilsに含まれています。

*/1 * * * * timeout 45 command1

上記のように指定すると、45秒で処理をタイムアウトさせることができます。タイムアウト時にはデフォルトではTERMシグナルが送られる(オプションで変更も可能)ので、コマンド側で必要に応じてシグナルハンドリングを行いましょう。

setlock─⁠─排他的に処理を実行する

たとえば毎分実行するようなジョブの実行時間が1分を超えてしまったような場合であっても、cronは次分のジョブを愚直に実行します。そのようなときにジョブの排他制御を行うのに重宝するのがsetlockコマンドです。setlockはdaemontoolsに含まれています。

setlockは次のようにロックファイルを指定して、後続にコマンドを指定します。

*/1 * * * * setlock -n /tmp/command1.lock command1

これはcommand1 を毎分実行するcronですが、command1が終わっていなかった場合にはcommand1が実行されないようになっています。

setlockに-nオプションを指定すると、ロックがかかっている場合に指定のコマンドの実行を行わず即座に終了するようになっています。逆に-nを指定しない場合は、ロックの解除を待ってからコマンドを実行するようになっています。

ほかのジョブと同じロックファイルを指定することで、ジョブ間の排他制御を行うこともできます。

*/1 * * * * setlock -nx /tmp/command1.lock command1
30 * * * * setlock /tmp/command1.lock command2

setlockの-xオプションは、コマンド実行が失敗した場合(ロックされていた場合も含まれます)でもエラー終了しないオプションです。

この場合、command1は毎分起動され、ロックされていれば実行が行われません。command2は毎時30分に起動され、ロックされている場合はロックしているジョブの実行を待ってからジョブが実行されるようになっています。

応用例としてリスト3のようにsetlockを重ねることで、複数のロックファイルを作ることもできます。これはあまりメリットを感じられる例ではありませんが、複数のジョブ間で複雑な排他制御をしたい場合に効果を発揮します。

リスト3 setlockを重ねて使う例
*/1 * * * * setlock -nx /tmp/lock1.lock setlock -nx /tmp/lock2.lock command

daemontoolsにはsetlockのほかにsoftlimitというコマンドも含まれています。これは、メモリなどのリソースを制限して指定コマンドを実行してくれるもので、こちらもcronでも有用です。

まとめ

多くの人が何気なく利用してきたcronですが、しっかり使おうとすると気をつけないといけない点、運用するにあたって知っておいたほうがよい知識、より活用するうえで知っておいたほうがよいTipsなど、いろいろ奥が深い点があることを、本稿を通じて知ってもらえれば幸いです。特に、cronにまつわるもろもろの項目をテストするといった視点は、これまであまり語られてこなかったように感じます。

ただ、使いづらいものを「奥が深い」と言って使い続けることは「奥が深い症候群」に陥りかけているとも言えるでしょう。より良いcronの代替については長らく待ち望まれている領域でもあるので、腕に覚えのある方は実装してみてはいかがでしょうか。

さて、次回の執筆者は石垣憲一さんで、テーマは「Perlで困ったときの調べ方」です。

おすすめ記事

記事・ニュース一覧