[AWS][Java] AmazonS3のサーバーアクセスログを解析 〜 最終版

2016.05.30

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。こむろ@札幌です。

前回、正規表現でAmazon S3のサーバーアクセスログを解析しました。一応目的は果たせたのですが、いかんせんあまり効率が良くありません。

正規表現を使ってもう少し改善してみましょう。

グルーピング

正規表現で対象文字列をパースする際に、() を使うことでマッチングした要素をグルーピングすることが出来ます。これらグルーピングされた要素は、それぞれプログラムの中で参照することが出来ます。

以下のような正規表現を考えてみましょう。

([\w]+|-) ([0-9]|-)

単語が1文字以上- 、スペースを挟んで 0〜9のいずれかの数値- です。画像にしてみます。

Created with Snapgroup #1One of:word- group #2One of:-09-

画像を見ると group#1group#2 という表記が確認できます。これは () により要素がグルーピングされています。実際の文字列で確認してみます。以下のように改行で表現された簡単な2行のテキストデータを例にしてみます。

abcdef123 13355
boijwer boijwer

まずは1行目の文字列だけ抜き出して注目してみます。

abcdef123 13355

こちらはスペースで句切られた2つの要素から成り立つ文字列です。abcdef123group#1 で参照できます。 13355 こちらは group#2 で参照できるのが分かります。

2行目の文字列を確認してみます。こちらはスペースで連結されていますが、後半の文字列が数値以外で構成されています。そのため、この文字列は先ほど指定した正規表現にはマッチしないため、不正な文字列と判定されます。そのため、 group#1 でも goup#2 でも要素を取得することができません。

簡単な例では有りますが、正規表現で正しいフォーマットかどうかを確認しつつ、グループによってわけられた各要素を取得・参照することができます。

S3ログを再度解析する

さて、前回細かいフォーマットの方は確認しましたので詳細は省きます。 *1

前回は要素ごとに分割した正規表現を考えましたが、このままだと全体のフォーマットの検査ができないので、実際に要素をパースしてみて初めて不正なフォーマットだと分かるような処理になっています。効率はよろしくありません。

先ほどのグルーピング機能を使って、一度の正規表現のマッチング処理を実行するだけでS3の正しいフォーマットのログファイルを読み込めるだけでなく、各要素も取得できるように修正しましょう。

まずは前回はバラバラにした正規表現を全部つなげます。

([\w]+) ([\w\-]+|-) (\[[a-zA-Z0-9/: \+]+\]) ([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}) ([\w\.\-:/]+|Anonymous) ([\w]+) ([\w\.]+) ([\w\-]+|-) ("(GET|PUT|POST|PATCH|DELETE|HEAD) ([\w- ./?%&=]+)") ([0-9]{3}) ([\w\-]+|-) ([0-9]+|-) ([0-9]+|-) ([0-9]+|-) ([0-9]+|-) ("((http(s)?://([\w-]+\.)+[\w-]+(/[\w- ./?%&=]*)?)|-)") ("(.+|-)") ([\w-]+|-)

可視化するとこんな感じになります。 *2

Created with Snapgroup #1One of:word group #2One of:word-- group #3[One of:-az-AZ-09/: +] group #4One of:-09at most 2 times.One of:-09at most 2 times.One of:-09at most 2 times.One of:-09at most 2 times group #5One of:word.-:/Anonymous group #6One of:word group #7One of:word. group #8One of:word-- group #9"group #10GETPUTPOSTPATCHDELETEHEAD group #11One of:word- ./?%&=" group #12One of:-092 times group #13One of:word-- group #14One of:-09- group #15One of:-09- group #16One of:-09- group #17One of:-09- group #18"group #19group #20httpgroup #21s://group #22One of:word-.One of:word-group #23/One of:word- ./?%&=-" group #24"group #25any character-" group #26One of:word--

なかなかごついことになりました。ただ、各要素ごとの正規表現そのものは、前回解説したようにあまり複雑なものではありません。

さあ。それぞれの要素を参照するようにしてみましょう。

Javaで正規表現パターンを実装する

Javaでは Pattern を使い以下のように書けます。前回は各要素ごとに Pattern を用意していましたが、今回はパターンを一つだけにしたので一つだけでOKです。

private static final String S3LOG_PATTERN_REGEX = "([\\w]+) ([\\w\\-]+|-) (\\[[a-zA-Z0-9/: \\+]+\\]) ([0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}) ([\\w\\.\\-:/]+|Anonymous) ([\\w]+) ([\\w\\.]+) ([\\w\\-]+|-) (\"(GET|PUT|POST|PATCH|DELETE|HEAD) ([\\w- ./?%&=]+)\") ([0-9]{3}) ([\\w\\-]+|-) ([0-9]+|-) ([0-9]+|-) ([0-9]+|-) ([0-9]+|-) (\"((http(s)?://([\\w-]+\\.)+[\\w-]+(/[\\w- ./?%&=]*)?)|-)\") (\"(.+|-)\") ([\\w-]+|-)";

private static Pattern pattern = Pattern.compile(S3LogParser.S3LOG_PATTERN_REGEX);

public List<String> parse(String log) {
    Matcher matcher = pattern.matcher(log);

    if (!matcher.find()) {
        throw new RuntimeException("not found log");
    }

    String bucketOwner = matcher.group(1);
    String bucket = matcher.group(2);
    String datetime = matcher.group(3);
    String remoteIp = matcher.group(4);
}

このようにグルーピングされた要素に応じて番号が振られているので、グループのIndexを指定することにより、要素を取得する事ができます。

先ほどの画像から分かるように、グループごとにIndexが振られています。

String bucketOwner = matcher.group(1);

BuecktOwnerの要素はIndexの1番目に該当しますので、このようにして取得していけば問題なさそうです。

複雑なグルーピング, グループに名前をつける

1つの要素につき1つのグルーピングが設定できていれば特には迷いません。初めからIndexを積み上げていけば取得できます。

ただ、途中から複雑なグルーピングが絡んできた時に一気に面倒になります。今回の正規表現の場合は、以下の2箇所が直感ではさっぱりわからない構成になっているのが分かります。さらにこのIndexの順序については明確な仕様がなく実装に依存しているようなので、確実に「このIndexがここのグループを指す!」と言えません。先ほどの画像を横にスクロールしてみてください。そこそこ複雑なグループ構造になっています。

Javaの正規表現ではグループに名前をつけることができます。こちらを利用しましょう。

(?<GROUP_NAME>[\w]+)

正規表現文字列でグルーピングをした () の始めに ?<~> という表記がありますが、これでそのグループに名前をつけてアクセスすることができます。必要なグループ全てに名前をつけたものは以下になります。

(?<bucketOwner>[\w]+) (?<bucket>[\w\-]+|-) (?<timestamp>\[[a-zA-Z0-9/: \+]+\]) (?<remoteIp>[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}) (?<requester>[\w\.\-:/]+|Anonymous) (?<requestId>[\w]+) (?<operation>[\w\.]+) (?<key>[\w\-]+|-) (?<requestUri>"(GET|PUT|POST|PATCH|DELETE|HEAD) ([\w- ./?%&=]+)") (?<status>[0-9]{3}) (?<errorCode>[\w\-]+|-) (?<bytesSent>[0-9]+|-) (?<objectSize>[0-9]+|-) (?<totalTime>[0-9]+|-) (?<turnAroundTime>[0-9]+|-) ("(?<referrer>(http(s)?://([\w-]+\.)+[\w-]+(/[\w- ./?%&=]*)?)|-)") (?<userAgent>"(.+|-)") (?<versionId>[\w-]+|-)

これで必要なグループに名前をつけることができました。

もう一度Javaのコードを書き直す

先程はIndexでアクセスしていましたが、今度はグループ名を定義したのでグループ名でアクセスしてみましょう。

private static final String S3LOG_PATTERN_REGEX = "(?<bucketOwner>[\\w]+) (?<bucket>[\\w\\-]+|-) (?<timestamp>\\[[a-zA-Z0-9/: \\+]+\\]) (?<remoteIp>[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}) (?<requester>[\\w\\.\\-:/]+|Anonymous) (?<requestId>[\\w]+) (?<operation>[\\w\\.]+) (?<key>[\\w\\-]+|-) (?<requestUri>"(GET|PUT|POST|PATCH|DELETE|HEAD) ([\\w- ./?%&=]+)") (?<status>[0-9]{3}) (?<errorCode>[\\w\\-]+|-) (?<bytesSent>[0-9]+|-) (?<objectSize>[0-9]+|-) (?<totalTime>[0-9]+|-) (?<turnAroundTime>[0-9]+|-) ("(?<referrer>(http(s)?://([\\w-]+\\.)+[\\w-]+(/[\\w- ./?%&=]*)?)|-)") (?<userAgent>"(.+|-)") (?<versionId>[\\w-]+|-)";

private static Pattern pattern = Pattern.compile(S3LogParser.S3LOG_PATTERN_REGEX);

public List<String> parse(String log) {
    Matcher matcher = pattern.matcher(log);

    if (!matcher.find()) {
        throw new RuntimeException("not found log");
    }

    String bucketOwner = matcher.group("bucketOwner");
    String bucket = matcher.group("bucket");
    String datetime = matcher.group("timestamp");
    String remoteIp = matcher.group("remoteIp");
}

何の値をとっているかがグループ名で明示されているので、前のサンプルより格段にわかりやすいですね。

まとめ

この正規表現パターンを使えば、現在(2016/05/30)のS3サーバーアクセスログをパースすることができます。ファイルや文字列が壊れていたりする場合を検知して、想定したログのみをフィルタすることができますので、万が一壊れたデータが来てもフィルタすることは出来るかと思います。

案件では、これらの情報をまとめてS3の操作ログとしてElasticsearchに登録しています。実際には、S3のオブジェクトを GET してもログが出力されるので、 GET のオペレーションのログはフィルタするなどの処理を加えるなどしています。

参照

脚注

  1. 詳しくは前回の記事を参考に
  2. 正規表現をビジュアライズしたURLはこちら