先にまとめ
- Step Functionsを使ってCloudWatch Logsのログストリーム整理する処理を作ってみた。
- Step FunctionsからAWSのAPIを叩くことができるので、あまりLambdaに頼らずに処理をかける。
- 安直に処理を書くとけっこう簡単にStep Functionsの制限に引っかかる。
- マネージメントコンソールから見ると、どういった処理なのか見通すことはできるが、CDKで記載したコード側から見ると、正直見通しがよいとは言えない。
- ロググループの機能として保持期間の設定とかあるので、それで空になったログストリームは削除してくれる機能も提供されるといいのに
- 無理にStep Functionsだけでワークフローを組むとコストが見合わないこともある。(2022-01-11追記)
はじめに
CloudWatch Logsって保持期間の設定があって、保持期間を経過したログ自体は削除してくれるものの、結果からになったログストリームは削除してくれません。
結果、空になったログストリームが大量に残ることになり、マネージメントコンソール等からログ確認しようとした時に、面倒になったりします。
そこで、空になったログストリームを削除することをやろうと考えました。
アプローチの検討
今回のような要件の場合、ストレートに考えると、Lambdaを定期実行して、LambdaからCloudWatchのAPIを叩く、というアプローチになると思います。
実際にこのアプローチを試みている人はいて、以下の記事でも試みられています。
私も同様の処理を書いて試してみたのですが、私の環境ではログストリームの数が多すぎたせいか、Lambdaの制限時間内で処理できず、何らかの別の方法をとる必要がありました。
単純に「Step Functionsで処理をバラしてもっと小さい単位でLambda処理かなぁ…」と思っていたところ、Step Functionsにこんなアップデートが入ったじゃないですか。
「これはLambdaなしにStep Functions Onlyでやるしかないでしょ!」
要件とか設計方針とか
要件
- 保持期間が過ぎて空になっているログストリームを削除する。
- この処理は1日1回自動で起動するものとする。
- 保持期間設定されていないロググループに対しては保持期間として30日を設定する。
- 追加要件。Lambda関数を普通に作ると、保持期間未設定のロググループが作られてしまう関係で、大量にログが残ってしまっていたので。
- 空になっているロググループは削除する。
- 追加要件。正直この要件を入れてもいいのか悩みましたが、Lambdaで色々作っては捨ててを繰り返している関係で、不要になったロググループが結構残っていたので、今回はこの要件を入れました。
- 作成後、1週間を経過していないロググループは処理の対象としない。
- 作りたてのものを対象にしてしまうと、開発中のものに干渉しちゃう可能性もあるので。
設計方針
- 処理本体はStep Functionsで行う。
- Lambdaは極力使わない。
- 1日1回の実行はEventBridgeトリガーで行う。
- 今回のスタックはCDK+TypeScriptで記述を行う。
設計・実装
とりあえず作成したソースはこちらに置いてあります。
このコードからできたStep Functionsのワークフローはこんな感じです。
このワークフローの中で語っておきたいところをピックアップして書いていきます。
なお、2枚画像を貼ったことでわかるように、今回のスタック内にはステートマシンを2つ作っています。
その辺の事情も含めて書いていきます。
GetEventTime
EventBridgeからの定時実行される際にStep Functionsへ渡される入力は以下。
{
"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には便利なものがあります。
裏ではLambdaとしてデプロイされるのですが、CDKでスタックを書く側としてはLambdaを使っていると意識する必要はありません。
使い方はこんな感じ。
const getEventTime = new tasks.EvaluateExpression(this, 'GetEventTime', {
expression: 'Date.parse($.time)',
resultPath: '$.eventTime',
});
変換や算術演算とか必要になる状況では同じようにEvaluateExpression
を使います。
DescribeLogGroups
ロググループの取得はDiscribeLogGroups
APIを使用します。
これをCDK上でStep Functionsから呼び出すにはこんな感じ。
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回リトライするように設定しました。
この辺りのリトライ処理の記述はシンプルにできていいですね。
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
const logStreamsMap = new tasks.StepFunctionsStartExecution(this, 'LogStreamFlowMap', {
stateMachine: stateMachineLogStreamFlow,
integrationPattern: sfn.IntegrationPattern.RUN_JOB,
resultPath: sfn.JsonPath.DISCARD,
});
2つ目のステートマシンを使って処理しています。
ステートマシンを分けた理由ですが、Step Functionsには、実行履歴のイベント上限が25,000、という制限があります。
1ステップ1イベント、とかならまだいいんですが、タスクの内容によっては、1ステップ5イベントとか履歴に記録されているので、Mapなどを使って繰り返し処理を実施してしまうと、けっこう簡単に25,000という上限に引っかかってしまいます。
これを回避するために、繰り返し呼び出されるループ箇所を別ステートマシンに分けています。
LogGroupMapLast, LogStreamMapFlowLast
命名がグダグダでしたが…
Mapの最後に状態を空オブジェクトにするステップを入れています。
const logStreamMapLast = new sfn.Pass(this, 'LogStreamsMapLast', {
result: sfn.Result.fromObject({}),
});
Mapとしての結果はMap内で処理したすべての結果を配列にまとめたものになりますが、その際に下のリンクに記載のある「タスク、状態、実行の最大入力または出力サイズ」の上限に引っかかってしまったため、ループの最後に明確に潰すようにしました。
後から考えれば、途中で結果を捨てるとかしていけば、もっとシンプルに解決できていたかもしれないです。
コード全体を通して
Step Functionsのマネージメントコンソールからワークフローの確認はできるのでそちらからの全体把握は行いやすいのですが、CDKのコード側から見ると、正直見通しがよいコードとは言えず、どういう書き方がベストなのかわかっていません。
終わりに
今回のスタックを使って、実際にデイリーでのログストリーム整理処理を走らせています。
今はデイリーで流しているので、1時間程度で処理が終わっていますが、空のログストリームが大量に残っていた初回は数時間かかっていました。(本当に大量に残っていたので…)
せっかく保持期間設定が提供されているので、それに伴って空になってしまったログストリームを削除してくれる機能もAWS側で提供してくれればいいのになぁ…とちょっと思ったりしました。
2022-01-11追記
今月のコスト状況を見たところ、約10日でこんな感じ。
ちょっとやっている内容と費用がバランスしないですね。ちょっと構成を見直そう…