PHPで圧縮されたApacheログから指定日付のログを抽出する方法と速度比較
少しマニアックなケースかも知れませんが、圧縮されたApacheログの中から指定した日付のログだけを抽出したい、という要件が出てきました。
圧縮されていなければ話は簡単で、grepコマンドなり何なりで抽出すれば良いのですが、レンタルサーバー側の都合で1日1回必ず圧縮されちゃうんですね。
しかも、日別にファイルを分けてくれるわけではなく、その日までのログを1つのGZIPにしちゃうの。そしてそのGZIPは月に1つだけ作成される。
例えば、2020年6月1日~30日のログがあったとしたら、1日の時点では1日分のログだけが 2020-06.gz みたいなファイルに入る。
15日の時点だったとしたら、1日~15日までのログが 2020-06.gz に入っているわけです。中のテキストファイルは1つだけ。
毎日、自動タスクでApacheログの検査をしたいのですが、これだと月末に近づくにつれダウンロード量が増えることに。それは大変無駄に思えたので、指定日付分のログだけ抽出してダウンロードするプログラムを作ってみよう、と思ったわけです。
zlib関数を使って圧縮されたApacheログをそのまま抽出
先日投稿した「PHPで圧縮したバイナリデータをPOSTする方法」で書いたとおり、最近Zlib関連の関数を使ってみて、こりゃ便利だなぁーと思ったので、今回もgzopen、gzgetsなどを使って直接GZIP圧縮されているログファイルを読み取ってみようと思います。
PHPのサンプルコード
$filename = '2020-06.gz';
$target_date = '2020-06-15';
$fp = gzopen($filename, 'r');
$fp2 = fopen('extract.log', 'w');
while (!gzeof($fp)) {
$line = gzgets($fp, 4096);
preg_match('/^(\S+) (\S+) (\S+) \[([^:]+):(\d+:\d+:\d+) ([^\]]+)\] \"(\S+) (.*?) (\S+)\" (\S+) (\S+) "([^"]*)" "(.*)"/', $line, $parsed);
$time_text = $parsed[4].':'.$parsed[5].' '.$parsed[6];
$dt = strtotime($time_text);
$datetime = date("Y-m-d H:i:s", $dt);
$date = substr($datetime, 0, 10);
if ($date == $target_date) {
fputs($fp2, $line);
} else if ($date > $target_date) {
break;
}
}
gzclose($fp);
fclose($fp2);
結果と解説
2020年6月分の実データ、1,291,866件のログを使って試したのですが、2020年06月15日のログ40,983件を抽出するのに約6.04秒かかりました。
ノート用のCore i3を積んだBeeBoxというテストPCで試したので、かなり遅いです。秒数はあくまで参考程度に。
↓このログの切り出し部分とか無駄っぽいですが、最終的には抽出しつつログ解析するかなと思ってこんなふうに書いています。
preg_match('/^(\S+) (\S+) (\S+) \[([^:]+):(\d+:\d+:\d+) ([^\]]+)\] \"(\S+) (.*?) (\S+)\" (\S+) (\S+) "([^"]*)" "(.*)"/', $line, $parsed);
1行目から愚直に検索しているのがバカバカしいのですが、GZIPデータはランダムアクセスを想定されておらず、バイナリーサーチは難しいんですよね。
最初、gzseek関数を使ってバイナリサーチしようとしたんですよ。
だってApacheのログファイルって必ず日付時刻順に並んでいるわけじゃないですか。それなら、ファイルサイズ取得して、その半分の位置までファイルシーク進めて、日付を取得し、対象日付より大きければもっかい半分にして……というのを繰り返したほうが早くなりますもんね。
ところが、gzseek関数って「解凍後のファイルの位置を指定する」関数なんですよ。それじゃダメじゃん。一度解凍しないと中のファイルサイズわからないし、そもそも解凍後のファイル位置を指定するってことは内部的に一度解凍しちゃってるので、結局遅いし。
GZIP圧縮された状態でシークして、そのシーク位置から解凍できればベストだったんだけどなぁ。まぁ出来ないなら仕方ない。
gzipコマンドで一度解凍してからApacheログを抽出
先述のgzopen~gzgetsは部分解凍しつつ抽出したわけですが、それならいっそ外部コマンドで一度ぜんぶ解凍しちゃったほうが早いのでは?と思い、試してみました。
PHPのサンプルコード
$filename = '2020-06.gz';
$target_date = '2020-06-15';
$command = 'gzip -cd '.$filename.' > temp.log';
system($command);
$fp = fopen('temp.log', 'r');
$fp2 = fopen('extract.log', 'w');
while (($line = fgets($fp, 4096)) !== false) {
preg_match('/^(\S+) (\S+) (\S+) \[([^:]+):(\d+:\d+:\d+) ([^\]]+)\] \"(\S+) (.*?) (\S+)\" (\S+) (\S+) "([^"]*)" "(.*)"/', $line, $parsed);
$time_text = $parsed[4].':'.$parsed[5].' '.$parsed[6];
$dt = strtotime($time_text);
$datetime = date("Y-m-d H:i:s", $dt);
$date = substr($datetime, 0, 10);
if ($date == $target_date) {
fputs($fp2, $line);
} else if ($date > $target_date) {
break;
}
}
fclose($fp);
fclose($fp2);
結果と解説
…………うーん、サンプル出した意味あるのかってくらい同じですね。gzipコマンド実行するコードが入ったのと、gzopenがfopenに変わっただけです。そりゃそうかw
データは前回と同じく2020年6月分の実データで、1,291,866件のログから40,983件を抽出しましたが、今度は約8.01秒かかりました。
おっそ~~~い。
テキストファイルを愚直に頭から検索している部分は同じなので遅いだろうとは思いましたが、試しに解凍部分だけチェックしてみたところ、gzipの解凍に2.41秒もかかっていることがわかりました。
1,291,866件のログはGZIP状態で約17MB。解凍すると300MBを超えます。
うーむ。ここまでの量になるとログを解凍する時間が馬鹿になりませんね。
今回のテストではzlib関数を使ったほうが早かったですが、それは2020年6月15日という、ちょうど真ん中へんのデータを抽出したからに過ぎません。これがログの最後のほうだったら、速度的には前者も後者も変わらないでしょう。
むしろ後者のほうがテキスト検索部分を改善できる分、有利かも。バイナリーサーチ的なのを実装しても良いし、どうせgzipという外部コマンドを使ったのだから、いっそ検索もgrepコマンドに頼っても良いでしょう。そしたらもっと早くなるはず。
しかしなぁー。どういうやり方するにせよ、解凍に2.41秒かかるのはイケてない。
これはもう素直に17MB分のGZIPをローカルにダウンロードしてから処理したほうがWebサーバーに負荷かからなくて良いかも。
まとめ
- レンタルサーバーの仕様により、Apacheログが毎日GZIPファイルに追記されていくため、転送量の削減を考え、指定日だけのログを抽出するプログラムを考えてみた。
- zlibを使ったパターン、gzipコマンドを使ったパターンを試したが、どちらにせよGZIPファイル自体が肥大化すると解凍にかかる時間が無視できないことがわかった。
- …GZIPのまま全ダウンロードしてローカルで処理したほうが良くね?w
1日目なら、1日分。10日目なら、10日分のログがGZIP圧縮され、それを毎日まるごとダウンロードするのは(転送量的に)無駄に思えました。
でも実際、Webサーバー側で解凍して、指定日付分だけ抽出をしてみるとその処理にかかる時間が無視できなそうなことがわかりました。
…というわけで、結局、GZIPファイルを毎日1回ダウンロードする処理のほうがサーバーに負荷かけないんじゃないか、という結論にw
ま、まぁ、Apacheログみたいな超肥大化するテキストファイルだからアレでしたけど、もっと小さいデータを扱う場合ならzlib関数を使った読み込み方法は有効だと思うので、サンプルを作ったのは無駄にはならないでしょう! …たぶん。