AWS-CLI & CloudWatch Insightでログを集計する

## 概要
とある案件でCloudWatch Logsの集計をAWS-CLI & CloudWatch Insightを用いて行うことになったので、その方法を共有します。
定期的に集計をレポーティングすることを想定しているため、最終的な出力はcsvを想定しています。

## CloudWatch Ingihtとは？
>新しいサービス CloudWatch Logs Insights がお役に立ちます。これはクラウドスケールで動作するよう設計され、セットアップやメンテナンスが不要なフルマネージドのサービスです。これは大量のログを数秒で走査し、インタラクティブなクエリの実行と可視化を提供します。あらゆるログフォーマットや、JSON ログから自動検出したフィールドを扱えます。使ってみると分かるように、これは大変フレキシブルであり、ログを調査するためのお気に入りのツールの一つになるでしょう。
>
>[公式ドキュメント](https://aws.amazon.com/jp/blogs/news/new-amazon-cloudwatch-logs-insights-fast-interactive-log-analytics/)より抜粋。

ログの検索自体はすでに実装されているfilter_log_eventsで近しいことはできます。
しかしこれにはクエリ言語が設定されておらず、ちょっと工夫した検索をしたい場合はとても使いづらいものになります。

CloudWatch Insightは独自の検索クエリ言語を持っており、多様なログフォーマットの検索を柔軟に実行できます。
非常に強力なツールです。しかし、後述の問題点があります。

## 背景
この強力なツールは当然各種AWS-SDKに含まれていると思うでしょう？
実は本記事執筆時点でCloudWatch InsightはSDKに含まれていないのです。
だから定期的に集計バッチ処理を行おうとすると、シェルスクリプトとAWS-CLIで頑張るしかないのです。
ということで、この解説記事を書きました。

## 必要なパッケージ
- AWS-CLI ver.1.16以上(このバージョンじゃないとInsightが使えない)
- jq(レスポンスを処理するために使う)

インストール方法は割愛します。
なお、実行環境はMacかec2上とします。

## ソース
実際に動作するソースを見ながら解説していきます。
サンプルプログラムでは、あるロググループに対して昨日中の集計を行うことを目的としています。

```shell

#!/bin/sh
alias date='gdate'

YESTERDAY=`date -d '1 days ago' '+%Y/%m/%d'`
START_TIME=`date -d $YESTERDAY' 00:00:00 9 hours ago' '+%s'`
END_TIME=`date -d $YESTERDAY' 23:59:59 9 hours ago' '+%s'`

#必要な設定
CLOUDWATCH_LOG_GROUP_NAME="ここにロググループ名"
REGION="ap-northeast-1"
QUERY="fields @timestamp, @message | limit 10"

echo 'Start aggregation.'

QUERYID=`
aws logs start-query \
--log-group-name $CLOUDWATCH_LOG_GROUP_NAME \
--start-time $START_TIME \
--end-time $END_TIME \
--query-string "$QUERY" \
--region $REGION \
--output text
`

#終了を待つ
printf 'Wait for complete'
while : 
do
    STATUS=`aws logs get-query-results --query-id $QUERYID --output text --region $REGION | sed -n '1p'`
    if [ "$STATUS" = "Complete" ]; then
        break
    fi
    printf "."
    sleep 1s;
done

#終了したら結果をjsonで出力
aws logs get-query-results --query-id $QUERYID --output table --region $REGION
RESULT_JSON=`aws logs get-query-results --query-id $QUERYID --output json --region $REGION`

#マッチ件数が1件以上ならjsonをcsvに変換し、results以下に保存する
MATCHED=`echo $RESULT_JSON | jq '.statistics.recordsMatched'`
if [ $MATCHED -gt 0 ] ; then
    #
    #header書き込み
    echo $RESULT_JSON \
    | jq '.results[0]' \
    | jq -r -c '([ .[].field | values ]) | @csv' \
    > ./result.csv

    #内容書き込み
    echo $RESULT_JSON \
    | jq '.results[]' \
    | jq -r -c '([ .[].value | values ]) | @csv' \
    >> ./result.csv
fi

echo 'Aggregation has completed.'
```

### 解説
#### 前準備

```shell

#!/bin/sh
alias date='gdate'

YESTERDAY=`date -d '1 days ago' '+%Y/%m/%d'`
START_TIME=`date -d $YESTERDAY' 00:00:00 9 hours ago' '+%s'`
END_TIME=`date -d $YESTERDAY' 23:59:59 9 hours ago' '+%s'`

#必要な設定
CLOUDWATCH_LOG_GROUP_NAME="ここにロググループ名"
REGION="ap-northeast-1"
QUERY="fields @timestamp, @message | limit 10"
```
ここではクエリ実行に必要な変数を定義しています。
注意したいのは期間指定であり、ここはUTC標準時のタイムスタンプを設定する必要があります。
なぜならAWS-CLIにおけるCloudWatch Insightの期間指定は、タイムスタンプで指定する必要があるからです。
これは他のlogs関連クエリでも同様です。

今回の例では前日の丸一日を走査の対象としています。
Insightは走査したログのデータ量によって料金が変わってきますので、不要に長い期間を設定するのは時間と金の無駄遣いと心得ましょう。

なお、冒頭のgdateのエイリアスはMacでは必要ですが、ec2で動かすなら不要です。

#### クエリの実行

```shell

QUERYID=`
aws logs start-query \
--log-group-name $CLOUDWATCH_LOG_GROUP_NAME \
--start-time $START_TIME \
--end-time $END_TIME \
--query-string "$QUERY" \
--region $REGION \
--output text
`
```
ここからが本題です。AWS-CLIでCloudWatch Insightを実行するためのコマンドは `aws logs start-query`となっています。
詳しくは[公式ドキュメント](https://docs.aws.amazon.com/cli/latest/reference/logs/start-query.html)を見る必要がありますが、ここでは使用しているパラメータについて解説します。

- `log-group-name`はそのままロググループ名です。必須です。
- `start-time`は集計開始のタイムスタンプです。必須です。
- `end-time`は集計終了のタイムスタンプです。必須です。
- `query-string`はクエリ本文です。必須です。
- `region`はリージョンです。configでデフォルト値を設定している場合は不要です。
- `output`は出力形式です。このコマンドの出力結果は実行結果を得るためのクエリIDなので、単にテキストを取得できればいいので指定しています。

このパラメータで注意したいのは、期間指定のタイムスタンプは**秒**ということです。
というのも他のCloudWatch logsの取得コマンドでは**マイクロ秒**を指定する場合があるためです。
例えば[filter-log-events](https://docs.aws.amazon.com/cli/latest/reference/logs/filter-log-events.html)では期間指定が**マイクロ秒**となっているので注意してください。ややこしいな！

#### クエリ実行完了の待機

```shell
while : 
do
    STATUS=`aws logs get-query-results --query-id $QUERYID --output text --region $REGION | sed -n '1p'`
    if [ "$STATUS" = "Complete" ]; then
        break
    fi
    printf "."
    sleep 1s;
done

#終了したら結果をjsonで出力
aws logs get-query-results --query-id $QUERYID --output table --region $REGION
RESULT_JSON=`aws logs get-query-results --query-id $QUERYID --output json --region $REGION`
```

`start-query`を実行するとAWSのほうで集計タスクが走りますが、これはすぐに完了するものではありません。
完了するまでクエリIDを用い、レスポンスを確認する処理が必要です。

スクリプト側は`get-query-result`コマンドをクエリIDを指定して実行することで、集計ステータスおよび集計結果を得ることができます。
出力形式をテキストにしている場合、レスポンスの１行目は単なるテキスト`Running`となり、完了すると`complete`となります。よってレスポンスが`Running`の間はループを実行しています。

ステータスが`complete`になった後、出力形式をテーブルにした上で結果を表示しています。
最終的には出力形式をjsonにした上で変数に格納します。

集計結果がjsonで得られれば十分であればここで完了となります。
次のパートから実用的と思われるcsvに変換します。

#### 結果の処理

```shell
#マッチ件数が1件以上ならjsonをcsvに変換し、results以下に保存する
MATCHED=`echo $RESULT_JSON | jq '.statistics.recordsMatched'`
if [ $MATCHED -gt 0 ] ; then
    #
    #header書き込み
    echo $RESULT_JSON \
    | jq '.results[0]' \
    | jq -r -c '([ .[].field | values ]) | @csv' \
    > ./result.csv

    #内容書き込み
    echo $RESULT_JSON \
    | jq '.results[]' \
    | jq -r -c '([ .[].value | values ]) | @csv' \
    >> ./result.csv
fi

echo 'Aggregation has completed.'
```

ここは本題から外れるので詳しくは解説はしません。

awscliの出力は`text, json, table`と選択できます。
簡単なメッセージのみ取得したいのであればtextで十分ですし、データを視覚的に認識したい場合はtableで出力します。
今回はcsvとして最終的な結果を得るために、処理をしやすいjson形式で出力しています。

この部分は`statistics.recordsMatched`が０以上の場合、jsonからcsvに変換しています。
`statistics.recordsMatched`は条件に一致した件数をさします。

## 最後に
そのうちSDKでもCloudWatch Insightが利用できるようになると思います。
おそらく、このような定期実行によるレポーティングをするベストプラクティスは下記のような構成でしょう。

- 実行環境はLambda、Pythonならboto3ライブラリでクエリを実行する
- CloudWatch EventsでLambdaを定期実行する
- 検索クエリはs3に配置して、集計結果もs3にアップロード

私が作った集計バッチプログラムも、実行環境がec2上という以外は上にならっています。
直近で必要な方はここのソースを参考にしてみてください。