S3のログファイルを直接Athenaで扱いやすくする

S3のSigV2、Xデーはどんどん近付いてきます
https://dev.classmethod.jp/cloud/aws/s3-sigv2-abolition/

私が携わっている~~レガシーの塊な~~歴史あるプロダクトもバッチリ対象になってました
プログラムの修正をしても本当に殲滅できてるかチェックする必要があるわけです
（aws-sdkのバージョンアップなので普通に考えてSigV2が使われることは有り得ないはずですが、まあ…）

パパっとググってみればSigV2の使用を検出する記事はたくさんありそうで余裕かと思われました
https://dev.classmethod.jp/cloud/aws/check-s3-sig2-usage-cloudwatch/
特にこの`AWSチームのすずき`さんはこの件について参考になる記事をたくさん書いてらっしゃるのですが、
AWSのいろんな機能を繋ぎまくってて私のようなにわかAWSマンには難しかった

__にわか（私）が日頃使ってる機能で簡単になんとかならんものか__

と考えた時に、やはりS3のアクセスログのファイルをそのままAthenaで扱いたくなるんですが、
ここでネックになるのが、出されるファイルそのままではどうもパーティションが使えないっぽい。（=金が溶ける可能性が上がる）
それをみんな大好きlambdaでフォローしてみよう、という考え方です。
なので今回使うのは、

- lambda
- athena

この2つです。メジャーなやつですね。

## 対象

- CloudTrailとかよくわからん！
- athenaとlambdaならぼちぼちわかる！
- SigV2が廃止になって問題ないか不安！
- S3ログの取り回しに日頃から不満がある！

## 概要

- S3のアクセスログファイルをathenaで扱いやすい形にrenameしてからあとは普通にathenaでいじる

すごいお手軽そうですね、でも意外とここから長いです。

## 作業

### S3のログを有効化する

まずはログを取らないとどうしようもない
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/ServerLogs.html

### 取ったログファイルをrenameする
作成されるS3ログファイルのフォーマットは上のドキュメントにもある通り
`TargetPrefixYYYY-mm-DD-HH-MM-SS-UniqueString`
という形式です。これがクセモノです。大量のファイルがフォルダ分けされることなく置かれていきます。
そもそもS3にフォルダという概念はなく、オブジェクトの名前を`/`で区切った部分をなんとなくフォルダっぽくよろしくやってるらしいです。
理屈はわからんでもないですが、実際コンソールではフォルダっぽく使いまくるので非常にやりにくいです。
athenaでもlocationとかパーティションでなにかとフォルダっぽく使う必要があります。

つまり、__S3のログファイルはそのままだとすごい使いにくい__です、これは前からずっと思ってた。

なのでこの`YYYY-mm-DD-HH-MM-SS-UniqueString`を使いやすい形式にrenameしてしまいます。

で、lambdaの出番です
実装はお好みでどうにでもなりますが私はGoブームが来てるのでこんな感じで

```go:mv.go
package main

import (
	"context"
	"fmt"
	"regexp"
	"strings"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3"
)

func initSession() *s3.S3 {
	s := session.Must(session.NewSessionWithOptions(session.Options{
		SharedConfigState: session.SharedConfigEnable,
	}))
	return s3.New(s)
}

func moveS3Object(s3ses *s3.S3, bucket string, key string) error {
	splitKey := strings.Split(key, "/")
	keylen := len(splitKey)
	rgx := regexp.MustCompile(`([0-9]{4}-[0-9]{2}-[0-9]{2})-(.+$)`)
	objName := rgx.ReplaceAllString(splitKey[keylen-1], "dt=$1/$2")
	splitKey[keylen-1] = objName
	copyKey := strings.Join(splitKey, "/")

	copyInput := &s3.CopyObjectInput{
		Bucket:     aws.String(bucket),
		Key:        aws.String(copyKey),
		CopySource: aws.String(bucket + "/" + key),
	}

	deleteInput := &s3.DeleteObjectInput{
		Bucket: aws.String(bucket),
		Key:    aws.String(key),
	}

	if _, err := s3ses.CopyObject(copyInput); err != nil {
		return err
	}
	if _, err := s3ses.DeleteObject(deleteInput); err != nil {
		return err
	}
	return nil
}

func handler(ctx context.Context, event events.S3Event) {
	s3ses := initSession()
	for _, record := range event.Records {
		if e := moveS3Object(s3ses, record.S3.Bucket.Name, record.S3.Object.Key); e != nil {
			fmt.Printf("Move Object Error: %v", e)
		}
	}
}

func main() {
	lambda.Start(handler)
}
```

`YYYY-mm-DD-HH-MM-SS-UniqueString`を`dt=YYYY-mm-DD/HH-MM-SS-UniqueString`にします。
（renameというか雑にcopyして雑にdeleteなんですが、aclとか大丈夫かいな）

オブジェクトの作成をトリガーに設定します。
![スクリーンショット 2019-04-24 20.21.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122084/4579864e-d0d4-465b-623c-f22fd640e388.png)

bucket名はご愛嬌ということで（もう消した）

### athenaで解析
上の変換によりパーティションが使いやすくなっています。（というか元がアレすぎる）
`dt=YYYY-mm-DD` と一見よくわからない変換をしたのもパーティションのため。
この辺のことは公式で https://docs.aws.amazon.com/ja_jp/athena/latest/ug/partitions.html

次のちょっとした問題はログのフォーマットです
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/LogFormat.html

正規表現パターンはこれまた`AWSチームのすずき`さんのこちらを参考にしました。
https://dev.classmethod.jp/cloud/aws/s3-accesslog-new-fields/
このままだと一致しないものがあるので少し手を加えてこんな感じ

```
^(\S+) (\S+) (\S+ \S+) (\S+) (\S+) (\S+) (\S+) (\S+) (\"[^\"]*\"|-) (\S+) (\S+) (\S+) (\S+) (\S+) (\S+) (\"[^\"]*\"|-) (\"[^\"]*\"|-) (\S+) (\S+ ?\S+) (\S+) (\S+) (\S+) (\S+) (\S+)$ | ^(.+)$
```

- クォーテーションで囲ってある部分が空の時にクォーテーションなしの `-` になってるパターン
- どうもHostIdにスペースが入ってるパターンがあるっぽい？（もしくは未知のフィールドがある？）（区切り文字スペースなんだから勘弁してくれー）
- これでまだ一致しないものは最後にとりあえず拾ってあとあと対処（事前の完全フォローは無理）
- __またいつ変わるかわからない__

で、テーブルを作ります

```sql
CREATE EXTERNAL TABLE IF NOT EXISTS default.test (
  `bucket_owner` string,
  `bucket` string,
  `time` string,
  `remote_ip` string,
  `requester` string,
  `request_id` string,
  `operation` string,
  `key` string,
  `request_uri` string,
  `http_status` string,
  `error_code` string,
  `bytes_sent` string,
  `object_size` string,
  `total_time` string,
  `turn_around_time` string,
  `referrer` string,
  `user_agent` string,
  `version_id` string,
  `host_id` string,
  `signature_version` string,
  `cipher_suite` string,
  `authentication_type` string,
  `host_header` string,
  `tls_version` string,
  `unmatch` string
  )
PARTITIONED BY (dt date)
ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.RegexSerDe'
WITH SERDEPROPERTIES (
  'serialization.format' = '1',
  'input.regex' = '^(\\S+) (\\S+) (\\S+ \\S+) (\\S+) (\\S+) (\\S+) (\\S+) (\\S+) (\"[^\"]*\"|-) (\\S+) (\\S+) (\\S+) (\\S+) (\\S+) (\\S+) (\"[^\"]*\"|-) (\"[^\"]*\"|-) (\\S+) (\\S+ ?\\S+) (\\S+) (\\S+) (\\S+) (\\S+) (\\S+)$|^(.+)$'
) LOCATION 's3://z-sakusaku-origin/test/folder/'
TBLPROPERTIES ('has_encrypted_data'='false');
```

正規表現に一致しない行は最後の`unmatch`にまるっと入るので見つけ次第フォローします。
パーティションをロードします。

```sql
MSCK REPAIR TABLE default.test;
```

（初めてHive形式でやってみたけどこれってファイル増えたらまたやらないといけないのか…これ金かからないよな？）

今回はパーティションをdate形式にしたので範囲でwhereが使えます、これはちょっと便利かも。

```sql
SELECT * FROM default.test where dt > date '2019-04-01' and signature_version = 'SigV2';
```

（結果はモザイクだらけになるので貼りません）


## 締め
今回は私の環境で最適と思った方法を紹介してみました。
もっと細かくパーティショニングしたいなら`HH`も使えばいいし、S3の視認性を重視したいなら`YYYY/mm/DD/`のような感じでrenameして`ALTER TABLE ~ ADD PARTITION ~ `すればいいと思います。

なんにせよ、AWSガチ勢から見ればヤキモキするやり方かもしれませんが、にわかにはこの程度がちょうどよい。