【AWS】CloudWatch LogsのログをS3バケットにエクスポートする処理はLambdaよりもStep Functionsの方が相性が良かった



# はじめに


本記事は、CloudWatch LogsのログをS3バケットにエクスポートする処理をStep Functionsで実装した時の記録です。


## やりたいこと


* CloudWatch Logsの全ロググループの先月分のログをS3バケットにバックアップする
* 毎月最初の休日の12:00に自動実行させる
* できるだけ楽して実装する


## 背景


CloudWatch Logsのログは、データ保管コストがS3バケットよりも高くなっています。

https://dev.classmethod.jp/articles/comparison-of-fees-for-cloudwatch-logs-and-s3/

直近のログデータのみを残して、古いログデータはS3にバックアップすることで、保管コストを抑えることができます。

::: note
古いログデータを自動削除したい場合は、CloudWatch Logsのログ保持期間を設定します。[^retention]
:::

以下の記事を参考にして、CloudWatch LogsのログをS3バケットに転送する方法を検討しました。

https://qiita.com/Regryp/items/031141f8930c94378d5f

一番楽に見えたのは「EventBridgeSchedulerでのS3エクスポート」です。しかし、AWSのAPI「[CreateExportTask](https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_CreateExportTask.html)」には下記制約があります。

* 1回のタスクでエクスポートできるCloudWatch Logsのロググループは1つ
* 同一アカウントにおいてエクスポートタスクは並列実行できない

> Each account can only have one active (RUNNING or PENDING) export task at a time.

従って、複数のCloudWatch Logsのロググループをエクスポートするためには、「エクスポートタスクを実行→エクスポートタスクの終了を待つ」という処理を繰り返す必要があります。

この処理はLambdaで実装できると考え、まずは「EventBridge＋LambdaでのS3エクスポート」で実装してみました。

しかし、思っていたよりもエクスポートタスクに時間がかかり、Lambdaの実行時間制限（15分）に引っかかってしまいました。

そこで、Lambdaの替わりにStep Functionsで実装することにしました。

::: note
Step Functionsの実行時間制限は最大で1年です。
> By configuring a long timeout duration and actively sending a heartbeat, an activity in Step Functions can wait up to a year for an execution to complete.[^timeout]
:::


## 自動実行させる日時について


一般的に、バックアップ等のメインテナンス処理は、夜間や休日に実行することが多いです。

今回のCloudWatch Logsのログをエクスポートする処理も負荷が高いため、毎月最初の休日の12:00に自動実行させます。

:::note warn
CloudWatch Logsのログデータは、エクスポートできるようになるまで最大12時間かかる場合があります。
> Log data can take up to 12 hours to become available for export. Export tasks time out after 24 hours. [^available_for_export]

1日00:00に実行するとエクスポートできないデータが含まれる可能性もあるため、実行時刻を12:00にしています。
:::



# なぜStep Functionsと相性が良いのか？（忙しい方向けのまとめ）


Lambdaの場合はコードを書く必要がありますが、Step Functionsはコードを書かなくても今回必要な機能の大半を提供してくれました。

<dl>

 <dt>AWSのAPI呼び出し</dt>
 <dd>Step Functionsから直接呼出し可能</dd>

 <dt>リトライ処理</dt>
 <dd>API失敗時のリトライ処理やエラー処理を設定可能</dd>

 <dt>配列の各要素毎の処理</dt>
 <dd>Map Stateで実現可能（ループ処理を自力で作る必要なし）</dd>

 <dt>条件分岐</dt>
 <dd>Choice Stateで実現可能</dd>

 <dt>待機</dt>
 <dd>Wait Stateで実現可能</dd>

 <dt>簡単なデータ処理</dt>
 <dd>組み込み関数で実現可能</dd>

 <dt>実行時間制限</dt>
 <dd>最大で1年（Lambdaは最大でも15分）</dd>

</dl>

<br>
もちろん、LambdaとStep Functionsそれぞれにメリット・デメリットがあります。

||Lambda|Step Functions|
|---|---|---|
|メリット|使い慣れたプログラミング言語を使用できる。各種ライブラリーも充実している。<br>複雑な処理にも対応可能。|AWSのAPI呼び出しやエラー処理、並列処理等を簡単に実現可能。<br>実行時間制限は最大で1年。|
|デメリット|最大15分の実行時間制限がある。<br>AWSのAPI呼び出しやエラー処理、並列処理等を自力でコーディングする必要がある。|Step Functionsのみで複雑な処理を実現するのは困難。<br>未経験の場合は学習コストがかかる。|

今回のようにAWSのAPI呼び出しだけで完結するようなケースでは、Step Functionsの方が楽に実装できました。

:::note
複雑な処理だけLambdaで実装して、それ以外はStep Functionsで実装することも可能です。
:::


# 今回実装した成果物


<details><summary>システム構成図</summary>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929201/f57e86ca-9219-600b-9eb1-9a2a1206749d.png)

</details>


<details><summary>クラウド型ジョブスケジューラーサービスの設定</summary>

![eventbridge_rules.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929201/2555939b-2eab-5279-0996-3e4a9000931b.png)

![jobsaas_calender.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929201/eb1e219b-75c2-5518-d88e-edfb07720877.png)

![jobsaas_rule.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929201/b0f1db2f-a8e4-a942-7b9b-a1320490dab6.png)

</details>

<details><summary>Step Functionsのステートマシン（デザイン）</summary>

![stepfunctions_graph.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929201/061949c8-6b4d-dc45-1a9b-521c82a3d123.png)

</details>


<details><summary>Step Functionsのステートマシン（コード）</summary>
下記変数は各自の環境に置き換えてください。

<dl>

<dt>${AWS::Region}</dt>
<dd>リージョン</dd>

<dt>${AWS::AccountId}</dt>
<dd>アカウントID</dd>

<dt>${BucketName}</dt>
<dd>S3バケット名</dd>

</dl>

```json:Step Functionsのステートマシン（コード）
{
  "StartAt": "Lambda args-for-create-export-task",
  "States": {
    "Lambda args-for-create-export-task": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:args-for-create-export-task:$LATEST",
        "Payload": {
          "time.$": "$.time"
        }
      },
      "Next": "Pass NextToken",
      "OutputPath": "$.Payload"
    },
    "Pass NextToken": {
      "Type": "Pass",
      "Next": "DescribeLogGroups",
      "Parameters": {
        "args.$": "$.args",
        "NextToken": null
      },
      "OutputPath": "$.temp",
      "ResultPath": "$.temp"
    },
    "DescribeLogGroups": {
      "Type": "Task",
      "Parameters": {
        "Limit": 50,
        "NextToken.$": "$.NextToken"
      },
      "Resource": "arn:aws:states:::aws-sdk:cloudwatchlogs:describeLogGroups",
      "Next": "Map extract",
      "ResultPath": "$.describe_log_groups"
    },
    "Map extract": {
      "Type": "Map",
      "ItemProcessor": {
        "ProcessorConfig": {
          "Mode": "INLINE"
        },
        "StartAt": "CreateExportTask",
        "States": {
          "CreateExportTask": {
            "Type": "Task",
            "Parameters": {
              "Destination": "${BucketName}",
              "DestinationPrefix.$": "States.Format('{}{}', $.args.destinationPrefix, $.LogGroupName)",
              "From.$": "$.args.from",
              "LogGroupName.$": "$.LogGroupName",
              "To.$": "$.args.to"
            },
            "Resource": "arn:aws:states:::aws-sdk:cloudwatchlogs:createExportTask",
            "Next": "Wait DescribeExportTasks",
            "Retry": [
              {
                "ErrorEquals": [
                  "States.TaskFailed"
                ],
                "BackoffRate": 1,
                "IntervalSeconds": 10,
                "MaxAttempts": 99999999,
                "Comment": "LimitExceededException",
                "MaxDelaySeconds": 10
              }
            ]
          },
          "Wait DescribeExportTasks": {
            "Type": "Wait",
            "Next": "DescribeExportTasks",
            "Seconds": 10
          },
          "Success map": {
            "Type": "Succeed"
          },
          "DescribeExportTasks": {
            "Type": "Task",
            "Parameters": {
              "TaskId.$": "$.TaskId"
            },
            "Resource": "arn:aws:states:::aws-sdk:cloudwatchlogs:describeExportTasks",
            "Next": "Choice DescribeExportTasks",
            "ResultPath": "$.status",
            "ResultSelector": {
              "Code.$": "$.ExportTasks[0].Status.Code"
            }
          },
          "Choice DescribeExportTasks": {
            "Type": "Choice",
            "Choices": [
              {
                "Or": [
                  {
                    "Variable": "$.status.Code",
                    "StringEquals": "RUNNING"
                  },
                  {
                    "Variable": "$.status.Code",
                    "StringEquals": "PENDING"
                  },
                  {
                    "Variable": "$.status.Code",
                    "StringEquals": "PENDING_CANCEL"
                  }
                ],
                "Next": "Wait DescribeExportTasks"
              },
              {
                "Variable": "$.status.Code",
                "StringEquals": "COMPLETED",
                "Next": "Success map"
              }
            ],
            "Default": "Fail map"
          },
          "Fail map": {
            "Type": "Fail"
          }
        }
      },
      "Next": "Choice NextToken",
      "ItemsPath": "$.describe_log_groups.LogGroups",
      "MaxConcurrency": 1,
      "ItemSelector": {
        "args.$": "$.args",
        "LogGroupName.$": "$$.Map.Item.Value.LogGroupName"
      },
      "ResultPath": "$.map_extract"
    },
    "Choice NextToken": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.describe_log_groups.NextToken",
          "IsPresent": true,
          "Next": "Pass loop"
        }
      ],
      "Default": "Success"
    },
    "Pass loop": {
      "Type": "Pass",
      "Next": "DescribeLogGroups",
      "Parameters": {
        "args.$": "$.args",
        "NextToken.$": "$.describe_log_groups.NextToken"
      }
    },
    "Success": {
      "Type": "Succeed"
    }
  },
  "TimeoutSeconds": 86400,
  "Comment": "CloudWatch logsのログをS3バケットへエクスポート"
}
```

</details>


<details><summary>Step Functionsのステートマシン（ロールの許可ポリシー）</summary>
下記変数は各自の環境に置き換えてください。

<dl>

<dt>${AWS::Region}</dt>
<dd>リージョン</dd>

<dt>${AWS::AccountId}</dt>
<dd>アカウントID</dd>

</dl>

```json:Step Functionsのステートマシン（ロールの許可ポリシー）
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "logs:DescribeLogGroups"
            ],
            "Resource": "arn:aws:logs:*:*:log-group::log-stream:*",
            "Effect": "Allow"
        },
        {
            "Action": [
                "logs:CreateExportTask",
                "logs:DescribeExportTasks"
            ],
            "Resource": "arn:aws:logs:*:*:log-group:*",
            "Effect": "Allow"
        },
        {
            "Action": [
                "lambda:InvokeFunction"
            ],
            "Resource": [
                "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:args-for-create-export-task",
                "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:args-for-create-export-task:*"
            ],
            "Effect": "Allow"
        },
        {
            "Action": [
                "xray:PutTraceSegments",
                "xray:PutTelemetryRecords",
                "xray:GetSamplingRules",
                "xray:GetSamplingTargets"
            ],
            "Resource": "*",
            "Effect": "Allow"
        }
    ]
}
```


</details>


<details><summary>CreateExportTaskの引数を作成するLambda関数のコード(Node.js 20.x)</summary>

```javascript:CreateExportTaskの引数を作成するLambda関数のコード(Node.js 20.x)
exports.handler = async (event) => {
  const time = new Date(event.time);
  if ( Number.isNaN(time.getDate())){
    throw new TypeError("invalid argument: 'time': " + event.time);
  }
  const from = new Date(time.getFullYear(), time.getMonth(), -1, 1, 0, 0, 0, 0);
  return {
    args: {
      from: from.getTime(),
      to: new Date(time.getFullYear(), time.getMonth(), 1, 0, 0, 0, 0).getTime(),
      destinationPrefix: 'exportedlogs/'+
        ('0000' + from.getFullYear()).slice(-4) + "/" +
        ('00' + (from.getMonth() + 1)).slice(-2) + "/logs",
    }
  };
};
```

</details>


# 解説


以下では今回実装した成果物について説明します。

::: note
Step Functionsの各ステートの説明では「コード」を記載していますが、筆者は作成時には「デザイン」（GUI）を使用しました。

「デザイン」と「コード」は自動的に同期してくれるので、好きな方で作成できます。
:::


## 全体の流れ


|要件|手段|
|---|---|
|毎月最初の休日の12:00に自動実行させる|クラウド型ジョブスケジューラーサービス（またはEventBridge Schedulerで代替）|
|CloudWatch Logsの全ロググループの先月分のログをS3バケットにバックアップする|Step Functions（+ Lambda）|

![stepfunctions_details.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929201/9e6330ea-9b78-e73d-267b-7b171ac5d73c.png)


## 毎月最初の休日の12:00に自動実行させる


### ステートマシンの定期自動実行


Step Functionsのステートマシンを下記要件の通り自動実行させます。
> 毎月最初の休日の12:00に自動実行させる

ステートマシンの自動実行は、EventBridge Schedulerで実現できます。

EventBridge Schedulerでは、スケジュールルールをcron式またはrate式で表現します。[^scheduler]
しかし、「毎月最初の休日」という要件をcron式またはrate式で表現する方法が思いつきませんでした。

そこで、ステートマシンの定期自動実行をクラウド型ジョブスケジューラーサービスで実現することにしました。

::: note
要件がcron式またはrate式で表現できるケースでは、EventBridge Schedulerでも実現可能です。
:::

<details><summary>EventBridge Schedulerを使用する場合【クリックすると展開されます】</summary>

### EventBridge Schedulerの設定


cron式またはrate式で「毎月最初の休日」を表現する方法が思いつかなかったため、要件を単純化して「毎月第1日曜日の12:00」にStep Functionsのステートマシンを実行させました。

#### システム構成図

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929201/99beba71-83d6-ead1-0317-375615cc0d9b.png)

#### スケジュール設定


![eventbridge_schedule.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929201/3012b72d-b339-c94e-31b5-a0ddbe67631a.png)


#### ターゲット設定


![eventbridge_target.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929201/d913df56-1f58-8afd-6277-d161e8f801f5.png)

実行ロールで、Step Functionsのステートマシンの`states:StartExecution`権限を付与します。


#### ステートマシンの入力データ


CloudWatch Logsのログをエクスポートする処理では「先月」の基準となる日付が必要です。

EventBridge Schedulerではターゲットに渡すペイロードでスケジュール時間を設定できるので、それをステートマシンの入力データとして受けるようにします。

> \<aws.scheduler.scheduled-time\> – The time you specified for the schedule to invoke its target, for example, 2022-03-22T18:59:43Z.[^scheduled_time]

</details>


### クラウド型ジョブスケジューラーサービスの設定


クラウド型ジョブスケジューラーサービスの詳細な設定手順については、以下の記事をご参照ください。

https://qiita.com/hs-matsu/items/3e8ff328cb3dccc0df5f


#### イベント送信先のEventBridgeのルール


![eventbridge_rules.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929201/2555939b-2eab-5279-0996-3e4a9000931b.png)


クラウド型ジョブスケジューラーサービスと連携するために、イベント送信先となるAWS環境上でEventBridgeの設定を行います。

EventBridgeのルールを作成する際は、ターゲットにStep Functionsのステートマシンを設定します。

ロールで、Step Functionsのステートマシンの`states:StartExecution`権限を付与します。


#### クラウド型ジョブスケジューラーサービスのカレンダー設定


![jobsaas_calender.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929201/eb1e219b-75c2-5518-d88e-edfb07720877.png)

今回の要件に合うカレンダーを作成します。

要件が「休日に実行する」なので、土曜日・日曜日を運用日に設定して、それ以外の曜日を休業日に設定します。また、祝日を運用日に設定します。


#### クラウド型ジョブスケジューラーサービスのジョブの実行ルール設定


![jobsaas_rule.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3929201/b0f1db2f-a8e4-a942-7b9b-a1320490dab6.png)

ジョブの実行ルールを設定します。

<dl>

  <dt>開始日の指定方法</dt>
  <dd>運用日</dd>

  <dt>開始日</dt>
  <dd>日付指定（第1運用日）</dd>

  <dt>開始時刻</dt>
  <dd>12:00</dd>

  <dt>繰り返し</dt>
  <dd>繰り返して実行する（1月毎に実行する）</dd>

</dl>


#### 実行予定日


上記設定により、以下のようなスケジュールで実行されます。

```:直近の実行予定
2024/12/01(日) 12:00
2025/01/01(水) 12:00
2025/02/01(土) 12:00
2025/03/01(土) 12:00
2025/04/05(土) 12:00
2025/05/03(土) 12:00
2025/06/01(日) 12:00
2025/07/05(土) 12:00
2025/08/02(土) 12:00
2025/09/06(土) 12:00
2025/10/04(土) 12:00
2025/11/01(土) 12:00
2025/12/06(土) 12:00
2026/01/01(木) 12:00
2026/02/01(日) 12:00
2026/03/01(日) 12:00
```


## CloudWatch Logsの全ロググループの先月分のログをS3バケットにバックアップする

### ステートマシンの入力データ

CloudWatch Logsのログをエクスポートする処理では「先月」の基準となる日付が必要です。

クラウド型ジョブスケジューラーサービスでは、イベント送信時のデータにジョブ実行時間`time`が含まれるので、これを使用します。


### CreateExportTaskで必要な引数を作成する


```json:Lambda args-for-create-export-task
    "Lambda args-for-create-export-task": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:args-for-create-export-task:$LATEST",
        "Payload": {
          "time.$": "$.time"
        }
      },
      "Next": "Pass NextToken",
      "OutputPath": "$.Payload"
    }
```

CloudWatch LogsのログをS3バケットにエクスポートする際は、AWSのAPI「[CreateExportTask](https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_CreateExportTask.html)」を使用します。

**CreateExportTaskの引数（一部抜粋）**
<dl>

 <dt>from</dt>
 <dd>エクスポートするログの期間（開始時間）</dd>

 <dt>to</dt>
 <dd>エクスポートするログの期間（終了時間）</dd>

 <dt>DestinationPrefix</dt>
 <dd>S3オブジェクトキーのプレフィックス</dd>

</dl>

`from`と`to`は[UNIX時間](https://ja.wikipedia.org/wiki/UNIX%E6%99%82%E9%96%93)で指定します。先ほどステートマシンの入力データ`time`を受け取りましたが、引数は[ISO 8601形式](https://ja.wikipedia.org/wiki/ISO_8601)です。つまり、<font color="Red">**ISO 8601形式の現在日時から先月1日のUNIX時間と今月1日のUNIX時間を作成**</font>しなくてはいけません。

Step Functionsでは[様々な組み込み関数](https://docs.aws.amazon.com/step-functions/latest/dg/intrinsic-functions.html)が提供されていますが、日付に関連する機能は2024年現在提供されていないようです。

できればLambdaを使用しないで実現したかったのですが、ここだけはLambdaの力を借りることにしました。

```javascript:CreateExportTaskの引数を作成するLambda関数のコード(Node.js 20.x)
exports.handler = async (event) => {
  const time = new Date(event.time);
  if ( Number.isNaN(time.getDate())){
    throw new TypeError("invalid argument: 'time': " + event.time);
  }
  const from = new Date(time.getFullYear(), time.getMonth(), -1, 1, 0, 0, 0, 0);
  return {
    args: {
      from: from.getTime(),
      to: new Date(time.getFullYear(), time.getMonth(), 1, 0, 0, 0, 0).getTime(),
      destinationPrefix: 'exportedlogs/'+
        ('0000' + from.getFullYear()).slice(-4) + "/" +
        ('00' + (from.getMonth() + 1)).slice(-2) + "/logs",
    }
  };
};

```

ついでなので、ここで`DestinationPrefix`のベース部分も作成しています（年と月でS3オブジェクトキーの階層を分けました）。最終的にはベース部分に「CloudWatch Logsのロググループ名」を結合したものを`DestinationPrefix`に指定します。

::: note
`destinationPrefix`は日付の後ろに「/logs」を付けていますが、これは「CloudWatch Logsのロググループ名」が「/」で始まるケースと「/」で始まらないケースの両方に対応するためです（例えば「exportedlogs/2024/**11vpc-flow-log-group**」や「exportedlogs/2024/11 **//** aws/lambda/args-for-create-export-task」みたいになるのは避けたいです）。[^duplication]
:::


### NextToken（null）を作成する


```json:Pass NextToken
    "Pass NextToken": {
      "Type": "Pass",
      "Next": "DescribeLogGroups",
      "Parameters": {
        "args.$": "$.args",
        "NextToken": null
      },
      "OutputPath": "$.temp",
      "ResultPath": "$.temp"
    }
```

CloudWatch Logsのロググループ一覧を取得する際は、AWSのAPI「[DescribeLogGroups](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatchLogs/latest/APIReference/API_DescribeLogGroups.html)」を使用します。

このAPIは一度に最大50個までのロググループしか取得できません。すべてのロググループを取得するためには引数`NextToken`（前回どこまで取得できたか）を指定して繰り返し実行する必要があります。

> limit
The maximum number of items returned. If you don't specify a value, the default is up to 50 items.

> nextToken
The token for the next set of items to return. (You received this token from a previous call.)

初回は`NextToken`を指定しなくても良いのですが、2回目以降では必須となります。初回と2回目以降の処理を共通化するために、ここで`NextToken`にnullを設定します。


### CloudWatch Logsのロググループ名を取得する（最大50個まで）


```json:DescribeLogGroups
    "DescribeLogGroups": {
      "Type": "Task",
      "Parameters": {
        "Limit": 50,
        "NextToken.$": "$.NextToken"
      },
      "Resource": "arn:aws:states:::aws-sdk:cloudwatchlogs:describeLogGroups",
      "Next": "Map extract",
      "ResultPath": "$.describe_log_groups"
    }
```

CloudWatch Logsのロググループを取得するためにAWSのAPI「[DescribeLogGroups](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatchLogs/latest/APIReference/API_DescribeLogGroups.html)」を呼び出します。


### 各ロググループ毎に処理を実行する


```json:Map extract
    "Map extract": {
      "Type": "Map",
      "ItemProcessor": {
        "ProcessorConfig": {
          "Mode": "INLINE"
        },
        "StartAt": "CreateExportTask",
        "States": {

          "～": "Map extract内の各ステートは省略　～"

        }
      },
      "Next": "Choice NextToken",
      "ItemsPath": "$.describe_log_groups.LogGroups",
      "MaxConcurrency": 1,
      "ItemSelector": {
        "args.$": "$.args",
        "LogGroupName.$": "$$.Map.Item.Value.LogGroupName"
      },
      "ResultPath": "$.map_extract"
    }
```

先ほど取得したCloudWatch Logsの各ロググループに対してエクスポートする処理を実行します。

配列の各要素毎の処理はMapステートで簡単に実現できます。`ItemsPath`に先ほど取得した配列要素を指定します。

配列以外に`$.args`も参照したいので、`ItemSelector`を使用します。この時、配列のデータは`$$.Map.Item.Value`で参照できます。

> The $$.Map.Item.Value context object contains the value of each individual data item.[^item_selector]

Mapステートはdefaultでは並列処理となります。今回のエクスポートタスクは同時実行できないので、`MaxConcurrency`（同時実行数）に1を指定します。

> Each account can only have one active (RUNNING or PENDING) export task at a time. [^export_task]

::: note warn
インラインMapステート内でエラーが発生した場合は、残りの処理は打ち切られてステートマシンの実行は失敗します。

> In this mode, the failure of any iteration causes the Map state to fail. All iterations stop when the Map state fails.[^inline_map]

もし一部処理が失敗しても残りの処理を継続したい場合は、エラーをキャッチする必要があります。
:::

### CloudWatch LogsのログをS3バケットにエクスポートするタスクを作成する


```json:CreateExportTask
          "CreateExportTask": {
            "Type": "Task",
            "Parameters": {
              "Destination": "${BucketName}",
              "DestinationPrefix.$": "States.Format('{}{}', $.args.destinationPrefix, $.LogGroupName)",
              "From.$": "$.args.from",
              "LogGroupName.$": "$.LogGroupName",
              "To.$": "$.args.to"
            },
            "Resource": "arn:aws:states:::aws-sdk:cloudwatchlogs:createExportTask",
            "Next": "Wait DescribeExportTasks",
            "Retry": [
              {
                "ErrorEquals": [
                  "States.TaskFailed"
                ],
                "BackoffRate": 1,
                "IntervalSeconds": 10,
                "MaxAttempts": 99999999,
                "Comment": "LimitExceededException",
                "MaxDelaySeconds": 10
              }
            ]
          }
```

CloudWatch Logsのログをエクスポートするタスクを作成するために、AWSのAPI「[CreateExportTask](https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_CreateExportTask.html)」を呼び出します。

`From`と`To`にはLambda関数`args-for-create-export-task`で作成したデータを指定します。

`DestinationPrefix`はCloudWatch Logsのロググループ名を組み合わせたいため、組み込み関数`States.Format`を使用しています。

> Use the States.Format intrinsic function to construct a string from both literal and interpolated values.[^format]

::: note warn
エクスポートタスクは同時実行できません。もし他で既にエクスポートタスクが実行中だった場合は`CreateExportTask`が失敗します。[^export_task]

そのため、`CreateExportTask`ステートではリトライ処理を設定しています。
:::


### Sleepする


```json:Wait DescribeExportTasks
          "Wait DescribeExportTasks": {
            "Type": "Wait",
            "Next": "DescribeExportTasks",
            "Seconds": 10
          }
```

`CreateExportTask`はエクスポートタスクを作成したら（タスクの完了を待たずに）終了します。エクスポートタスクは同時実行できないため、タスクの完了を待つ必要があります。

エクスポートタスクの状態を確認するAWSのAPIは「[DescribeExportTasks](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatchLogs/latest/APIReference/API_DescribeExportTasks.html)」です。エクスポートタスクは時間がかかるため、ここで適度にSleepしています。

::: note warn
短時間に連続してAWSのAPIを実行すると、スロットリングの制限に引っ掛かることがあります。

```json:エラーメッセージ
{
  "cause": "Rate exceeded (Service: CloudWatchLogs, Status Code: 400, Request ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)",
  "error": "CloudWatchLogs.ThrottlingException",
  "resource": "describeExportTasks",
  "resourceType": "aws-sdk:cloudwatchlogs"
}
```
:::


### エクスポートタスクの状態を取得する


```json:DescribeExportTasks
          "DescribeExportTasks": {
            "Type": "Task",
            "Parameters": {
              "TaskId.$": "$.TaskId"
            },
            "Resource": "arn:aws:states:::aws-sdk:cloudwatchlogs:describeExportTasks",
            "Next": "Choice DescribeExportTasks",
            "ResultPath": "$.status",
            "ResultSelector": {
              "Code.$": "$.ExportTasks[0].Status.Code"
            }
          }
```

エクスポートタスクの状態を確認するために、AWSのAPI「[DescribeExportTasks](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatchLogs/latest/APIReference/API_DescribeExportTasks.html)」を呼び出します。


### エクスポートタスクが完了するまでループする


```json:Choice DescribeExportTasks
          "Choice DescribeExportTasks": {
            "Type": "Choice",
            "Choices": [
              {
                "Or": [
                  {
                    "Variable": "$.status.Code",
                    "StringEquals": "RUNNING"
                  },
                  {
                    "Variable": "$.status.Code",
                    "StringEquals": "PENDING"
                  },
                  {
                    "Variable": "$.status.Code",
                    "StringEquals": "PENDING_CANCEL"
                  }
                ],
                "Next": "Wait DescribeExportTasks"
              },
              {
                "Variable": "$.status.Code",
                "StringEquals": "COMPLETED",
                "Next": "Success map"
              }
            ],
            "Default": "Fail map"
          }
```

エクスポートタスクの状態で処理を分岐します。

<dl>

<dt>RUNNING, PENDING, PENDING_CANCEL</dt>
<dd>タスク実行中なので「Wait DescribeExportTasks」に戻る
</dd>

<dt>COMPLETED</dt>
<dd>タスクが成功したので「Success map」に進む
</dd>

<dt>それ以外</dt>
<dd>タスクが失敗したので「Fail map」に進む
</dd>

</dl>

> Note: An AWS account can have only one export task for log data in the PENDING, PENDING_CANCEL, or RUNNING state.[^status]

::: note
エクスポートタスクの状態はAWSコンソールからも確認できます。

https://`${AWS::Region}`.console.aws.amazon.com/cloudwatch/home?region=`${AWS::Region}`#logsV2:export-tasks

※ `${AWS::Region}`にリージョンを指定
:::


### ロググループ毎の処理成功


```json:Success map
          "Success map": {
            "Type": "Succeed"
          }
```

`Map extract`の配列要素1つに対する処理が成功した状態です。


### ロググループ毎の処理失敗


```json:Fail map
          "Fail map": {
            "Type": "Fail"
          }
```

`Map extract`の配列要素1つに対する処理が失敗した状態です。

残りの処理は打ち切られてステートマシンの実行は失敗扱いとなります。


### 全ロググループを処理するまでループする


```json:Choice NextToken
    "Choice NextToken": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.describe_log_groups.NextToken",
          "IsPresent": true,
          "Next": "Pass loop"
        }
      ],
      "Default": "Success"
    }
```

`Map extract`の処理が完了したので、再度`DescribeLogGroups`を実行する必要があるかどうかを判定します。

AWSのAPI「[DescribeLogGroups](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatchLogs/latest/APIReference/API_DescribeLogGroups.html)」が`NextToken`を返していた場合、まだCloudWatch Logsのロググループが存在するので、`Pass loop`に遷移します。

`NextToken`を返さなかった場合、CloudWatch Logsのすべてのロググループに対してエクスポートタスクが完了したので、`Success`に遷移します。


### NextTokenを作成する


```json:Pass loop
    "Pass loop": {
      "Type": "Pass",
      "Next": "DescribeLogGroups",
      "Parameters": {
        "args.$": "$.args",
        "NextToken.$": "$.describe_log_groups.NextToken"
      }
    }
```

再び`DescribeLogGroups`を呼び出すために`NextToken`を設定します（`NextToken`以外の`$.describe_log_groups`データは不要なので、ここで捨てます。）。

設定したら`DescribeLogGroups`に遷移して、同じ処理を繰り返します。


### ステートマシン実行成功


```json:Success
    "Success": {
      "Type": "Succeed"
    }
```

すべての処理が完了したので、ステートマシンの実行を終了します。


### タイムアウトの設定


```json:タイムアウトの設定
{
  "StartAt": "Lambda args-for-create-export-task",
  "States": {

    "～": "各ステートは省略　～"

  },
  "TimeoutSeconds": 86400,
  "Comment": "CloudWatch logsのログをS3バケットへエクスポート"
}
```

Step Functionsのアクティビティは最大1年の実行時間制限があります。

何らかの理由で無限ループに陥った場合を考慮して、タイムアウトを設定します。

> By configuring a long timeout duration and actively sending a heartbeat, an activity in Step Functions can wait up to a year for an execution to complete.[^timeout]


# おわりに


使い慣れたプログラミング言語で実装した方が楽なので、新しい技術（Step Functions）に手を出すのは勇気がいったのですが、学習コストに見合う見返りがあったと思います。

Step Functionsは普段プログラミングをしている方であれば使いやすいと思います。
大変だったのはステート間のパラメータ受け渡しについてですが、以下の記事がとても分かりやすかったです。

https://dev.classmethod.jp/articles/stepfunctions-parameters-inter-states/

「毎月最初の休日の12:00に自動実行させる」という要件は、クラウド型ジョブスケジューラーサービスを使うことで簡単に実現することができました。


# 参考情報

- Amazon Web Service公式サイト
https://aws.amazon.com/jp/

- クラウド型ジョブスケジューラーサービス
https://www.hitachi-solutions.co.jp/jobschedulerservice/


[^retention]:https://docs.aws.amazon.com/ja_jp/managedservices/latest/userguide/log-customize-retention.html
[^timeout]:https://docs.aws.amazon.com/step-functions/latest/dg/concepts-activities.html
[^available_for_export]:https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/S3Export.html
[^scheduler]:https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-scheduled-rule-pattern.html
[^scheduled_time]:https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-context-attributes.html
[^duplication]:両方のケースで「/」の付与を切り分けすることは可能ですが、その場合は重複の可能性も考えないといけなくなります。
[^item_selector]:https://docs.aws.amazon.com/step-functions/latest/dg/input-output-itemselector.html
[^export_task]:https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_CreateExportTask.html
[^inline_map]:https://docs.aws.amazon.com/step-functions/latest/dg/state-map-inline.html
[^format]:https://docs.aws.amazon.com/step-functions/latest/dg/intrinsic-functions.html#asl-intrsc-func-generic
[^status]:https://repost.aws/knowledge-center/cloudwatch-logs-retrieve-data