Hateburo: kazeburo hatenablog

Operations Engineer / Site Reliability / 運用系小姑 / Perl Monger

GCP HTTP(S) load balancing 配下のnginxでクライアントIPを取得する方法

GCP HTTP(S) load balancing の X-Forwarded-For ヘッダは少し変わっているのでメモ。

X-Forwarded-For とクライアントIP

ELBや他のproxyを使って、その配下のサーバにリクエスト元のIPアドレスを伝える際には、X-Forwarded-For ヘッダが使われます。

X-Forwarded-For: $remote_addr

リクエストにすでにX-F-Fヘッダがあった場合は、後ろに追加します。

X-Forwarded-For: $http_x_forwarded_for, $remote_addr

となります。

X-F-Fを受け取ったサーバでは、アクセス元のIPが信用できるIPアドレスまたはIP帯域の場合に、X-F-Fの最後のIPアドレスを、remote_addrとして利用します。

nginxでは ngx_http_realip_module を使います。

set_real_ip_from  192.168.1.0/24;
real_ip_header    X-Forwarded-For;

アクセス元が 192.168.1.0/24 の場合、X-F-Fの最後のIPを利用するという意味です。

GCP HTTP(S) load balancing の X-F-F

本題のGCP HTTP(S) load balancingの場合、X-F-Fは

X-Forwarded-For: 1.2.3.4, 35.186.x.x

となります。この場合、1.2.3.4 が実際のclientのIPアドレス、35.186.x.xがロードバランサーに割り当てたIPアドレスです。また、アクセス元のIPアドレスは、以下の帯域(2017/2/21現在)となります。

130.211.0.0/22
35.191.0.0/16

この状態で、

まず

set_real_ip_from 130.211.0.0/22;
set_real_ip_from 35.191.0.0/16;
real_ip_header   X-Forwarded-For;

という設定をいれると、nginxはアクセス元を信用してX-F-Fの最後の一つをクライアントIPだとします。この場合は 35.186.x.x になってしまい、クライアントIPではありません。

さらに設定が必要になります。

set_real_ip_from 130.211.0.0/22;
set_real_ip_from 35.191.0.0/16;
set_real_ip_from 35.186.x.x;
real_ip_header   X-Forwarded-For;
real_ip_recursive on;

set_real_ip_from 35.186.x.xreal_ip_recursive を追加しました。real_ip_recursiveを有効にすると信用できるIPである限り再帰的にX-F-Fをたどっていきます。set_real_ip_fromにロードバランサーのIPを追加することで、35.186.x.xを信用し、次のIPアドレスをクライアントのIPとして採用ができるようになります。

この設定でようやく$remote_addrが1.2.3.4となりました。

まとめ

IPアドレスの追加の機会はそれほど回数がないので、IPアドレスをバランサーに追加した際にset_real_ip_from の追加を忘れるということは必ず発生するでしょう(発生した)。

アクセスログで正しくIPアドレスが解決ができているかの監視を行ったり、blogに書くなどしてチームに共有する必要があります。

Googleの皆様におかれましては、X-F-Fだけではなく、Akamai/CloudFlareのように True-Client-IP ヘッダの採用をお願いしたく候。True-Client-IPがあれば、ロードバランサーIPアドレスをnginxのconfに書く必要がなくなり、

set_real_ip_from 130.211.0.0/22;
set_real_ip_from 35.191.0.0/16;
real_ip_header True-Client-IP;

だけとなります。

何卒 :bow:

N秒間だけクエリ実行ログを取りたい

pt-query-digestだったり調査のために、N秒間だけmysqlの全クエリのログを取得したいということはよくありますよね

そんな時はこんなコマンドを使うと簡単に指定の秒数slowlogを切り替えて保存、取得後に元に戻してくれます。

$ slowlog.pl --duration 10 -- --default-extra-file=/hoge/my.cnf -uuser

-- のあとはmysqlコマンドに渡すオプション

ソース

#!/usr/bin/perl

use strict;
use warnings;
use IO::Handle;
use Getopt::Long;
use File::Spec;

sub find_path {
    my $pg = shift;
    my $path;
    for ( split /:/, $ENV{PATH} ) {
        if ( -x "$_/$pg" ) {
            $path = "$_/$pg";
            last;
        }
    }
    $path;
}

my $duration = 10;
Getopt::Long::Configure ("no_ignore_case");
GetOptions(
    "duration=s" => \$duration,
);
my @mysqlopt = @ARGV;
$|=1;

die "duration does not seems numeric" unless $duration =~ m!^\d+$!;
$duration += 0;

my $mysql = find_path('mysql')
    or die "could not find mysql";
my $tmpdir = File::Spec->tmpdir();

my $before = <<'EOF';
SET @cur_long_query_time = @@long_query_time;
SET @cur_slow_query_log_file = @@slow_query_log_file;
SET @cur_slow_query_log = @@slow_query_log;
SET GLOBAL slow_query_log_file = "<TMP_DIR>/slow_query_<DATE>.log";
SET GLOBAL long_query_time = 0;
SET GLOBAL slow_query_log = 1;
EOF

my $after = <<'EOF';
SET GLOBAL long_query_time = @cur_long_query_time;
SET GLOBAL slow_query_log_file = @cur_slow_query_log_file;
SET GLOBAL slow_query_log = @cur_slow_query_log;
EOF

$before =~ s!<TMP_DIR>!$tmpdir!;
my @lt = localtime();
my $date = sprintf('%04d%02d%02d%02d%02d%02d',$lt[5]+1900,$lt[4],$lt[3],$lt[2],$lt[1],$lt[0]);
$before =~ s!<DATE>!$date!;

print STDERR "exec mysql to change long_query_time and slow_query_log_file\n";
print STDERR "save slowlog to $tmpdir/slow_query_$date.log\n";
my $pid = fork;
if ( defined $pid && $pid == 0 ) {
    my $stop = 0;
    local $SIG{INT} = sub {
        $stop++;
    };
    local $SIG{TERM} = sub {
        $stop++;
    };

    open(STDOUT,'>/dev/null');
    open(my $pipe, '|-', $mysql, @mysqlopt, '--sigint-ignore');
    $pipe->autoflush;
    $pipe->print($before);
    for my $i ( 0..$duration ) {
        last if $stop;
        $pipe->print("SELECT 1;\n") if $i % 7 == 0;
        sleep 1;
    }
    $pipe->print($after);
    exit;
}
print STDERR "wait $duration seconds\n";
while (wait == -1) {}
my $exit_code = $?;
if ( $exit_code != 0 ) {
    die sprintf("Error: mysql exited with code: %d", $exit_code >> 8);
}

print STDERR "finished capturing slowlog.\n";

RDSとかAuroraとかよく知りません

【29年ぶりの大雪】YAPC::Hokkaido 2016 SAPPORO へ行ってきた! 【試される大地】

JPAの新しいメンバーのもと、REBOOTされたYAPC::Asia、YAPC::Hokkaido 2016 SAPPOROに、ゲストスピーカーとして呼んでいただけたので、行ってきました。

発表資料はこちら。

メルカリの技術ブログで紹介したSRE、1年たってエンジニアの新しいロールとして少しずつ浸透してきたと思います。今回の発表ではSREとはということで、本家であるGoogleのSREについて、メルカリでの取り組みについて、そしてYAPCなので、SREとPerlを結びつけてまとめとしました。参考になれば幸いです。

Read more

libeatmydataを使ってpostfixを劇速にする [用法用量要確認]

libeatmydataというLD_PRELOADを使って、起動したプロセスのfsyncを無効化するライブラリがあったので試してみています。

libeatmydata - disable fsync and SAVE!

fsyncがないと何が嬉しいかというとクラウドのようなIOまわりの環境が弱いところで、安全性は若干犠牲になりますが、パフォーマンスを稼ぐことができるようになります。

まず perlでfsyncを発行してどうなるかstraceをつかって確認してみます。

libeatmydataなし

$ strace perl -MIO::Handle -e 'open(my $fh,">:unix","test.txt"); print $fh "test"; IO::Handle::sync($fh);'
open("test.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3
fstat(3, {st_mode=S_IFREG|0664, st_size=0, ...}) = 0
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0
write(3, "test", 4)                     = 4
fsync(3)                                = 0
close(3)  

libeatmydataあり

$ strace  eatmydata perl -MIO::Handle -e 'open(my $fh,">:unix","test.txt"); print $fh "test"; IO::Handle::sync($fh);'
open("test.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3
fstat(3, {st_mode=S_IFREG|0664, st_size=0, ...}) = 0
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0
write(3, "test", 4)                     = 4
close(3)   

IO::Handle::syncやってるはずなのに、fsyncが表示されていません。

postfixのパフォーマンス改善

某所でPostfixを経由して外部の配信サービスにメールを転送しているサーバがあり、このIO負荷が高いのが課題となってました。

f:id:kazeburo:20161111142853p:plain

一斉メール送信のタイミングでかなりIO-waitが上がっています。

postfixの構成

www.wakhok.ac.jp

がわかりやすい。

postfixはいくつかのプロセスに分かれて動作します。メールを受けるときは、smtpdのプロセスが送信者からデータを受け取り、cleanupというプロセスに渡し、cleanupプロセスが実際にdiskに書き込む流れになっています。このcleanupプロセスがメールをdiskに書き込む時にfsyncを行うので、ここだけlibeatmydataを入れ込めば良いはず。

そこで /usr/libexec/postfix に cleanup_nosync というファイルを用意

#!/bin/sh
exec /usr/bin/eatmydata /usr/libexec/postfix/cleanup $*

master.cfを変更する

cleanup   unix  n       -       n       -       0       cleanup_nosync #最後だけ変更

postfixをreloadすれば設定が反映されます。

効果の確認

以上の設定をいれたサーバにて、受け取ったメールをすべて捨てるサーバ宛に大量にメールを送信して確認しました。

まず libeatmydata 適用前

$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 1  0      0 2420292 249248 1677904    0    0     0    41    2    0  4  0 95  1  0
 1  0      0 2420284 249248 1677904    0    0     0     0 1264  325 23  2 74  0  0
 1  3      0 2387640 249248 1677904    0    0     0  1180 2660 3530 21  3 64 11  0
 0  0      0 2386628 249248 1677916    0    0     0  4356 5434 11669  9  4 77 10  0
 0  5      0 2385868 249248 1677956    0    0     0  3620 4632 10255 13  3 75  9  0
 0  0      0 2385844 249248 1678004    0    0     0  2792 4294 12083  4  4 88  4  0
 0  0      0 2385596 249248 1678052    0    0     0  3724 4772 13095  3  4 88  4  0
 0  0      0 2386200 249248 1678116    0    0     0  3408 4452 11660  4  4 86  6  0
 0  0      0 2387968 249248 1678136    0    0     0  2908 4327 10611  4  3 82 11  0
 0  0      0 2390704 249248 1678200    0    0     0  4724 5314 13459  4  5 86  6  0
 0  0      0 2388580 249248 1678240    0    0     0  5212 5520 13300  4  5 85  6  0
 3  1      0 2388208 249248 1678292    0    0     0  4716 5934 13486  4  5 86  6  0
 0  1      0 2387960 249248 1678344    0    0     0  4608 5947 13964  4  4 85  7  0
 0  1      0 2386720 249248 1678392    0    0     0  3164 4561 10527  3  4 81 11  0
 0  0      0 2386464 249248 1678424    0    0     0  4544 5555 13150  4  4 86  6  0
 0  0      0 2386092 249248 1678472    0    0     0  5052 6135 13801  4  4 85  7  0
 0  1      0 2387456 249248 1678524    0    0     0  5232 5905 13530  5  5 84  7  0
 0  0      0 2402424 249248 1678560    0    0     0   920 1203 2561  1  1 97  2  0
 0  0      0 2402424 249248 1678560    0    0     0     8  160  300  0  0 100  0  0

iowaitが5-10%前後でています。

libeatmydata 適用後

$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 0  0      0 2421320 249256 1678660    0    0     0     8  266  474  0  0 100  0  0
 0  0      0 2391500 249256 1678660    0    0     0     0 2248 4025  5  2 93  0  0
 0  0      0 2391212 249256 1678652    0    0     0     0 6185 13856  4  4 92  0  0
 0  0      0 2390824 249256 1678976    0    0     0     0 6106 13950  4  4 92  0  0
 0  0      0 2391552 249256 1678804    0    0     0     0 5932 13648  4  4 92  0  0
 0  0      0 2388644 249256 1678868    0    0     0   104 5901 13003  5  5 91  0  0
 0  0      0 2389644 249256 1678896    0    0     0     0 5552 13448  4  4 92  0  0
 4  0      0 2389280 249256 1678936    0    0     0     0 5912 13921  4  4 93  0  0
 5  0      0 2391024 249256 1679004    0    0     0     0 6185 13774  4  4 93  0  0
 1  0      0 2390528 249256 1679056    0    0     0     0 5848 13187  4  4 92  0  0
 0  0      0 2390404 249256 1679108    0    0     0   116 6250 12982  5  4 91  0  0
 0  0      0 2387496 249256 1679156    0    0     0    12 6977 13495  6  6 89  0  0
 0  0      0 2389112 249256 1679176    0    0     0     0 6074 14039  4  4 92  0  0
 0  0      0 2387616 249256 1679236    0    0     0     0 5966 13956  4  4 92  0  0
 0  0      0 2403196 249256 1679296    0    0     0     0 1412 2950  1  1 99  0  0
 0  0      0 2403444 249256 1679304    0    0     0   136  217  450  0  0 100  0  0

iowaitは常に 0。圧倒的な効果がでました。

データの安全性対策

念のため、1秒間隔でdiskへのsyncが行われるように設定しました。

$ sudo sysctl -w vm.dirty_writeback_centisecs=100

productionへの適用

f:id:kazeburo:20161111142950p:plain

IO-waitが消え、送信にかかる時間も短くなりました。やった!

Ruby 2.4.0 preview3 での pico_http_parser のベンチマーク

Ruby 2.4.0-preview3 で Hash まわりの改善があったということで、pico_http_parser のベンチマークを取ってみた

Ruby 2.4.0-preview3 リリース

GitHub - kazeburo/pico_http_parser: Fast HTTP Parser using picohttpparser

ベンチマークは。pico_http_parser/benchmark 以下のscript。結果としては1.5倍程度パフォーマンスが上がっていることが観測できました。リアルなアプリケーションでも速度アップが見込めるんじゃないかなと思います。素晴らしい

f:id:kazeburo:20161111135258p:plain

ヘッダが0個の時だけパフォーマンスが悪くなるのは、Hashの要素数が少ないときの最適化が変わったのかなと予想

最近の Plack のパフォーマンス改善まとめ 2015年11月版

OSS活動の成果発表のお時間です。

Plackの 1.0038と2015年11月27日時点のmasterにはPlack::Request、Plack::Responseのパフォーマンスをあげる変更が入ってます。その紹介とベンチマークです。

github.com

Plack 1.0038 で HTTP::Headers::Fast 0.20 につけた flatten メソッドを使うようになってます。Plack::Response->finalizeのパフォーマンス向上が期待できます。

github.com

こちらはまだmasterにmergeされた状態。リリースはされてない。POSTリクエストのパースにHTTP::Bodyではなく、HTTP::Entitiy::Parserを使い、パラメータのパースに WWW::Form::UrlEncodedを使うようになっています。

HTTP::Entity::Parserは tokuhirom の pullreq をベースに作ったモジュールで chansen の HTTP::MultiPartParserをuploadデータの解析に利用しています。GETのクエリーパラメータなどの文字列をパースする WWW::Form::UrlEncoded は XS版も用意され、インストールするだけで長いクエリパラメータの処理が超高速になります。

ベンチマーク

以下のような psgi を用意して Plackの 1.0037、10038、そしてmasterでベンチマークを行いました。

use Plack::Builder;
use Plack::Request;
my $length = 4000;
my $body = 'x'x$length;
builder {
    enable 'AccessLog', logger => sub { };
    sub {
        my $env = shift;
        my $req = Plack::Request->new($env);
        my @params = $req->param('foo');
        my $res = $req->new_response(200);
        $res->content_type('text/plain');
        $res->content_length($length);
        $res->body($body);
        $res->finilize;
    }
};

ベンチマークを行ったサーバはgcpの12コアのサーバです。下記のsysctlのチューニングを行いました

sudo sysctl -w net.core.netdev_max_backlog=8192
sudo sysctl -w net.core.somaxconn=32768
sudo sysctl -w net.ipv4.tcp_tw_recycle=1
sudo sysctl -w net.ipv4.tcp_tw_reuse=1

perlは5.20.3、Starletは 0.28、Gazelleは0.40です。

起動オプションは Starlet、Gazelleともに同じです。

plackup -s (Starlet|Gazelle) -E production --max-workers=10 --max-reqs-per-child=5000000 -a app.psgi 

ベンチマークは wrk にて取りました

./wrk -t 2 -c 8 -d 30 'http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome'

結果

f:id:kazeburo:20151127160552p:plain

1.0037から1.0038は微増。1.0038からmasterは PP版で5%ほどのパフォーマンス向上、XSになると15-20%のパフォーマンス向上となりました。WWW::Form::UrlEncoded::XSを使うと実際のアプリケーションでも差異がでるかもしれません。

以下wrkコマンドの結果

Gazelle + Plack 1.0037

$ ./wrk -t 2 -c 8 -d 30 'http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome'
Running 30s test @ http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome
  2 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   375.59us  187.59us   9.30ms   98.41%
    Req/Sec     8.58k     0.93k   12.50k    91.67%
  512632 requests in 30.00s, 1.98GB read
Requests/sec:  17085.90
Transfer/sec:     67.46MB

Gazelle + Plack 1.0038

$ ./wrk -t 2 -c 8 -d 30 'http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome'
Running 30s test @ http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome
  2 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   369.07us  188.53us   8.06ms   98.86%
    Req/Sec     8.73k   686.61    12.24k    89.50%
  520994 requests in 30.00s, 2.01GB read
Requests/sec:  17365.83
Transfer/sec:     68.56MB

Gazelle + Plack master + WWW::Form::UrlEncoded::PP

$ ./wrk -t 2 -c 8 -d 30 'http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome'
Running 30s test @ http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome
  2 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   347.94us  210.95us  10.08ms   98.87%
    Req/Sec     9.15k     0.90k   13.45k    91.33%
  546160 requests in 30.00s, 2.11GB read
Requests/sec:  18204.06
Transfer/sec:     71.87MB

Gazelle + Plack master + WWW::Form::UrlEncoded::XS

$ ./wrk -t 2 -c 8 -d 30 'http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome'
Running 30s test @ http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome
  2 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   297.28us  220.10us  10.23ms   99.33%
    Req/Sec    10.30k     0.95k   14.73k    92.00%
  614871 requests in 30.00s, 2.37GB read
Requests/sec:  20494.01
Transfer/sec:     80.91MB

Starlet + Plack 1.0037

$ ./wrk -t 2 -c 8 -d 30 'http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome'
Running 30s test @ http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome
  2 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   450.54us  175.99us   7.97ms   96.39%
    Req/Sec     7.38k     0.90k   11.11k    92.33%
  440509 requests in 30.00s, 1.71GB read
Requests/sec:  14683.24
Transfer/sec:     58.20MB

Starlet + Plack 1.0038

$ ./wrk -t 2 -c 8 -d 30 'http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome'
Running 30s test @ http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome
  2 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   443.11us  175.54us   7.51ms   96.42%
    Req/Sec     7.47k     0.91k   11.22k    92.17%
  446088 requests in 30.01s, 1.73GB read
Requests/sec:  14866.09
Transfer/sec:     58.92MB

Starlet + Plack master + WWW::Form::UrlEncoded::PP

$ ./wrk -t 2 -c 8 -d 30 'http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome'
Running 30s test @ http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome
  2 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   420.69us  177.07us   6.51ms   98.15%
    Req/Sec     7.82k   786.01    11.52k    91.33%
  467110 requests in 30.00s, 1.81GB read
Requests/sec:  15569.02
Transfer/sec:     61.71MB

Starlet + Plack master + WWW::Form::UrlEncoded::XS

$ ./wrk -t 2 -c 8 -d 30 'http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome'
Running 30s test @ http://localhost:5000/foo?foo=bar&bar=baz&q=HTTP%3A%3ARequest%3A%3ACommon&ie=UTF-8&sourceid=chrome
  2 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   373.80us  193.16us   6.98ms   98.96%
    Req/Sec     8.65k   785.91    12.77k    95.00%
  516923 requests in 30.02s, 2.00GB read
Requests/sec:  17222.14
Transfer/sec:     68.26MB

crontabのsyntax checkをTravis-CIで行う

crontabファイルをrespositoryで管理していますが、テストができてなかったので、それを解消すべくTravis-CIでやってみました。

.travis.yml

language: ruby
script:
  - cat crontab.ok.txt | crontab
  - cat crontab.fail.txt | crontab

travisで実行された様子

f:id:kazeburo:20150902145316p:plain

意外なことに簡単に動いた。

Travisはcrontabが登録されても実際にはコマンドが動かない、はず。

songmuさんのParse::Crontabも非常に良いのですが、今のプロジェクトはPerlのプロジェクトではないので若干入れにくい。

参考

おそらくはそれさえも平凡な日々: 運用におけるcrontabのテストとParse::Crontab

第25回 cron周りのベストプラクティス(1):Perl Hackers Hub|gihyo.jp … 技術評論社