こんにちは~ 浮田です。
ある日、Lambda で動かしている夜間バッチの失敗を知らせるメールが届きました。
一番知りたい「どのバッチが落ちたのか」は、本文を 18 行ほどスクロールした先の Dimensions にようやく出てきます。全部英語でわかりづらいですよね~
件名もALARM: "alermdemo-batch-errors" in Asia Pacific (Tokyo)
という調子です。
「このバッチが失敗したよ、と一目で分かるメールにしたい」。出発点はそれだけでした。
■ Q1. SNS の通知メッセージって、そもそも変換・整形できるの?
最初に考えたのは「SNS 側に整形テンプレートみたいな設定があるのでは?」でした。
結論: 無理でした。
SNS にはメッセージの変換・整形機能が存在しません。SNS が持っているのはフィルタリング(サブスクリプションフィルターポリシー)とルーティング(ファンアウト)であって、通ってきたメッセージの中身を書き換える手段はありません。
メールの文面を変えたければ選択肢はこの 2 つに絞られます。
「決まった文面に値を埋めるだけでいい」なら、Lambda を書かずに済む Input Transformer が最有力です。
CloudWatch Alarm の状態変化は CloudWatch Alarm State Change イベントとして EventBridge に流れてくるので、ルールで拾ってターゲット(SNS)に渡す際に変換をかけられます。
// InputPathsMap: イベントから値を抽出(最大 100 変数)
{
"alarm": "$.detail.alarmName",
"reason": "$.detail.state.reason",
"at": "$.detail.state.timestamp"
}
// InputTemplate: 抽出した値を埋め込むテンプレート(最大 8,192 文字)
"バッチ処理でエラーが発生しました。
対象アラーム: <alarm>
発生時刻: <at>
理由: <reason>
テンプレートはただの文字列なので、日本語もそのまま書けます。言語の制約はありません。
ただし実際に組んでみて分かった制約が 2 つ。
見た目については、解決します。1 段目の SNS をトリガーに Lambda を起動し、CloudWatch Alarm の JSON をパースして日本語メールに組み立て、2 段目の SNS に publish するだけです。
import json
import boto3
sns = boto3.client("sns")
DEST_TOPIC_ARN = "arn:aws:sns:ap-northeast-1:123456789012:formatted-alerts"
def handler(event, context):
for record in event["Records"]:
msg = json.loads(record["Sns"]["Message"])
dims = {d["name"]: d["value"]
for d in msg.get("Trigger", {}).get("Dimensions", [])}
function_name = dims.get("FunctionName", msg["AlarmName"])
body = (
f"バッチ「{function_name}」が失敗しました。\n"
f"\n"
f"発生時刻: {msg['StateChangeTime']}\n"
f"アラーム: {msg['AlarmName']}\n"
f"理由: {msg['NewStateReason']}\n"
)
sns.publish(
TopicArn=DEST_TOPIC_ARN,
Subject=f"[Batch Failed] {function_name}",
Message=body,
)
これで「どのバッチが落ちたか」が 1 行目に来るメールにはなりました。めでたし……と言いたいところですが、整形コードを書いている最中に、もっと本質的な問題に気づきます。
整形 Lambda で扱える材料は、CloudWatch Alarm が SNS に流してくる JSON がすべてです。
その中でエラーの説明に当たるのは NewStateReason ですが、検証環境で実際に CloudWatch Alarm を発火させて取得した実物はこうです。
Threshold Crossed: 1 datapoint [1.0 (29/07/26 12:19:00)] was greater
than or equal to the threshold (1.0).
「Errors メトリクスが閾値を超えた」という事実だけ。
Lambda がどんな例外を throw したのか、エラーメッセージは何か、スタックトレースは何も入っていません。当然です。
CloudWatch Alarm はメトリクスの監視装置であって、メトリクスは「エラーが 1 回起きた」という数値しか持っていないのですから。
失敗したという事実のみ知りたければそれでいいかもしれません。
ここで最初の目標が 2 つに分裂していたことに気づきました。
メールの「見た目」を整えるのと、メールの「中身」を充実させるのは、まったく別の話だなと
見た目は Q2・Q3 の方法で直る。
しかし「メールを見ただけで原因の見当がつく」ためには、実際のエラーメッセージを通知経路のどこかで拾ってこないといけない。そしてそれは CloudWatch Alarm 経由では原理的に不可能でした。
というわけで調査範囲を広げ、「Lambda バッチの失敗をメールで知る」ための経路を一通り調べました。結果、方式によって載せられる情報に差がありました。
|
|
① Alarm(Errors)+ SNS |
② Amazon EventBridge Rule + Input Transformer |
③ Lambda Destinations(OnFailure) |
④ メトリクスフィルター |
⑤ Logs サブスクリプション |
⑥ try/catch 内で直接 publish |
|
実際のエラーメッセージが載るか |
❌ |
❌ |
✅ |
❌ |
✅(生ログ) |
✅ |
|
対応する呼び出し方式 |
すべて |
すべて |
非同期のみ |
すべて |
すべて |
すべて(コードが到達すれば) |
|
Lambda コードが必要か |
整形するなら要 |
不要 |
不要(整形するなら要) |
整形するなら要 |
要 |
要(元の Lambda 自体に) |
|
ペイロード制約 |
なし |
8KB(テンプレート) |
256KB(超過するとペイロード欠落) |
なし |
なし |
SNS 本体の 256KB |
|
複数の失敗を集計できるか |
✅ |
✅ |
❌(1 回ごと) |
✅ |
❌(イベント単位) |
❌ |
|
クラッシュ系(タイムアウト / OOM)を拾えるか |
✅ |
✅ |
✅ |
✅ |
✅(ログに出れば) |
❌(catch まで届かない) |
いくつか補足します。
③ Lambda Destinations(OnFailure)が情報量では圧勝です。
※Lambdaが非同期で呼ばれた後、「その処理がどうなったか」を自動的に別の場所へ報告する仕組み
失敗した呼び出しの記録(invocation record)がそのまま宛先に流れ、responsePayload に errorMessage / errorType / stackTrace が丸ごと入っています。
以下は、わざと KeyError で落ちる検証用 Lambda を非同期呼び出しして、実際に SNS に届いた invocation record です(一部整形)。
{
"version": "1.0",
"timestamp": "2026-07-29T12:19:22.046Z",
"requestContext": {
"requestId": "8a7f70c7-1111-44a1-88c0-1111128cc4",
"functionArn": "arn:aws:lambda:ap-northeast-1:...:function:alermdemo-batch:$LATEST",
"condition": "RetriesExhausted",
"approximateInvokeCount": 1
},
"requestPayload": {},
"responseContext": { "statusCode": 200, "functionError": "Unhandled" },
"responsePayload": {
"errorMessage": "'expires_at'",
"errorType": "KeyError",
"stackTrace": [
" File \"/var/task/batch_function.py\", line 15, in handler\n cleanup_history(record)\n",
" File \"/var/task/batch_function.py\", line 9, in cleanup_history\n return record[\"expires_at\"]\n"
]
}
}
これが欲しかったやつです。エラーの型・メッセージ・スタックトレースまで丸ごと入っています。
ただし実測で確認できた注意点が 2 つ。
同じ Lambda を同期(RequestResponse)で失敗させた場合、エラーは呼び出し元にそのまま返り、OnFailure 宛先には失敗後 2 分以上監視して何も届きませんでした。 API Gateway の同期呼び出しの裏にいる Lambda には設定しても発火しません。 EventBridge Scheduler や S3 イベントで起動するバッチ系とは相性が良く、同期 API 系とは無縁、とはっきり分かれます。
そのままメール購読すると、件名はデフォルトの「AWS Notification Message」、本文は生の invocation record JSON が 1 行に詰まったメールが届きました。情報は全部あるのに、これはこれで読めない。件名と本文を人間向けにしたければ、結局整形 Lambda を挟むことになります。
④ メトリクスフィルターは、アプリログの特定パターン(ERROR など)をカスタムメトリクス化して、①と同じアラーム経路に乗せる方式です。監視対象を柔軟に定義できるのが利点ですが、通知に乗るのが「閾値を超えた」という事実だけなのは①と同じで、「中身がない」問題は解決しません。
⑤ Logs サブスクリプションは生ログが取れる分、何でもできますが、ログイベント単位で流れてくるためアラームのような時間窓での集計はできず、フィルターパターンの管理と整形 Lambda の実装がフルに必要です。手間対効果で見ると今回の用途にはオーバーでした。
⑥ try/catch 内で自前 publish は一見手軽ですが、タイムアウトと OOM では catch 節まで到達しないという穴があります。バッチが「静かに死ぬ」ケースこそ知りたいのに、そこを拾えないので単独採用はなし。保険として ① と組み合わせる前提の方式です。
最後に残ったのが件名です。
整形 Lambda からは Subject= を渡すだけなので変更自体は簡単ですが、「SNS の Subject は ASCII 限定で日本語不可」という情報がブログ等でよく見つかりました。
ですが、実際、SDK 経由だと日本語件名がエラーにならず送れてしまうため、「たまたま通っているだけで保証外」という解説が定番でした。
ところが今回、公式の API リファレンスを確認しに行くと、現行の記述はこうなっていました。(2026/07/31時点)
Constraints: Subjects must be UTF-8 text with no line breaks or control characters, and less than 100 characters long.
— Amazon SNS API Reference: Publish
「ASCII text」ではなく「UTF-8 text」。 「ASCII 限定」は都市伝説だったのかというと、そうではありません。更新が凍結された旧世代の公式ドキュメント(AWS SDK for Ruby v2 の PublishInput)には、旧記述が今も残っています。
Constraints: Subjects must be ASCII text that begins with a letter, number, or punctuation mark; must not include line breaks or control characters; and must be less than 100 characters long.
つまり、かつて確かに公式の制約だった「ASCII 限定」が、いつからかドキュメント上 UTF-8 に緩和されていた、ということです(旧制約にあった「先頭は英数字か句読点」という条件も現行版からは消えています)。「ASCII 前提の非対称仕様」という原稿を書きかけていた身としては拍子抜けでしたが、二次情報の賞味期限を実感した瞬間でもあります。
実際に送って境界値まで確かめました(ap-northeast-1、2026-07-29 実測)。
|
テスト内容 |
結果 |
|
件名「【バッチ失敗】日本語件名の実測テスト(絵文字も🚀)」 |
✅ 成功。絵文字含め無傷で配信 |
|
ASCII 100 文字 |
✅ 成功 |
|
ASCII 101 文字 |
❌ InvalidParameter: Invalid parameter: Subject |
|
日本語 100 文字(UTF-8 で 300 バイト) |
✅ 成功 |
|
改行を含む件名 |
❌ InvalidParameter(仕様どおり) |
|
絵文字 🚀 × 50(UTF-16 で 100 単位) |
✅ 成功 |
|
絵文字 🚀 × 51(UTF-16 で 102 単位) |
❌ InvalidParameter |
|
絵文字 🚀 × 100(コードポイント数なら 100 文字) |
❌ InvalidParameter |
この結果からわかること。
つまり件名の長さ制限は UTF-16 コードユニット数で 100 以下です。
日本語は 1 文字 = 1 ユニットなので実質 100 文字フルに使えますが、サロゲートペアになる絵文字は 1 文字 = 2 ユニットで数えられます(Java の String.length() と同じ数え方)。
ドキュメントの「characters」の一語からここまでは読み取れないので、実測して初めて分かる仕様でした。
もっとも、件名に絵文字を 50 個も並べる運用は想定しなくていいはずなので、実用上は「日本語で 100 文字まで、絵文字は 2 文字分」と覚えておけば十分です。
このテスト、受信箱を見ると面白いことになっています。
上限超えと改行入りは Publish API の時点で拒否されるためメール自体が存在せず、成功したテストのメールだけが並ぶ。
届いたメールではなく「欠番」のほうが制約の証拠になっています。
なお本文(Message)側は従来どおり「UTF-8 で最大 256KB」です。
仕上げに、整形 Lambda が publish した日本語件名メールを実際に受信して確認しました。Outlook 上で件名「【バッチ失敗】alermdemo-batch: KeyError」・日本語本文とも文字化けなしに表示されています。
今回のバッチは EventBridge Scheduler起動、つまり非同期呼び出しだったので、最終的にこの二段構えにしました。
[中身担当] Lambda(バッチ) --OnFailure--> SNS --> 整形Lambda --> SNS --> メール
(errorMessage / stackTrace 入り)
[保険担当] CloudWatch Alarm(Errors) --> SNS --> メール
(タイムアウト・OOM・Destinations経路自体の故障をカバー)
この構成は検証環境で end-to-end で動作確認済みです。非同期でバッチを落とすと、Destinations 経路から「【バッチ失敗】alermdemo-batch: KeyError」(スタックトレース付き)、CloudWatch Alarm 経路から「【バッチ失敗】alermdemo-batch(アラーム検知)」の 2 通が届き、意図どおり「中身担当」と「保険担当」が両方仕事をしました。
実際に届いたメールを並べるとこうなりました。同じ 1 回のバッチ失敗から、素のアラームメール(Before)と、スタックトレース付きの日本語メール(After)。どちらを深夜のスマホで見たいかは言うまでもありません。
「メールを読みやすくしたい」という小さな動機から始まったのに、蓋を開けてみれば SNS は「変換できない」、CloudWatch Alarm は「中身を持っていない」 という 2 つの「無いもの」を確認して回る旅でした。
無いものが分かれば、あとは組み合わせの問題です。
今回は Destinations で中身を、CloudWatch Alarm で保険を、という分担に落ち着きました。
さて、メールに errorMessage とスタックトレースが載るようになると、次に欲しくなるのは「で、これは何が悪いの?」です。
次回は、この通知経路の整形 Lambda に Claude API を挟んで、エラー内容の一次診断コメントを自動で添える構成なども面白そうですね~。