[AWS][Java] AmazonS3のサーバーアクセスログを解析する [正規表現]

2016.04.30

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

こんにちは。こむろです。 札幌は桜が咲いてるというのに気温は1桁台。

春はいずこへ・・・。

はじめに

AmazonS3のサーバーアクセスログをご存知でしょうか。Bucket内のオブジェクトへ何らかの操作を行うと自動的にロギングしてくれる機能があります。

詳しくはこちら

AmazonS3のサーバーアクセスログをパースして変換するために使った方法をご紹介します。

サーバーアクセスログの形式

さっそくサーバーアクセスログの形式の定義を見てみましょう。こちらを参照します。

79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be mybucket [06/Feb/2014:00:00:38 +0000] 192.0.2.3 79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be 3E57427F3EXAMPLE REST.GET.VERSIONING - "GET /mybucket?versioning HTTP/1.1" 200 - 113 - 7 - "-" "S3Console/0.4" -
79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be mybucket [06/Feb/2014:00:00:38 +0000] 192.0.2.3 79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be 891CE47D2EXAMPLE REST.GET.LOGGING_STATUS - "GET /mybucket?logging HTTP/1.1" 200 - 242 - 11 - "-" "S3Console/0.4" -
79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be mybucket [06/Feb/2014:00:00:38 +0000] 192.0.2.3 79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be A1206F460EXAMPLE REST.GET.BUCKETPOLICY - "GET /mybucket?policy HTTP/1.1" 404 NoSuchBucketPolicy 297 - 38 - "-" "S3Console/0.4" -
79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be mybucket [06/Feb/2014:00:01:00 +0000] 192.0.2.3 79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be 7B4A0FABBEXAMPLE REST.GET.VERSIONING - "GET /mybucket?versioning HTTP/1.1" 200 - 113 - 33 - "-" "S3Console/0.4" -
79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be mybucket [06/Feb/2014:00:01:57 +0000] 192.0.2.3 79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be DD6CC733AEXAMPLE REST.PUT.OBJECT s3-dg.pdf "PUT /mybucket/s3-dg.pdf HTTP/1.1" 200 - - 4406583 41754 28 "-" "S3Console/0.4" -
79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be mybucket [06/Feb/2014:00:03:21 +0000] 192.0.2.3 79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be BC3C074D0EXAMPLE REST.GET.VERSIONING - "GET /mybucket?versioning HTTP/1.1" 200 - 113 - 28 - "-" "S3Console/0.4" -

(・・・・JSONじゃないのかよ・・・)

基本的に1操作につき1行です。GETされたら1行、PUTされても一行という具合。

項目の詳細はドキュメントに譲るとして、項目のリストを以下に記述してみます。

項目名
Bucket Owner 英数混在文字列
バケット 英数混在文字列, /
時間(というか日時) [%d/%b/%Y:%H:%M:%S %z] *1
Remote IP XXX.XXX.XXX.XXX => IPv4形式の模様。
リクエスタ 英数混在文字列, -, ., / or Anonymous
リクエストID 英数混在文字列
演算 SOAP.operation, REST.HTTP_method.resource_type, WEBSITE.HTTP_method.resource_type, BATCH.DELETE.OBJECT
キー(ObjectKey) 英数混在文字列, /, -
Request-URI "METHOD "
Http Status 数値
エラーコード 英数混在文字列 or -
Bytes Sent 数値 or -
Object Size 数値
Total Time 数値
Turn-Around Time 数値
Referrer "" or "-"
User-Agent ""
Version-Id 英数混在文字列 or -

それぞれの項目は (半角スペース)のデリミタで接続されています。

これがAmazonS3サーバーアクセスログの形式定義になります。

項目ごとに分割する

デリミタが(半角スペース)であるため、単純に半角スペースでSplitすればよいかというと、当然のことながらそうは問屋がおろしません。単純にSplitしただけでは、日時, User-Agent, Request URI など"(ダブルクオーテーション)で囲まれてる中の空白まで検出してしまいます。

やり方はいくつかあるかと思いますが、自分は 正規表現 を利用したマッチング処理を行うような手法でアプローチしてみました。

正規表現を定義する

あまり厳密な正規表現は行いません。あくまで間違えずに各要素を認識できれば良いという妥協の元条件を作成していきます。正規表現といいつつも分かりやすい簡易な記述でいきましょう。 *2

各要素のマッチング条件を作成していきます。

Bucket Owner

まずはBucket Ownerから行きます。定義は英数混在文字列です。そしてさすがにBucket Ownerが空というのはありえないので必ず一文字以上が入ってくると想定します。

"[\\w]+ "

\w は単語を構成します。本来であれば[0-9a-zA-Z_]あたりで構成するのが正しいと思いますが、長いのでとりあえずこれで。単語に相当する文字が 1文字以上 とデリミタとしての(半角スペース)をつなげたものがBucket Ownerの要素のマッチング条件となります。

バケット

つづいてバケットにいきましょう。定義は英数混在文字列に加え、/も必要です。

"[\\w\\-]+ "

こちらは先程の単語に加えて\-を追加しました。-[]ないでは範囲を示す特別な意味を持つため、\を加えてエスケープしておきます。こちらもデリミタとしての(半角スペース)をつなげたものがマッチング条件です。

時間(というか日時)

続いて時間です。ちょっと不思議な形式なのですが要は[]内に、英数字と, :, +及び/が入ったものをマッチングすれば良いので以下のように簡略化します。

"(\\[[a-zA-Z0-9/: \\+]+\\]) "

\wだと_が入ってしまうので念のため今回は抜きました。別に\wでも問題はないと思います。[]はこちらも特別な意味を持つので先頭と最後のものは\でエスケープします。もちろん最後に(半角スペース)のデリミタを接続しましょう。

Remote IP

IPアドレスのマッチング条件です。実データなどもいくつか見てみたのですが、どうやらIPv4形式しか来ないようなので、IPv4のみをターゲットにします。

"([0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}) "

最小1桁、最大3桁の数字を.で接続します。.は本来 任意の1文字 という正規表現上の意味があるので、\でエスケープします。最後に(半角スペース)のデリミタを接続しましょう。

リクエスタ

まだまだ続きます。続いてリクエスタです。リクエスタはIAMユーザーのアカウントIDやルートIDなどが表示されます。未認証の場合Anonymousという文字列が記載されるようです。

"([\\w\\.\\-:/]+|Anonymous) "

こちらは、サンプルでは全く気づかなかったのですが、実際には., -, :, / が含まれる可能性があります。これらは実データのログデータを見て初めて分かりました。なかなか定義の文章から読み解くのは難しかった箇所の一つです。

リクエストID

こちらは特に問題なく英数混在(さらに英語は大文字限定っぽい)の文字列なので今まで出てきた条件をそのまま使います。

"[\\w]+ "

演算

こちらは実際利用されるものは、今回の要件ではRESTくらいです。そのためかなり厳密にやっても良かったのですが、単に要素を分割したいだけなのでそこまでやりません。以下に簡略化します。

"([\\w\\.]+) "

怠惰な私をお許し下さい。

Key(Object Key)

Bucketに格納されてるオブジェクトのパスになります。

"([\\w\\-/]+) "

prefixに/が入る可能性があるので、/を加えた条件にしています。値が存在しない場合は、-となるのでこちらも加えておきましょう。最後にもちろん以下略。

Request-URI

今回のマッチング条件でとても面倒だったのがここです。ダブルクオーテーションで囲われる中にスペースが登場するため、ここのおかげで単純なSplitが効きません><

"(\"(GET|PUT|POST|PATCH|DELETE|HEAD) ([\\w\\- ./?%&=]+)\") "

メソッドに関しては必要そうなものをとりあえず列挙しておきました。"は正規表現上では問題ありませんが、Javaでは特別な意味があるので、ここではJavaにおけるエスケープ対象として\"と記述します。

後半の部分は URIプロトコル表記 になります。サンプルを確認してみましょう。

GET /mybucket?logging HTTP/1.1"

/, , ., ?が確認できます。URIの箇所はいわゆるURLと同じパターンが適用されるはずなので、URLと同じ記号も適用されると予測できます。次の記号も追加しておきましょう。

  • %, &, =

これらは、URL引数を取る場合やURLEncodeによるエスケープ処理を実行する際に出現する可能性がありますので入れておきましょう。

ちなみに実データではS3の設定によって、恐ろしく長いURIが設定されます。そちらの実データを確認して漏れがないかは再度確認した方が良いかと思います。今回の案件では上記マッチング条件で事足りました。

Http Status

特に解説する必要もないような。

"([0-9]{3}) "

数値3桁固定にしました。

エラーコード

こちらはサンプルデータから読み取りました。恐らく単語のみで事足りるであろうと考え、以下をマッチング条件とします。

"([\\w\\-]+) "

エラーコードが存在しない場合は-となるため、こちらも条件に加えておきます。

Bytes Sent

送信されたレスポンスのバイト数(HTTP プロトコルオーバーヘッドを除きます)。

なるほど。バイト数だとすると相当大きくなる気はします。ちょっと予測がつかないので念のため無限に取っておけるようにしました。

"([0-9]+|-) "

何故か0の場合は - が返却されるので、こちらも取れるようにしておきます(なんで0を返却してくれないのだろうか)

Object Size

取得するオブジェクトのサイズです。こちらは-の可能性がないようなので条件から削除します。

"([0-9]+) "

Total Time

転送中の時間になります。こちらもさすがに0ということはないようで、-はありません。

"([0-9]+) "

Turn-Around Time

リクエストの処理時間を示します。こちらも0ということはないようなので-はないようです。

"([0-9]+) "

Referrer

リファラはURLの形式が入力されます。サンプルデータと実データを確認したところ、一応http, httpsのプロトコルのURIのみ対応しておけば良いようです。必要であればこちらのプロトコルは追加する必要があります。

"\"((http(s)?://([\\w\\-]+\\.)+[\\w\\-]+(/[\\w\\- ./?%&=]*)?)|-)\" "

こちらを参考にさせていただきました。リファラが存在しない場合は"-"とダブルクオーテーション付きになるので気をつけましょう。

User Agent

ユーザーエージェントは任意の文字列がぶち込まれてくるため、さっぱり分かりません。なので少し姑息ですが以下のようなパターンにしました。

"(\"(.+|-)\") "

.は任意の一文字に合致するため、任意の一文字が1文字以上、もしくは"-"となるようにマッチング条件を設定しました。

Version-Id

最後になりました。VersionIdです。サンプルから予測するに英数混在文字列と-を考慮しておけば良いようです。

"([\\w\\-]+)"

こちらは最後の項目になるため、デリミタである(半角スペース)はつけません。

お疲れ様でした。これで全ての項目のマッチング条件を列挙できました。

マッチング条件を使って各項目を分割して取得

マッチング条件が一通り揃ったのでJavaで実装してみます。

// S3サーバーアクセスログを各項目に分割する条件
public static final Map<String, String> PATTERN_MATCH_MAP = new HashMap<String, String>() {{
put("bucketOwner", "[\\w]+ ");
put("bucket", "([\\w\\-]+) ");
put("datetime", "(\\[[a-zA-Z0-9/: \\+]+\\]) ");
put("remoteIp", "([0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}) ");
put("requester", "([\\w\\.\\-:/]+|Anonymous) ");
put("requestId", "([\\w]+) ");
put("operation", "([\\w\\.]+) ");
put("objectKey", "([\\w\\-/]+) ");
put("requestUri", "(\"(GET|PUT|POST|PATCH|DELETE|HEAD) ([\\w\\- ./?%&=]+)\") ");
put("httpStatus", "([0-9]{3}) ");
put("errorCode", "([\\w\\-]+) ");
put("bytesSent", "([0-9]+|-) ");
put("objectSize", "([0-9]+) ");
put("totalTime", "([0-9]+) ");
put("turnAroundTime", "([0-9]+) ");
put("referrer", "\"((http(s)?://([\\w\\-]+\\.)+[\\w\\-]+(/[\\w\\- ./?%&=]*)?)|-)\" ");
put("userAgent", "(\"(.+|-)\") ");
put("versionId", "([\\w\\-]+)");
}};

面倒なことにS3のサーバーアクセスログは、順序が結構重要なのでこのMapを上から適用していきます。

// S3サーバーアクセスログは一行ずつ読み込むと改行コードを気にしなくて良い
String line = is.readLine();
Tuple2<String, String> bucketOwnerResult = getGroupValue(PATTERN_MATCH_MAP.get("bucketOwner"), line);

// BucketOwnerの項目を削った文字列を対象文字列とする(元の`line`を対象文字列とするとまた先頭が取れてしまうため)
Tuple2<String, String> bucketResult = getGroupValue(PATTERN_MATCH_MAP.get("bucket"), bucketOwnerResult._2());

///////(snip)///

/**
* @param regex マッチング条件文字列
* @param target 評価対象文字列
* @return _1: 検出した項目の値, _2: 評価後の対象文字列
*/
private static Tuple2<String, String> getGroupValue(String regex, String target) {
Pattern pattern = Pattern.compile(regex);
if (pattern.find()) {
String bucketOwner = pattern.group();
System.out.println("Pattern : " + pattern.toString() + ", Group : " + bucketOwner);

// 後ろの半角スペースを削除
String replacedGroup = bucketOwner.trim();

// マッチした先頭の要素を空文字で置換
String replacedTarget = target.replaceFirst(regex, "");
return Tuple.of(replacedGroup, replacedTarget);
} else {
throw new InvalidPatternException();
}
}

定義の順序を守って適用していきます。 InvalidPatternExceptionのクラスはどこの項目で引っかかったかを知るために、項目名をコンストラクタ引数に入れても良いでしょう。Keyの順序が確実に守れるのであれば、foreachなどで回しても良いかもしれません。

Tuple2, InvalidPatternExceptionなどのクラスはなければ適当に作ってください。

まとめ

正規表現でマッチングの条件作ってる時は、パズルを説いてるようで楽しいのです。あまりエレガントな処理にできなかったのですが、ひとまず目的は達成できたので良しとしました。NodeではS3 Logをパースするモジュールがあるようです。こちらをPortingするほうが筋が良いかもしれません。今回は完成してから気がついたので参照していませんが、もしかするともう少しまともな処理になっているかもしれません。

正規表現のチェックに関してはREGEXPERを利用しました。分かりやすくビジュアライズしてくれるのでプレビューにとても便利です。 *3

スクリーンショット 2016-04-30 18.11.32

SVGでベクタ画像としてもダウンロード可能なはずなのですが、真っ黒になってしまって何も見えなくなってしまいます。何故だ。。。

参照

脚注

  1. 見慣れない・・・
  2. じゃないと後々自分で読めなくなる・・・
  3. `/`をエスケープする必要があることや、Javaだと`\`は`\\`と定義するため、その辺りの細かい調整は必要ですが。