CloudWatch Logsがもっと簡単にDLできたらいいのに…と思った方へ（スクリプト付き）

# はじめに

**CloudWatch Logs をサクッとダウンロードしたいと思ったことありませんか？**

CloudWatch Logs をファイルとしてダウンロードするには、

1. CloudWatch Logs から S3 にエクスポート
2. S3 からファイルをダウンロード

という手順を踏まなければなりません。

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/S3Export.html

これ、地味に面倒じゃないですか？　→　**私は面倒ですｗ**

というわけで、CloudWatch Logs（特定のログストリーム）を簡単にファイル出力するスクリプトを作ってみました。


# Pythonスクリプト

Python と AWS SDK (boto3) で作りました。

```python:download_logs.py
#!/usr/bin/env python3

import argparse
import boto3
import re
import traceback
from datetime import datetime


def get_log_events(client, log_group_name, log_stream_name):
    """ログイベント取得"""

    # 初回リクエスト
    response = client.get_log_events(
        logGroupName=log_group_name,
        logStreamName=log_stream_name,
        startFromHead=True
    )
    print('Count of events: ' + str(len(response['events'])))
    print('nextForwardToken: ' + response['nextForwardToken'])

    # 取得したイベントを返す
    yield response['events']

    while True:
        # nextForwardTokenを取得
        prev_token = response['nextForwardToken']

        # 2回目以降のリクエスト
        response = client.get_log_events(
            logGroupName=log_group_name,
            logStreamName=log_stream_name,
            nextToken=prev_token
        )
        print('Count of events: ' + str(len(response['events'])))
        print('nextForwardToken: ' + response['nextForwardToken'])

        # 取得したイベントを返す
        yield response['events']

        # nextForwardTokenが前回と同じであればログイベントを最後まで取得した
        if response['nextForwardToken'] == prev_token:
            break


def main():
    """メインメソッド"""

    try:
        # 引数定義
        arg_parser = argparse.ArgumentParser()
        arg_parser.add_argument(
            '-r',
            '--region',
            metavar='REGION',
            default='ap-northeast-1',
            help='リージョンを指定する（デフォルト：ap-northeast-1）'
        )
        arg_parser.add_argument(
            'log_group',
            metavar='LOG_GROUP',
            help='ロググループ名を指定する'
        )
        arg_parser.add_argument(
            'log_stream',
            metavar='LOG_STREAM',
            help='ログストリーム名を指定する'
        )

        # 引数取得
        args = arg_parser.parse_args()
        region = args.region
        log_group = args.log_group
        log_stream = args.log_stream

        # ファイル名に使用できない文字を置換
        replaced_log_stream = re.sub('[\\/:*?"<>|]', '_', log_stream)

        # 出力ファイル名
        output_filename = 'aws_logs_' + replaced_log_stream + '.txt'

        # CloudWatch Logs クライアント取得
        client = boto3.client('logs', region_name=region)

        # ファイルオープン
        with open(output_filename, 'w', encoding='UTF-8') as f:

            # ログイベントを取得
            for events in get_log_events(client, log_group, log_stream):

                # ログのタイムスタンプとメッセージを抽出
                messages = [datetime.fromtimestamp(event.get('timestamp')/1000).isoformat()
                            + '\t' + event.get('message') for event in events]

                # ファイル出力
                f.writelines(messages)

    except Exception as e:
        traceback.print_exc()


if __name__ == '__main__':
    main()
```


# 使い方

引数でロググループ名とログストリーム名を指定して実行します。
詳細は help をご確認ください。

```
$ python3 download_logs.py --help
usage: download_logs.py [-h] [-r REGION] LOG_GROUP LOG_STREAM

positional arguments:
  LOG_GROUP             ロググループ名を指定する
  LOG_STREAM            ログストリーム名を指定する

optional arguments:
  -h, --help            show this help message and exit
  -r REGION, --region REGION
                        リージョンを指定する（デフォルト：ap-northeast-1）
```


## 実行例

### 実行環境

CloudShell で実行しましたが、Python と AWS CLI がインストール済みの環境であればOKです。

```
$ aws --version
aws-cli/2.4.20 Python/3.8.8 Linux/4.14.262-200.489.amzn2.x86_64 exec-env/CloudShell exe/x86_64.amzn.2 prompt/off

$ python3 --version
Python 3.7.10
```

### 実行コマンド

コンソール出力の **nextForwardToken** については後ほど説明します。

```
$ python3 download_logs.py '/aws/lambda/example-lambda-function' '2022/03/06/[$LATEST]abcdefghijklmnopqrstuvwxyz123456'
Count of events: 60
nextForwardToken: f/12345678901234567890123456789012345678901234567890123456/s
Count of events: 0
nextForwardToken: f/09876543210987654321098765432109876543210987654321098765/s
Count of events: 0
nextForwardToken: f/09876543210987654321098765432109876543210987654321098765/s
```

### 出力結果

```:aws_logs_2022_03_06_[$LATEST]abcdefghijklmnopqrstuvwxyz123456.txt
2022-03-06T08:23:31.096000      START RequestId: 0bc5db8a-3d39-40bd-b535-cee024a5262b Version: $LATEST
2022-03-06T08:23:32.099000      END RequestId: 0bc5db8a-3d39-40bd-b535-cee024a5262b
2022-03-06T08:23:32.099000      REPORT RequestId: 0bc5db8a-3d39-40bd-b535-cee024a5262b  Duration: 1002.67 ms    Billed Duration: 1003 ms        Memory Size: 256 MB     Max Memory Used: 38 MB  Init Duration: 114.31 ms
2022-03-06T08:23:32.335000      START RequestId: 8bfcfa3b-ce0a-4f91-9f1c-037acab16efc Version: $LATEST
2022-03-06T08:23:33.342000      END RequestId: 8bfcfa3b-ce0a-4f91-9f1c-037acab16efc
2022-03-06T08:23:33.342000      REPORT RequestId: 8bfcfa3b-ce0a-4f91-9f1c-037acab16efc  Duration: 1002.41 ms    Billed Duration: 1003 ms        Memory Size: 256 MB     Max Memory Used: 38 MB
(後略)
```

# 解説

[1]:https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/logs.html#CloudWatchLogs.Client.get_log_events
[2]:https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/logs.html#CloudWatchLogs.Client.describe_log_groups

基本は、[get_log_events()][1] でログイベントを取得し、それをファイル出力しているだけですが、いくつかポイントがあるので解説しておきます。

## get_log_events() の nextForwardToken

これが一番のポイントです。
[get_log_events()][1] は他のAPIと比べて、この部分の仕様が特殊なのです。

### nextToken とは

前提として、複数のデータを取得するAPIには、**一回のAPI呼び出しで取得できるデータ数の上限があります。**

例えば、[describe_log_groups()][2] で取得できるロググループの上限は「50」となっています。
※この値は limit パラメータで変更可能ですが、指定可能な値は「1～50」、つまり、デフォルト値＝上限となっています。

では、ロググループが51件以上ある場合、51件目以降のロググループを取得するにはどうしたらよいのか？

答えは、**もう一度APIを呼び出せばよいのです。**

ただし、一回目と同じように呼び出すだけでは、同じ結果＝1～50件目が返されてしまいます。
ここで **「50件目までは取得済みなので、次は51件目から取得してください」** ということを示すパラメータが **nextToken** です。

**nextToken はデータの続きがある場合に限り、レスポンスに含まれます。**
つまり、
　**レスポンスに nextToken が含まれている**
　　**→　続きがある（次のリクエストで nextToken を指定して続きから取得できる）**
　**レスポンスに nextToken が含まれていない**
　　**→　続きがない（すべて取得した）**
ということになります。

:::note warn
* 1回のAPI呼び出しで取得可能なデータ数には上限がある
* 続きを取得するには **nextToken** を使う
:::

### get_log_events() は特殊

前提を説明したところで話を戻しましょう。

[get_log_events()][1] も複数のデータ（ログイベント）を取得するAPIですが、前述の nextToken とは異なる点がふたつあります。

#### ① 双方向の nextToken を持つ

ひとつめの相違点は、[get_log_events()][1] は、**双方向の nextToken** を持っていることです。
これは、[get_log_events()][1] が特定の時間帯のログイベントを取得できることに起因します。

[get_log_events()][1] は startTime パラメータと endTime パラメータで指定した期間のログイベントだけを取得することが可能です。
そのため、次のAPI呼び出しでは、**順方向の新しいログイベントだけでなく、逆方向の古いログイベントの取得が必要になる場合があります。**
それに対応するために、**nextForwardToken** と **nextBackwardToken** という **双方向の nextToken** を持っています。

ただし、今回のスクリプトでは、初回リクエストのパラメータに **startFromHead** を **True** としている＝最初のデータ（最も古いデータ）から取得しているため、nextBackwardToken は使用しません。

:::note warn
* get_log_events() は **nextForwardToken** と **nextBackwardToken** という**双方向の nextToken** を持っている
:::

#### ② 後続データの判定方法が異なる

ふたつめの相違点は、後続データの判定方法が nextToken とは異なることです。

前述のとおり、nextToken はレスポンスにそれ自体が含まれているか否かで後続データの有無を判定できます。
しかし、[get_log_events()][1] の **nextBackwardToken** は異なります。（nextBackwardToken も nextBackwardToken と同様です）

以下はAPIリファレンスの抜粋です。（日本語訳 by Google翻訳）

> * nextForwardToken (string) --
> 順方向の次のアイテムセットのトークン。 トークンは24時間後に期限切れになります。 ストリームの最後に到達すると、渡したのと同じトークンが返されます。

**「ストリームの最後に到達すると、渡したのと同じトークンが返されます。」**

どういうことでしょうか？
実際の動きを見てみましょう。

前提：ログイベントが20件含まれるログストリームを対象とする場合

* 1回目のAPI呼び出し
  * リクエスト
    * 初回なので nextForwardToken は指定なし
    * 最初からデータを取得するため startFromHead = True を指定する
  * レスポンス
    * **20件** の events が含まれる
    * nextForwardToken が含まれる
      * 便宜上、この値を **f/xxxxxxxx/s** とする

* 2回目のAPI呼び出し
  * リクエスト
    * 1回目のレスポンスに含まれる nextForwardToken = **f/xxxxxxxx/s** を指定する
  * レスポンス
    * **0件** の events が含まれる
    * nextForwardToken が含まれる
      * 便宜上、この値を **f/yyyyyyyy/s** とする
      * ストリームの最後に到達しているので渡したのと同じ **f/xxxxxxxx/s** が返されると思いきや、なぜか異なる値が返される

* 3回目のAPI呼び出し
  * リクエスト
    * 2回目のレスポンスに含まれる nextForwardToken = **f/yyyyyyyy/s** を指定する
  * レスポンス
    * **0件** の events が含まれる
    * nextForwardToken が含まれる
      * 渡したのと同じ **f/yyyyyyyy/s** が返される
      * **渡したのと同じ値が返ってきたので最後に到達したと判定できる**

という動作になっていました。

**・・・いや、わかりづれぇわ！！**
特に余分にAPIを呼び出さないと最後まで到達したか判定できない点がイマイチですね。。
2回目で渡したのと同じ値が返ってこない点も不思議です。。

でも、そういう仕様なのでしかたがないのです。。(´・ω・｀)

:::note warn
* **get_log_events() は nextForwardToken は最後まで到達した場合でも返される**（nextBackwardToken も同様）
* **最後まで到達したかは、渡したのと同じ nextForwardToken が返されたことで判定する**
:::


# あとがき

最後まで読んでいただき、ありがとうございます。
この記事が少しでも役に立てば幸いです。
いいね（LGTM）頂けると励みになりますm(_ _)m

他にも全サービスクォータを一覧化するスクリプトなどを公開しているので、よろしければご覧ください。
↓

https://qiita.com/ozzy3/items/e9691f21d431a6ceac7a