【AWS】コラム

CloudWatch AlarmのSNS通知メールをカスタマイズしたい

作成者: ゼネックコミュニケーション|Aug 20, 2026, 7:59:27 AM

 こんにちは~ 浮田です。  

■  TL;DR

  • SNS 自体にはメッセージの変換・整形機能が存在しない。フィルタリングとルーティングだけ。整形したければ Lambda を挟んで別トピックに再 publish するか、EventBridge の Input Transformer を使うしかない。
  • 整形して見た目を直しても、そもそも Alarm の通知には「実際のエラーの中身」が入っていないNewStateReason は「閾値を超えた」という事実だけで、例外メッセージもスタックトレースも乗らない。
  • エラーの中身まで通知に載せたいなら方式選びが本題になる。情報量では Lambda Destinations(OnFailure) が圧勝。ただし非同期呼び出し限定。
  • 件名(Subject)については「ASCII 前提」という情報が広く出回っているが、現行の公式ドキュメントでは「UTF-8 テキスト(改行・制御文字なし、100 文字未満)」に更新されている。実測でも日本語・絵文字入り件名がそのまま届いた。さらに境界値テストの結果、上限の正体は文字数でもバイト数でもなく UTF-16 コードユニット数で 100 以下(日本語は 1 文字 = 1、絵文字は 1 文字 = 2)だった。調べ物は一次情報を見よう、そして最後は実測しよう。

 

  はじめに:このメール、どのバッチが落ちたか分かりますか 

 ある日、Lambda で動かしている夜間バッチの失敗を知らせるメールが届きました。  

一番知りたい「どのバッチが落ちたのか」は、本文を 18 行ほどスクロールした先の Dimensions にようやく出てきます。全部英語でわかりづらいですよね~

件名もALARM: "alermdemo-batch-errors" in Asia Pacific (Tokyo)

という調子です。

「このバッチが失敗したよ、と一目で分かるメールにしたい」。出発点はそれだけでした。

 

■  Q1. SNS の通知メッセージって、そもそも変換・整形できるの?  

最初に考えたのは「SNS 側に整形テンプレートみたいな設定があるのでは?」でした。

結論: 無理でした。

SNS にはメッセージの変換・整形機能が存在しません。SNS が持っているのはフィルタリング(サブスクリプションフィルターポリシー)とルーティング(ファンアウト)であって、通ってきたメッセージの中身を書き換える手段はありません。

メールの文面を変えたければ選択肢はこの 2 つに絞られます。

  1. Lambda を挟む: Alarm → SNS(1 段目) → Lambda で整形 → 別の SNS(2 段目) → メール、という二段 SNS 構成
  2. EventBridge の Input Transformer を使う: Alarm の状態変化イベントを EventBridge で拾い、テンプレートに値を埋め込んで SNS へ

 

■  Q2. 固定テンプレートで済むなら、  

「決まった文面に値を埋めるだけでいい」なら、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 つ。

  • 条件分岐は一切できない。「ALARM のときはこの文面、OK に戻ったときはこの文面」のような出し分けはテンプレート内では不可能です(ルールを 2 本に分けてイベントパターン側で振り分けることになる)。
  • 件名(Subject)は変えられない。 EventBridge から SNS ターゲットに渡せるのはメッセージ本文だけで、メールの件名は制御できません。本文だけ直せれば十分、という場合に限り Lambda レスな最短ルートです。
  • 改行の扱いが特殊で、しかも引用符が本文に残る。 これが一番の想定外でした。素朴に複数行のテンプレートを書くと ValidationException で登録できず、\n エスケープに変えると今度は改行にならず 文字としての \n がそのまま配信されます。
    ュメトの作法は「各行をそれぞれ二重引用符で囲む」(上のテンプレート例の書式)なのですが、この書式で配信された本文はこんな感じです。

 

 Q3. じゃあ Lambda で整形すれば全部解決?    

見た目については、解決します。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 行目に来るメールにはなりました。めでたし……と言いたいところですが、整形コードを書いている最中に、もっと本質的な問題に気づきます。

 

  気づいた本質的な問題:Alarm には「中身」がない  

整形 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 経由では原理的に不可能でした。

 

  Q4. エラーの中身まで通知に載せたいなら:Lambda 失敗通知 6 方式の比較  

というわけで調査範囲を広げ、「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)がそのまま宛先に流れ、responsePayloaderrorMessage / 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 つ。

  • Destinations は非同期呼び出し限定。

同じ Lambda を同期(RequestResponse)で失敗させた場合、エラーは呼び出し元にそのまま返り、OnFailure 宛先には失敗後 2 分以上監視して何も届きませんでした。 API Gateway の同期呼び出しの裏にいる Lambda には設定しても発火しません。 EventBridge Scheduler や S3 イベントで起動するバッチ系とは相性が良く、同期 API 系とは無縁、とはっきり分かれます。

  • Destinations が publish するメッセージには Subject が付かない。

そのままメール購読すると、件名はデフォルトの「AWS Notification Message」、本文は生の invocation record JSON が 1 行に詰まったメールが届きました。情報は全部あるのに、これはこれで読めない。件名と本文を人間向けにしたければ、結局整形 Lambda を挟むことになります。

④ メトリクスフィルターは、アプリログの特定パターン(ERROR など)をカスタムメトリクス化して、①と同じアラーム経路に乗せる方式です。監視対象を柔軟に定義できるのが利点ですが、通知に乗るのが「閾値を超えた」という事実だけなのは①と同じで、「中身がない」問題は解決しません。

⑤ Logs サブスクリプションは生ログが取れる分、何でもできますが、ログイベント単位で流れてくるためアラームのような時間窓での集計はできず、フィルターパターンの管理と整形 Lambda の実装がフルに必要です。手間対効果で見ると今回の用途にはオーバーでした。

⑥ try/catch 内で自前 publish は一見手軽ですが、タイムアウトと OOM では catch 節まで到達しないという穴があります。バッチが「静かに死ぬ」ケースこそ知りたいのに、そこを拾えないので単独採用はなし。保険として ① と組み合わせる前提の方式です。

 

 Q5. SNS の件名(Subject)は日本語にできるか  

最後に残ったのが件名です。

整形 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

この結果からわかること。

  • ASCII 100 ✅ / 101 ❌ → 実際の上限は「100 文字未満」ではなく 100 以下(ドキュメントと 1 文字ずれている?)
  • 日本語 100 文字(300 バイト)✅ → バイト数ではない
  • 絵文字 100 個(コードポイント数なら 100)❌、50 個 ✅ / 51 個 ❌ → コードポイント数でもない

つまり件名の長さ制限は 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経路自体の故障をカバー)

  • 主経路は Lambda Destinations(OnFailure)。失敗のたびに errorMessage とスタックトレース付きの日本語メールが届く。整形 Lambda は Q3 のものを invocation record 用に書き換えて流用。
  • CloudWatch Alarm は撤去せず保険として残す。Destinations は便利ですが、通知経路そのものが Lambda + SNS の組み合わせである以上、経路自体の故障や設定ミスもあり得ます。「素朴だが確実に鳴る CloudWatch Alarm」を残しておくことで、通知の通知漏れを防ぎます。

この構成は検証環境で end-to-end で動作確認済みです。非同期でバッチを落とすと、Destinations 経路から「【バッチ失敗】alermdemo-batch: KeyError」(スタックトレース付き)、CloudWatch Alarm 経路から「【バッチ失敗】alermdemo-batch(アラーム検知)」の 2 通が届き、意図どおり「中身担当」と「保険担当」が両方仕事をしました。

実際に届いたメールを並べるとこうなりました。同じ 1 回のバッチ失敗から、素のアラームメール(Before)と、スタックトレース付きの日本語メール(After)。どちらを深夜のスマホで見たいかは言うまでもありません。

 

 ハマったポイントまとめ  

  1. SNS に変換機能はない。 探すだけ無駄でした。整形は Lambda か EventBridge Input Transformer の仕事。
  2. Input Transformer は「埋め込み」専用。 条件分岐はできず、件名も変えられない。テンプレート上限は 8,192 文字。
  3. CloudWatch Alarm の通知には例外の中身が乗らない。 NewStateReason は閾値超えの事実だけ。「見た目」と「中身」は別問題。
  4. Lambda Destinations は非同期限定。 同期呼び出しには設定しても発火しない。SNS 宛の invocation record は 256KB 上限で、リクエスト+レスポンスのペイロード合計が超過すると、ペイロード部分が欠落した通知が届く。
  5. try/catch 自前通知はクラッシュ系を拾えない。 タイムアウト・OOM は catch に届かない。
  6. Subject の「ASCII 限定」は過去の情報。 現行ドキュメントは UTF-8(旧記述は凍結された SDK for Ruby v2 ドキュメントに現存)。実測では上限は UTF-16 コードユニット数で 100 以下 — 日本語はそのまま 100 文字、絵文字は 1 個 = 2 カウント。日本語も絵文字も無傷で届いた。一次情報を確認し、最後は実測してから書く・組む。
  7. Destinations の通知は Subject(件名) が空。 メール件名はデフォルトの「AWS Notification Message」になるので、件名で判別したければ整形 Lambda が必要。
  8. Windows の AWS CLI は入出力ともエンコーディングの罠がある。 file:// の読み込みには AWS_CLI_FILE_ENCODING=UTF-8、パイプ出力は cp932。

 

 まとめ

「メールを読みやすくしたい」という小さな動機から始まったのに、蓋を開けてみれば SNS は「変換できない」、CloudWatch Alarm は「中身を持っていない」 という 2 つの「無いもの」を確認して回る旅でした。

無いものが分かれば、あとは組み合わせの問題です。

今回は Destinations で中身を、CloudWatch Alarm で保険を、という分担に落ち着きました。

さて、メールに errorMessage とスタックトレースが載るようになると、次に欲しくなるのは「で、これは何が悪いの?」です。

次回は、この通知経路の整形 Lambda に Claude API を挟んで、エラー内容の一次診断コメントを自動で添える構成なども面白そうですね~。