Step Functionsを使ってCloudWatch Logsのログストリーム整理を行なってみた

# 先にまとめ

- Step Functionsを使ってCloudWatch Logsのログストリーム整理する処理を作ってみた。
- Step FunctionsからAWSのAPIを叩くことができるので、あまりLambdaに頼らずに処理をかける。
- 安直に処理を書くとけっこう簡単にStep Functionsの制限に引っかかる。
- マネージメントコンソールから見ると、どういった処理なのか見通すことはできるが、CDKで記載したコード側から見ると、正直見通しがよいとは言えない。
- ロググループの機能として保持期間の設定とかあるので、それで空になったログストリームは削除してくれる機能も提供されるといいのに
- 無理にStep Functionsだけでワークフローを組むとコストが見合わないこともある。(2022-01-11追記)

# はじめに

CloudWatch Logsって保持期間の設定があって、保持期間を経過したログ自体は削除してくれるものの、結果からになったログストリームは削除してくれません。

結果、空になったログストリームが大量に残ることになり、マネージメントコンソール等からログ確認しようとした時に、面倒になったりします。

そこで、空になったログストリームを削除することをやろうと考えました。

# アプローチの検討

今回のような要件の場合、ストレートに考えると、Lambdaを定期実行して、LambdaからCloudWatchのAPIを叩く、というアプローチになると思います。

実際にこのアプローチを試みている人はいて、以下の記事でも試みられています。

https://blog.serverworks.co.jp/tech/2020/03/13/schedule-delete-log-stream-with-lambda/

私も同様の処理を書いて試してみたのですが、私の環境ではログストリームの数が多すぎたせいか、Lambdaの制限時間内で処理できず、何らかの別の方法をとる必要がありました。

単純に「Step Functionsで処理をバラしてもっと小さい単位でLambda処理かなぁ…」と思っていたところ、Step Functionsにこんなアップデートが入ったじゃないですか。

https://aws.amazon.com/jp/about-aws/whats-new/2021/09/aws-step-functions-200-aws-sdk-integration/

「これはLambdaなしにStep Functions Onlyでやるしかないでしょ！」

# 要件とか設計方針とか

## 要件

- 保持期間が過ぎて空になっているログストリームを削除する。
- この処理は1日1回自動で起動するものとする。
- 保持期間設定されていないロググループに対しては保持期間として30日を設定する。
    - 追加要件。Lambda関数を普通に作ると、保持期間未設定のロググループが作られてしまう関係で、大量にログが残ってしまっていたので。
- 空になっているロググループは削除する。
    - 追加要件。正直この要件を入れてもいいのか悩みましたが、Lambdaで色々作っては捨ててを繰り返している関係で、不要になったロググループが結構残っていたので、今回はこの要件を入れました。
- 作成後、1週間を経過していないロググループは処理の対象としない。
  - 作りたてのものを対象にしてしまうと、開発中のものに干渉しちゃう可能性もあるので。

## 設計方針

- 処理本体はStep Functionsで行う。
- Lambdaは極力使わない。
- 1日1回の実行はEventBridgeトリガーで行う。
- 今回のスタックはCDK+TypeScriptで記述を行う。

![StepFunctions.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/83499/0e840cb6-d323-d298-f039-06c0bf127462.png)

# 設計・実装

とりあえず作成したソースはこちらに置いてあります。

https://github.com/masaminh/clean_cloudwatch_logs

このコードからできたStep Functionsのワークフローはこんな感じです。

![stepfunctions_graph.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/83499/5d39e341-a7a0-d46e-93f7-b43791a88787.png)

![stepfunctions_graph (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/83499/02340a54-c73b-de9a-42a9-3aa738948a4d.png)


このワークフローの中で語っておきたいところをピックアップして書いていきます。
なお、2枚画像を貼ったことでわかるように、今回のスタック内にはステートマシンを２つ作っています。
その辺の事情も含めて書いていきます。

## GetEventTime

EventBridgeからの定時実行される際にStep Functionsへ渡される入力は以下。

```json
{
  "version": "0",
  "id": "01234567-89ab-cdef-0123-456789abcdef",
  "detail-type": "Scheduled Event",
  "source": "aws.events",
  "account": "123456789012",
  "time": "2021-12-18T00:35:51Z",
  "region": "ap-northeast-1",
  "resources": [
    "arn:aws:events:ap-northeast-1:123456789012:rule/CleanCloudwatchLogsStack-ap-northeast-Rule01234567-XXXXXXXXXXXXX"
  ],
  "detail": {}
}
```

ここの`time`を現時刻として使いたいと思います。
ただ、この先で使う予定の`DescribeLogGroups`や`DescribeLogStreams`で取得できる時刻はエポックミリ秒なので、この現時刻もエポックミリ秒に変換する必要があります。

「Lambdaは極力使わない」と言っていたのに早速Lambda頼り…ではありますが、CDKには便利なものがあります。

https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-stepfunctions-tasks.EvaluateExpression.html

裏ではLambdaとしてデプロイされるのですが、CDKでスタックを書く側としてはLambdaを使っていると意識する必要はありません。

使い方はこんな感じ。

```typescript
const getEventTime = new tasks.EvaluateExpression(this, 'GetEventTime', {
  expression: 'Date.parse($.time)',
  resultPath: '$.eventTime',
});
```

変換や算術演算とか必要になる状況では同じように```EvaluateExpression```を使います。

## DescribeLogGroups

ロググループの取得は```DiscribeLogGroups```APIを使用します。

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatchLogs/latest/APIReference/API_DescribeLogGroups.html

これをCDK上でStep Functionsから呼び出すにはこんな感じ。

```typescript
const describeLogGroups = new tasks.CallAwsService(this, 'DescribeLogGroups', {
  service: 'cloudwatchlogs',
  action: 'describeLogGroups',
  iamAction: 'logs:describeLogGroups',
  iamResources: ['*'],
  resultPath: '$.describeLogGroupOutput',
}).addRetry(retryProps);
```

service, actionなどは、APIのドキュメントを見て書く流れですが、「Workflow Studio」を使ってサンプルのワークフローを実際に配置しつつ調べるほうが早いかもです。
iamAction, iamResourceのところに関しても、これらの文字列からそのままIAMポリシーが作られますので、ドキュメントを見ながら書く形になると思います。
(存在しないアクションをiamActionの文字列に書いてもそのままIAMポリシーに記載されます)

addRetryのところは、APIの実行時に例外が飛んだ時のリトライ方法を記載。
スロットリング時のリトライを想定して1秒後、2秒後、4秒後…と5回リトライするように設定しました。
この辺りのリトライ処理の記述はシンプルにできていいですね。

```typescript
const retryProps: sfn.RetryProps = {
  errors: ['CloudWatchLogs.CloudWatchLogsException', 'CloudWatchLogs.SdkClientException'],
  interval: cdk.Duration.seconds(1),
  maxAttempts: 5,
  backoffRate: 2,
};
```

なお、今回の環境では、ロググループの数が50以下のため、```DescribeLogGroups```を繰り返し呼ぶ作りにはする必要ありませんでしたが、汎用的な本来的な作りであれば、```DescribeLogGroups```の戻り値に含まれる```NextToken```がなくなるまで繰り返し呼ぶ、というのが正解になると思います。

また、これ以降もCloudWatch APIを使っている箇所はありますが、同様に`CallAwsService`を使用しています。

## LogStreamFlowMap

```typescript
const logStreamsMap = new tasks.StepFunctionsStartExecution(this, 'LogStreamFlowMap', {
  stateMachine: stateMachineLogStreamFlow,
  integrationPattern: sfn.IntegrationPattern.RUN_JOB,
  resultPath: sfn.JsonPath.DISCARD,
});
```

2つ目のステートマシンを使って処理しています。
ステートマシンを分けた理由ですが、Step Functionsには、実行履歴のイベント上限が25,000、という制限があります。

https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/bp-history-limit.html

1ステップ1イベント、とかならまだいいんですが、タスクの内容によっては、1ステップ5イベントとか履歴に記録されているので、Mapなどを使って繰り返し処理を実施してしまうと、けっこう簡単に25,000という上限に引っかかってしまいます。

これを回避するために、繰り返し呼び出されるループ箇所を別ステートマシンに分けています。

## LogGroupMapLast, LogStreamMapFlowLast

命名がグダグダでしたが…
Mapの最後に状態を空オブジェクトにするステップを入れています。

```typescript
const logStreamMapLast = new sfn.Pass(this, 'LogStreamsMapLast', {
  result: sfn.Result.fromObject({}),
});
```

Mapとしての結果はMap内で処理したすべての結果を配列にまとめたものになりますが、その際に下のリンクに記載のある「タスク、状態、実行の最大入力または出力サイズ」の上限に引っかかってしまったため、ループの最後に明確に潰すようにしました。

https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/limits-overview.html

後から考えれば、途中で結果を捨てるとかしていけば、もっとシンプルに解決できていたかもしれないです。

## コード全体を通して

Step Functionsのマネージメントコンソールからワークフローの確認はできるのでそちらからの全体把握は行いやすいのですが、CDKのコード側から見ると、正直見通しがよいコードとは言えず、どういう書き方がベストなのかわかっていません。

# 終わりに

今回のスタックを使って、実際にデイリーでのログストリーム整理処理を走らせています。
今はデイリーで流しているので、1時間程度で処理が終わっていますが、空のログストリームが大量に残っていた初回は数時間かかっていました。(本当に大量に残っていたので…)

せっかく保持期間設定が提供されているので、それに伴って空になってしまったログストリームを削除してくれる機能もAWS側で提供してくれればいいのになぁ…とちょっと思ったりしました。

# 2022-01-11追記

今月のコスト状況を見たところ、約10日でこんな感じ。
ちょっとやっている内容と費用がバランスしないですね。ちょっと構成を見直そう…

![スクリーンショット 2022-01-11 21.37.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/83499/1dd50ea1-ebe6-be37-ac52-b5d4be0516b8.png)