はじめに
こんばんは、mirukyです。
数ヶ月前、CloudWatch Logsの保持期間設定と、アーカイブの文脈でS3エクスポートを担当しました。
もともとCloudWatch Logsは無期限保持になっていたため、保持期間を365日に変更する対応は、厳密にはコスト削減の意味合いが強いものでした。ただし、非機能要件としては「ジャーナルログを365日保持する」という整理であり、365日分のログはCloudWatch Logs上に残す必要がありました。
加えて、障害対応や監査観点から、CloudWatch Logsからは365日で削除しつつ、別にS3へもアーカイブすることになりました(こう書くとCloudWatchのログそのままでいいでしょ、となりますよね。非機能要件がなあなあになっていたので、それをきちんとしよう、というのが今回の趣旨でした)。
最初はいつも通りEventBridgeで定期起動して、LambdaでAPIを叩けば簡単にエクスポートできるだろう、と考えていました。しかし実際には、ExportTaskの制約、Lambdaの実行時間、SQS FIFOでの直列制御、初回移行の順番、S3ライフサイクルの仕様など、思ったより考えることが多くありました。
ちょっと一般的では無いのかもしれませんが、こういう特殊な要件に備えて、備忘録的な意味も込めて、どのように設計したかなどを含めて共有できればと思います!
目次
- 要件
- CloudWatch LogsをS3へエクスポートする方式の候補
- 今回採用した構成
- CreateExportTask方式で気をつけたこと
- Lambda + SQS FIFOで実装するうえで考慮したこと
- 結構危ない保持期間設定の順番
- 初回リリース時の手動エクスポートについて
- S3 Glacier Instant Retrievalへ直接保存できない
- S3ライフサイクル0日移行では128KB未満のログは移行されない
- S3に置いたログをAthenaで調査できるようにした
- 今回の構成でよかった点と微妙だった点
1. 要件
要件は、大きく分けると2つでした。
$$
\displaylines{
\text{①ジャーナルログの365日間の保持} \\
\text{②障害調査用・監査用に一応ログはアーカイブしておく}
}
$$
ジャーナルログ、とやや格式張った名前をしていますが、今回はイコールCloudWatch Logsのログのことだと思ってください。これはPJによると思います。
①のCloudWatch Logs(ジャーナルログ)側の対応は、あくまで保持期間を365日にすることです。CloudWatch Logsのログデータはデフォルトでは無期限に保存され、ロググループごとに保持期間を設定できます。
一方、②では保持期間365日が経過する前に、定期的にS3にログをエクスポートし、永続保管・アーカイブを行います。これは、CloudWatch Logsの保持期間設定とは別の話です。障害対応で過去ログを確認したい、監査観点で一定期間ログを退避したい、という運用を加味した要件です。
今回のS3側は、厳密な法令監査や改ざん防止まで含む要件ではありませんでした。もしそのレベルまで求められるなら、S3 Object Lock、バケットポリシー、KMSキー管理、CloudTrailでの操作監査まで含めて設計する必要がありますが、その必要が無かったのは救いでした。
2. CloudWatch LogsをS3へエクスポートする方式の候補
CloudWatch LogsをS3へ送る方法はいくつかあります。今回は最初に3つの候補を比較しました。
| 候補 | 構成 |
|---|---|
| ①サブスクリプションフィルター + Kinesis Data Streams / Amazon Data Firehose | CloudWatch Logsからリアルタイムに配送する |
| ②EventBridge Scheduler + Step Functions + CreateExportTask(API) | 状態管理しながらExportTaskを実行する |
| ③EventBridge Scheduler + Lambda + SQS FIFO + Lambda | ロググループ単位でSQS FIFOに積み、LambdaでExportTaskを起動する |
CloudWatch Logsのログを継続的にアーカイブする用途では、 CreateExportTask ではなくサブスクリプションの利用が推奨されています。①のサブスクリプションフィルターは、CloudWatch LogsのログイベントをKinesis Data Streams、Amazon Data Firehose、Lambdaなどへほぼリアルタイムに送る仕組みです。AWS認定試験でよく聞くと思います。①の構成以外にも、リアルタイムの組み方はいくつかあります。
ただ、今回の主眼は基本的に 定期的なバッチエクスポート でした。リアルタイム配送の仕組みを新たに組むより、既存ログを期間指定でS3へ出せる CreateExportTask のほうが要件に合っていました。要は、リアルタイムシステムを組むのは過剰な構成だということです。
ここで①に含有されたリアルタイム構成が消えたので、②と③が残りました。
続いて、②のStep Functionsを使うと、待機、状態管理、リトライ、タイムアウトの扱いがかなり素直になります。今振り返ると、設計としてはStep Functionsも有力でした。というか、Step Functionsを使った方がすんなりいった可能性が高いと思います(隣の芝生は青い)。
それでも今回は、チーム内でLambdaとSQSの運用経験が豊富だったことから、Lambda + SQS FIFOの構成を採用しました。
タイトル回収になりますが、最も簡単だと思っていた ために採用しました。
3. 今回採用した構成
2章で触れたとおり、今回は③のEventBridge Scheduler + Lambda + SQS FIFO + Lambda構成を採用しました。全体像は次の形です。文字文字しいですが、これを見るだけで注意点も含めて、大体掴めるように作成してあります。
処理の流れは次のとおりです。上の構成図をズームアップした画像をもとに、説明します。
①EventBridge Schedulerで毎日0:00にLambdaを起動する
②1つ目のLambdaで保持期間365日のロググループを取得する
最初のLambdaでは、実際のエクスポート処理までは行いません。対象になるロググループを洗い出し、後続処理へ渡すための入口にしています。
今回は「CloudWatch Logs上で365日保持するロググループ」が対象だったため、保持期間365日のロググループを取得する形にしました。対象範囲をここで絞ることで、意図しないロググループをエクスポート対象にしないようにしています。
③取得したロググループをSQS FIFOへメッセージとして投入する
④2つ目のLambdaがSQS FIFOから1ロググループずつ処理する
Lambdaには、皆さんご存知のとおり最大15分の実行時間制限があります。Step FunctionsやAWS Lambda Durable Functionsで待機処理を外に出す構成はいったん置いておくと、 CreateExportTask はCloudWatch Logs側で実行される非同期処理なので、完了までかなり時間がかかる場合があります。
仮に全ロググループ分のエクスポートを1つのLambda実行に詰め込むと、Lambdaの実行時間制限を超える可能性があります。そのため、SQS FIFOにロググループ単位でメッセージを積み、2つ目のLambdaは1ロググループにつき1回起動する構成にしました。詳しくは、後のセクションでも触れます。
ちなみにAWS Lambda Durable Functionsは、中断しても進行状況を維持しながら長時間の実行を扱える仕組みなので、今回のような待機処理にも使える可能性があります。ただ、今回の対応ではチーム内の運用実績がなく、監視や失敗時対応まで含めると既存のLambda + SQS FIFO構成のほうがすぐに組み込めたため、採用しませんでした。時期的にGA直後くらいだったんですよね、、。
⑤対象ログが360日前であれば CreateExportTask を呼び出す
⑥CloudWatch Logs側のExportTaskがS3へログを出力する
⑦S3ライフサイクル0日移行でGlacier Instant Retrievalへ移行する
定期運用では、365日前のログではなく360日前のログをエクスポート対象にしました。365日ぴったりを狙うと、ExportTaskの遅延やリトライが発生したときに、保持期間切れまでの余裕が小さくなります。
また、 CreateExportTask ではS3のストレージクラスを指定できません。そのため、CloudWatch LogsからS3へ出力したあと、S3ライフサイクルルールでGlacier Instant Retrievalへ移行する構成にしています。
⑧10回、約150分で完了しない場合はDLQへ移し、CloudWatch AlarmからSNSへ通知する
ExportTaskはログ量や対象期間によって完了までの時間が変わります。短時間で終わる前提にすると、少し遅延しただけで異常扱いになってしまいます。
そこで、SQS FIFOのリトライで一定時間は待ち、それでも完了しない場合だけDLQへ移す形にしました。今回は10回、約150分を目安にし、DLQにメッセージが入ったらCloudWatch AlarmからSNSへ通知して、手順書に沿って確認する運用にしています。
この章では、構成図の読み方を中心に整理しました。以降の章では、この構成にした理由を CreateExportTask 、Lambda + SQS FIFO、初回移行、S3ライフサイクルの順に分けて説明します。
4. CreateExportTask方式で気をつけたこと
CreateExportTask は名前だけ見ると簡単そうですよね。ロググループ、期間、S3バケット、プレフィックスを指定すれば、CloudWatch LogsがS3へエクスポートしてくれます。
ただし、実装してみるといくつか特性があります。
| 特性 | 内容 |
|---|---|
| ①非同期タスクである | LambdaがログをS3へ直接書き込むのではなく、CloudWatch Logs側のExportTaskを起動する |
| ②完了待ちが必要 | ExportTaskは数秒で終わることもあれば、数時間かかることもある |
| ③並列実行できない | アクティブなエクスポートタスクはアカウントごとに1つ。これはクォータ制限なので、プログラム側でも並列にすることはできません。 |
| ④S3バケットに制約がある | エクスポート先S3バケットはログデータと同じリージョンに置く必要がある |
| ⑤ストレージクラスを指定できない |
CreateExportTask のAPIパラメータにはS3ストレージクラスがない(後述します) |
特に大きいのは、 アクティブなエクスポートタスクが、同時刻に1アカウント1リージョンでは1つだけ という制約です。
これはロググループ単位の制約ではありません。アカウント、リージョン単位の制約です。つまり、ロググループAのExportTaskが実行中であれば、ロググループBのExportTaskも新しく開始できません。
この制約が、後述する直列制御の前提になりました。複数のロググループがあっても、ExportTaskの起動は1つずつ扱う必要があります。
5. Lambda + SQS FIFOで実装するうえで考慮したこと
3章で構成全体には触れたので、ここではメッセージを受け取ったLambda側の制御に絞ります。
| 観点 | 考えたこと |
|---|---|
| 直列制御 | 実行中または待機中のExportTaskがある場合は新規タスクを起動しない |
| べき等性 | 同じメッセージが再処理されても二重エクスポートしない |
| DLQ通知 | 長時間失敗したメッセージを検知できるようにする |
メッセージを受け取ったLambdaは、いきなり CreateExportTask を呼び出さないようにしました。まずCloudWatch Logs側のタスク状態を確認し、実行中または待機中のExportTaskがある場合は、新しいタスクを作らず同じメッセージを後で再処理します。
直列制御の考え方は、簡略化すると次のようになります。
def handle_message(message):
# 対象ロググループと対象期間を取り出す
export_target = parse_export_target(message)
# 360日前のログだけをエクスポート対象にする
if not is_target_day(export_target, days_ago=360):
return "skip"
# すでに完了済みなら再実行しない
if is_already_exported(export_target):
return "done"
# すでに起動済みのタスクがあれば完了状況を確認する
task_id = find_existing_task_id(export_target)
if task_id:
if is_export_task_completed(task_id):
mark_exported(export_target)
return "done"
raise RetryLaterError("ExportTaskが完了していません")
# 実行中または待機中のExportTaskがあれば後で再処理する
if has_active_export_task():
raise RetryLaterError("ExportTaskが実行中です")
# 実行可能な場合だけExportTaskを作成する
task_id = create_export_task(export_target)
# タスクIDを記録して、次回以降の確認に使う
save_export_task_id(export_target, task_id)
raise RetryLaterError("ExportTaskの完了を後続リトライで確認します")
FIFOキューを使っても、Lambda側の処理はべき等に作る必要があります。ExportTaskを起動したあと、Lambdaの実行時間内にタスクが完了しない場合、同じメッセージが再処理されます。そのため、対象ロググループと対象期間をキーにして、起動済みのExportTaskや完了済みかどうかを判定できるようにしました。同じメッセージがもう一度来ても、同じ期間を不用意に再エクスポートしないような設計が肝要です。
DLQまわりは、次のように考えました。
| 設定 | 意図 |
|---|---|
maxReceiveCount を10にする |
一時的な遅延やExportTask実行中をリトライで吸収する |
| LambdaタイムアウトとSQS可視性タイムアウトを揃える | 処理中メッセージが早く再表示される事故を避ける |
| DLQにCloudWatch Alarmを設定する | 5分間で1件でもDLQに入ったら検知する |
| SNS トピックへ通知する | 開発者・運用担当者が手順書に沿って対応できるようにする |
たとえば、1回あたり最大15分待つ設計で maxReceiveCount を10にすると、概算では最大150分程度は再試行できます。それでも完了しない場合は、対象タスクを表すSQS FIFOメッセージをDLQへ移し、人が確認する判断にしました。
6. 結構危ない保持期間設定の順番
今回一番気をつけたのは、実装そのものよりも作業順序でした。
CloudWatch Logsはもともと無期限保持でした。そこに365日の保持期間を設定すると、現在の保持設定より古いデータは削除対象になります。
つまり、システムが700日前から稼働していた場合、先に保持期間を365日に変更してしまうと、700日前から365日前までのログを失う可能性があります。ぱっと見、設定した日にちを0日として、そこから365日経つと削除、みたいに考えてしまいがちなんじゃないかと個人的に思いますが、そうではなく、純粋にログが発生した日を元に計算されることに注意が必要です。
そのため、作業順序は次のようにしました。縦軸は一日の時間、横軸は日にちの、カレンダーのようなイメージで見てください。
大事なのは、 保持期間を設定する前に、削除対象になりうる過去ログをS3へ退避する ことです。
CloudWatch Logsは、保持期間に到達したログイベントをすぐに物理削除するわけではなく、通常は削除まで最大72時間かかると説明されています。ただし、これは安全な猶予として当てにするものではありません。保持期間を設定した時点で、古いログは削除対象として扱われます。
既存システムに後から保持期間を入れる場合は、場合によってインシデントにもなり得る、かなり重要な視点です(長年続いている大規模PJでは特に怖いですね)。
7. 初回リリース時の手動エクスポートについて
通常運用では、毎日0:00に起動したLambdaが360日前のログをS3へエクスポートします。しかし初回リリース時だけは、すでにCloudWatch Logsに溜まっている過去ログをまとめて退避する必要がありました。
そこで、SQS FIFOへキューイングするLambdaに、定期エクスポート用の処理とは別に、手動エクスポート用の入り口も用意しました。
手動エクスポートでは、次のようなイベントをLambdaに渡す想定です。
AWSマネコンのテストタブで、下記のようなイベントを渡しました。
{
"mode": "manual",
"fromDate": "2024-01-01",
"toDate": "2025-05-27",
"logGroupNames": [
"/aws/lambda/example-api"
]
}
このイベントを受け取ったLambdaで、対象ロググループと対象期間をSQS FIFOへ投入します。実際の CreateExportTask 起動は、5章で触れた後続Lambda側に寄せています。
import hashlib
import json
import os
from datetime import datetime, timezone
import boto3
logs = boto3.client("logs")
sqs = boto3.client("sqs")
QUEUE_URL = os.environ["EXPORT_QUEUE_URL"]
MESSAGE_GROUP_ID = os.environ.get("MESSAGE_GROUP_ID", "cloudwatch-logs-export")
def to_epoch_ms(date_text):
# CloudWatch LogsのCreateExportTaskはミリ秒のUnix時刻で期間を指定する
dt = datetime.strptime(date_text, "%Y-%m-%d").replace(tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
def iter_target_log_groups(log_group_names):
# 手動指定がある場合は、そのロググループだけを対象にする
if log_group_names:
yield from log_group_names
return
# 指定がない場合は、保持期間365日のロググループを対象にする
paginator = logs.get_paginator("describe_log_groups")
for page in paginator.paginate():
for group in page.get("logGroups", []):
if group.get("retentionInDays") == 365:
yield group["logGroupName"]
def build_deduplication_id(log_group_name, from_ms, to_ms):
# FIFOキューの重複排除IDが長くなりすぎないようにハッシュ化する
source = f"{log_group_name}:{from_ms}:{to_ms}"
return hashlib.sha256(source.encode("utf-8")).hexdigest()
def lambda_handler(event, context):
if event.get("mode") != "manual":
raise ValueError("modeにはmanualを指定してください")
from_ms = to_epoch_ms(event["fromDate"])
to_ms = to_epoch_ms(event["toDate"])
log_group_names = event.get("logGroupNames", [])
queued_count = 0
for log_group_name in iter_target_log_groups(log_group_names):
message = {
"mode": "manual",
"logGroupName": log_group_name,
"from": from_ms,
"to": to_ms,
"destinationPrefix": f"manual/{event['fromDate']}_{event['toDate']}"
}
sqs.send_message(
QueueUrl=QUEUE_URL,
MessageBody=json.dumps(message, ensure_ascii=False),
MessageGroupId=MESSAGE_GROUP_ID,
MessageDeduplicationId=build_deduplication_id(log_group_name, from_ms, to_ms)
)
queued_count += 1
return {
"queuedCount": queued_count,
"from": from_ms,
"to": to_ms
}
上のコードは、あくまでキュー投入側のサンプルです。ログ量が多い場合は、ロググループ単位だけでなく、期間も分割して投入できるようにしておくと安全です。
対象期間は、システム稼働開始時点から360日前までとしました。たとえば、システムが700日前から稼働していた場合、700日前から360日前までを初回エクスポート対象にするイメージです。
365日前ではなく360日前にしたのは、リリース作業が業後であり、初回エクスポートが当日中に終わらない可能性があったためです。事前検証でも、ログ量やロググループ数によってExportTaskの完了時間が大きく変わることが分かっていました。
そのため、5日分のバッファを持たせて360日前までを退避し、翌日に完了を確認してからCloudWatch Logsの保持期間を365日に変更しました。
この判断はかなり地味ですが、実務では一番重要でした。コードが正しくても、作業順序を間違えると過去ログが消えます。
8. S3 Glacier Instant Retrievalへ直接保存できない
次に苦戦したのがS3側です。
最初は、CloudWatch LogsからS3へエクスポートするときに、コストメリットと取り出し易さのバランスの良さから、出力先ストレージクラスとしてS3 Glacier Instant Retrievalを指定しようと思っていました。
しかし、 CreateExportTask のAPIパラメータには、出力先S3バケットとプレフィックスはありますが、S3ストレージクラスを指定する項目はありません。
そのため、CloudWatch LogsからはいったんS3へ通常どおり出力し、その後にS3ライフサイクルルールでGlacier Instant Retrievalへ移行する構成にしました。
S3へ自分で PutObject する処理であれば、 GLACIER_IR のようなストレージクラスを指定できます。しかし、今回のようにCloudWatch Logsの CreateExportTask に任せる場合、その指定口がありません。
なので、別途S3ライフサイクルで対応する必要があります。
9. S3ライフサイクル0日移行では128KB未満のログは移行されない
さらに、S3ライフサイクルでも動作確認中に発見がありました。
開発環境にて、S3ライフサイクルルールで0日後にGlacier Instant Retrievalへ移行する設定にしました。これで、CloudWatch Logsから出力されたファイルはすぐGlacier Instant Retrievalへ移ると考えていました。
しかし実際には、多くのログファイルが移行されませんでした。
原因は、 128KB未満のオブジェクトはデフォルトではライフサイクル移行されない という仕様です。2024年9月以降、S3ライフサイクル設定のデフォルト挙動として、128KB未満のオブジェクトはどのストレージクラスにも移行されないと説明されています。
今回のCloudWatch Logsエクスポート結果は、小さいログファイルが多く含まれていました。そのため、0日移行のルールを設定しても、想定していたほどGlacier Instant Retrievalへ移りませんでした。
対処としては、ライフサイクルルールに ObjectSizeGreaterThan や ObjectSizeLessThan のフィルタを設定して、小さいオブジェクトも移行対象にできます。
ただし、ここはコストを見て判断する必要があります。Glacier Instant Retrievalには128KBの最小オブジェクトサイズがあります。小さいオブジェクトを大量に移行すると、移行リクエスト料金や最小課金サイズの影響で、期待したほど安くならない可能性があります。
このS3ライフサイクルでS3 GIRに移すという施策の目的がコスト削減であることから、そもそもコスト的にメリットの無い選択をする必要はないということですね。
10. S3に置いたログをAthenaで調査できるようにした
S3へエクスポートできたとしても、それだけでは障害調査に使いやすい状態とは言えません。
ログがS3にあるだけだと、障害発生時に「どのプレフィックスを見るのか」「どの形式で検索するのか」「CloudWatch Logs Insightsで使っていたクエリをどう置き換えるのか」を毎回考えることになります。そこで、S3へ退避したログをAthenaで検索できるようにし、よく使う調査クエリも事前に保存しておきました。
GlueクローラーでS3上のログを自動検出できるのが最善でしたが、CloudWatch Logsからエクスポートしたログは、ロググループやログストリーム、アプリケーション側の出力形式によって見え方が変わります。クローラーだけできれいにテーブル化できることもありますが、今回のログでは期待どおりの列定義になりませんでした。
そのため、Glueクローラーに任せ切るのではなく、Athena側でテーブル定義を用意しました。実際には、S3のprefixに合わせてロググループ名や日付をパーティションにし、スキャン量を絞れるようにしました。
CREATE EXTERNAL TABLE IF NOT EXISTS archived_cloudwatch_logs (
log_line string
)
PARTITIONED BY (
log_group string,
dt string
)
STORED AS TEXTFILE
LOCATION 's3://example-log-archive-bucket/cloudwatch-logs/'
TBLPROPERTIES (
'skip.header.line.count'='0'
);
上は考え方を示すためのサンプルです。実際には、S3のプレフィックス設計、ロググループ名、日付、ログメッセージの形式に合わせてDDLを調整しました。
CloudWatch Logs Insightsで使っていたクエリも、そのままAthenaでは使えません。たとえば、CloudWatch Logs Insightsのクエリ構文では fields、filter、sort、limit、parse のような書き方をします。一方、AthenaではSQLとして書きます。
CloudWatch Logs Insights側では、たとえば次のようなクエリを使っていました。
fields @timestamp, @logStream, @message
| filter @message like /ERROR/
| sort @timestamp desc
| limit 100
リクエストIDで追跡する場合は、ログメッセージから値を抽出するクエリも使っていました。
fields @timestamp, @message
| parse @message /"requestId":"(?<requestId>[^"]+)"/
| parse @message /"level":"(?<level>[^"]+)"/
| parse @message /"errorCode":"(?<errorCode>[^"]+)"/
| filter requestId = "request-id-example"
| sort @timestamp asc
| limit 100
考え方としては、次のように置き換えました。
| CloudWatch Logs Insights | Athena SQL |
|---|---|
fields @timestamp, @message |
SELECT event_time, message |
filter @message like /ERROR/ |
WHERE message LIKE '%ERROR%' |
sort @timestamp desc |
ORDER BY event_time DESC |
limit 100 |
LIMIT 100 |
parse @message ... |
regexp_extract や json_extract_scalar
|
たとえば、障害調査でよく使う「エラーだけを時系列で見る」クエリは、Athena側では次のような形にしました。
SELECT
event_time,
log_group,
log_stream,
message
FROM archived_cloudwatch_logs_view
WHERE message LIKE '%ERROR%'
AND event_time BETWEEN TIMESTAMP '2026-05-01 00:00:00'
AND TIMESTAMP '2026-05-01 23:59:59'
ORDER BY event_time DESC
LIMIT 100;
アプリケーションログがJSON形式なら、 json_extract_scalar でリクエストID、ユーザーID、エラーコードなどを取り出すクエリも用意しました。
SELECT
event_time,
json_extract_scalar(message, '$.requestId') AS request_id,
json_extract_scalar(message, '$.level') AS level,
json_extract_scalar(message, '$.errorCode') AS error_code,
message
FROM archived_cloudwatch_logs_view
WHERE json_extract_scalar(message, '$.requestId') = 'request-id-example'
ORDER BY event_time ASC;
これらのクエリは、Athenaの保存済みクエリとして登録しました。障害時にSQLを一から書くのではなく、保存済みクエリを開いて、期間やリクエストIDだけ差し替えれば調査を始められるようにするためです。
ここまでやって、ようやく「S3にアーカイブしたログを障害調査に使える」と言える状態になりました。
S3への退避は保管の話です。Athenaのテーブル定義、保存済みクエリは、退避したログを実際に使うための準備でした。
Glacier Instant Retrievalへ移行したログをAthenaで検索する場合、障害調査としては使いやすい一方で、クエリのスキャン量や取り出し料金も意識する必要があります。日付やロググループで絞り込めるテーブル設計にしておくと、調査時のコストと待ち時間を抑えやすくなります。
11. 今回の構成でよかった点と微妙だった点
最後に、今回の構成を振り返ります。
| 観点 | よかった点 |
|---|---|
| 実装のしやすさ | LambdaとSQS FIFO中心なので、チーム内で実装イメージを持ちやすかった |
| 処理分割 | ロググループ単位でメッセージ化できた |
| 初回移行 | 手動エクスポートと定期エクスポートで同じ仕組みを使えた |
| 再試行 | SQS FIFOのリトライとDLQを使えた |
| 運用検知 | DLQ + CloudWatch Alarm + SNSで長時間失敗に気づける構成にできた |
| 調査準備 | Athenaのテーブル定義と保存済みクエリを用意し、S3上のログを調査に使いやすくできた |
一方で、微妙だった点もあります。
| 観点 | 微妙だった点 |
|---|---|
| 状態管理 | 直列制御とべき等性を自分たちで実装する必要があったこと。ステートマシンとしてStep Functionsを入れれば、多少楽だったのかもしれません。 |
| Lambda待機 | ExportTask完了待ちをLambdaで扱うと、15分制限を意識する必要がある |
| 設計の素直さ | Step Functionsのほうが状態管理として自然だった可能性がある |
| S3移行 | 小さいログファイルはライフサイクルで期待どおりGlacier Instant Retrievalへ移らなかった |
今から同じ設計をやり直すなら、Step Functionsをもう少し真面目に検討します。 CreateExportTask の完了待ち、状態遷移、リトライ、失敗通知は、状態管理サービスに寄せたほうが読みやすくなります。
今改めて調べてみると、上記のような素晴らしい技術記事がありました、、、。
作業開始前にこの記事を読んでいたら、多分Step Functionsを採用したと思いますが、LambdaとSQSを採用したことによる学びも多かったので、まぁ良しとしましょう。
おわりに
ここまでお読みいただきありがとうございます。
CloudWatch LogsをS3へエクスポートするだけ、ということで最初は一瞬で対応できると思っていました。
しかし、業務の非機能要件として組み込むと、CloudWatch Logsの保持期間設定、過去ログの退避順序、 CreateExportTask の並列実行制限、Lambdaの15分制限、SQS FIFOの直列制御、べき等性、DLQ通知、S3ライフサイクルの128KB未満オブジェクトの扱いまで考える必要がありました。
他の機能開発の設計・構築より楽だったのは間違いないですが、ドキュメントを隅から隅まで読んで、動作を確認する、みたいな地道な作業で意外と時間を食いました。
この記事が、皆さんの非機能要件の特殊要件対応の手助けとなれば、幸いです。
ではまた、お会いしましょう。
参考リンク
CloudWatch Logs
- ログデータを Amazon S3 にエクスポートする - AWS
- AWS CLI を使用してログデータを Amazon S3 にエクスポートする - AWS
- CloudWatch Logs のクォータ - AWS
- ロググループとログストリームの操作 - Amazon CloudWatch Logs
- サブスクリプションによるログデータのリアルタイム処理 - AWS
- アカウント間およびリージョン間のログ一元化 - AWS
LambdaとSQS
- Lambda 関数のタイムアウトを設定する - AWS
- Lambda の耐久性のある関数 - AWS
- Amazon SQS で Lambda を使用する - AWS
- Amazon SQS の少なくとも 1 回の配信 - AWS
- FIFO キュー - AWS
- Amazon SQS のデッドレターキューを使用する - AWS
Amazon S3
- Amazon S3 ライフサイクルを使用したオブジェクトの移行 - AWS
- ライフサイクル設定の要素 - AWS
- 長期データストレージ向け S3 Glacier ストレージクラス - AWS
- Amazon S3 ストレージクラスの使用 - AWS






