AWSのHealth Event通知を集約して利用者に届ける仕組みを作った
この記事は、GENDA Advent Calendar 2025 シリーズ1の10日目の記事です。
また、この記事はJAWS-UG神戸支部の LT会 で発表した内容をコード付きで詳しく解説したものです。
はじめに
AWSの管理者宛に届くこんなメール、ありますよね。

Health Eventの通知メール
そして、複数のAWSアカウントを管理している方なら共感していただけると思うのですが、メールボックスがこうなりますよね。

こういったHealth Eventの通知(以下、Health通知)は長文かつ大量に届くため、全ての内容を漏れなく把握することは難しいです。現実的な落とし所として、「RDSやLambdaのアップデート期限など、主要なもののみ対応する」といった運用になっている組織もあるのではないでしょうか。
真正面から向き合うには物量が多すぎるHealth通知ですが、仕組み化によって漏れなく把握し、必要に応じて利用者に届けられるようになりました。運用を開始してから4か月ほどになりますが、現時点で大きな問題もなくうまく機能しています。
本記事では、どんな仕組みを作り、どのように回しているかをご紹介します!
解決したい課題
まずは現状のHealth通知の課題を整理します。
気付かない/気付いても対応しきれない
Health通知を最終的に届けたい相手は、AWS管理者ではなく、AWS上でシステムを開発しているエンジニア(以下、利用者)です。
Health通知は各AWSアカウントのHealth Dashboardにも表示されるため、利用者が自主的にキャッチアップすることは可能です。とはいえ、日常的にHealth Dashboardを見るかどうかは人によりますし、普段ログインしないAWSアカウントであれば気付きようがありません。
利用者の自主性に任せるのでは見落としやすいため、管理者が全てのHealth通知を把握し、必要な人に必要な情報を届けるのが理想的です。
しかし、前述の通り管理者の手元には大量のメールが届くため、人力で確認していくのは現実的ではありません。
文面が難解で、重要度を判断しづらい
AWSからのお知らせは、変更内容や影響範囲を正しく細やかに記載しているため、1通あたりの文章量が多くなりがちです。そのため、一見して「何が起こるのか」が分からないことも多くあります。
比較的AWSの知見を持つ管理者でも読むのが大変なのですから、この長文をそのまま利用者に横流ししようものなら、「で、何をしたらいいの?」と返ってくるか、最悪の場合は無視されるでしょう。
そうならないように、受け手が読みやすい形に整えてから伝える必要があります。
作ったもの
これらの課題を改善するため、運用フローも含めて仕組み化しました。
最初から最後まで完全に自動化するのではなく、まずは運用が回る最小限の仕組みとして半自動なシステムとしました。自動化しやすいところは自動化しつつ、人間の判断も挟むようなフローにしています。

構成図
前提: 通知メールの集約
各AWSアカウントの管理者メールアドレスを awsadmin+{aws-account-name}@example.com のように 管理者用ML + AWSアカウント名のSuffix という形式に統一しています。
これにより、すべてのAWSアカウントのHealth通知が1つのメールボックスに集約され、かつ宛先アドレスを参照することでAWSアカウントを特定できます。
自動処理1: メール仕分け
構成図の左半分の部分です。
GAS(Google Apps Script)により、Health通知メールを開封してChatGPTに要約させ、その結果をスプレッドシートにまとめています。
ここで重要なポイントは以下2点です。
- 特にアクションを必要としないものや、アナウンスすると逆にノイズになりそうなものは、最初のメール検索の時点で除外する
- ChatGPTの返却形式をJSONで指定し、擬似的なAPIとして使う
プロンプト抜粋
あなたはAWSからの通知メールを指示通りに要約するアシスタントです。
次のメールを日本語で要約してください。
出力は以下のjson形式とします。
summaryではAWS側の変更点や起こり得るイベントのみを述べてください。受信者が対応すべきアクションはsummaryに含めず、actionにて回答してください。
due_dateは、期日がない場合は空のStringにしてください。
priorityは本文を3段階で評価します。特定の条件に当てはまる場合のみ影響を受けるなら「低」、未対応で期限を迎えると稼働中のリソースが必ず影響を受ける場合は「中」、現在問題が発生しているなら「高」の1文字を返してください。
{
"summary": "XXの仕様がXXからXXに変わる",
"action": "期日までにXXする必要がある。期日までに対応しなかった場合にはXXの問題が起きる。",
"due_date": "YYYY/MM/DD",
"priority": "低"
}
コード
実行トリガーはメール受信時としたかったですが、GASにそういったトリガーがないため1時間おきの実行としています。
実装には生成AIの協力を得ています。
// 対象外のメールは検索クエリから除外
const IGNORE_SUBJECTS = [
"[Notification] Upcoming routine retirement of your AWS Elastic Container Service tasks running on AWS Fargate beginning",
"A new Amazon ElastiCache service update is available",
"Maintenance Report Amazon SageMaker Studio applications",
"Your certificate is renewed",
"Amazon OpenSearch Service Software Update Available for domain(s) in AWS account"
];
const IGNORE_QUERY = IGNORE_SUBJECTS.map(item => `-subject:"${item}"`).join(" ");
function exportGmailToSheet() {
// sheetIdはスプレッドシートのURLのこの部分
// https://docs.google.com/spreadsheets/d/{sheetId}/edit?gid=000000000#gid=000000000
const sheetId = "スプレッドシートのID";
const sheet = SpreadsheetApp.openById(sheetId).getSheetByName("シート名");
if (!sheet) {
Logger.log("[ERROR] 指定のシートが見つかりません");
return;
}
// 検索クエリを組み立て、メールを取得
const searchQuery = `replyto:health@aws.com label:unread -${IGNORE_QUERY}`;
const threads = GmailApp.search(searchQuery);
const messages = GmailApp.getMessagesForThreads(threads);
// 1スレッドに複数メッセージが存在する場合があるので、スレッドとメッセージで繰り返し
const data = [];
messages.forEach(thread => {
thread.forEach(message => {
const subject = message.getSubject().trim();
const body = message.getPlainBody().slice(0, 2000);
Logger.log(`[INFO] processing: ${subject}`);
// ChatGPTのAPIを使って要約(後述)
const response = summarizeWithChatGPT(body);
data.push([
message.getDate(), // 受信日時
message.getTo().split(/\+|@/)[1], // アカウント名
subject,
response.summary,
response.action,
response.due_date,
response.priority
]);
message.markRead(); // 未読メールを対象にしているため既読マークをつける
});
});
if (data.length > 0) {
sheet.getRange(sheet.getLastRow() + 1, 1, data.length, data[0].length).setValues(data);
Logger.log(`[INFO] ${data.length} 件のメールをスプレッドシートに追加しました`);
} else {
Logger.log("[INFO] 対象のメールはありません");
}
}
const OPENAI_API_KEY = "APIキー";
const SUBJECT_SIMILARITY_THRESHOLD = 0.6;
function summarizeWithChatGPT(email) {
const systemPrompt = 'あなたはAWSからの通知メールを指示通りに要約するアシスタントです。';
const userInstruction = `
次のメールを日本語で要約してください。
出力は以下のjson形式とします。
summaryではAWS側の変更点や起こり得るイベントのみを述べてください。受信者が対応すべきアクションはsummaryに含めず、actionにて回答してください。
due_dateは、期日がない場合は空のStringにしてください。
priorityは本文を3段階で評価します。特定の条件に当てはまる場合のみ影響を受けるなら「低」、未対応で期限を迎えると稼働中のリソースが必ず影響を受ける場合は「中」、現在問題が発生しているなら「高」の1文字を返してください。
{
"summary": "XXの仕様がXXからXXに変わる",
"action": "期日までにXXする必要がある。期日までに対応しなかった場合にはXXの問題が起きる。",
"due_date": "YYYY/MM/DD",
"priority": "低"
}
`;
const url = 'https://api.openai.com/v1/responses';
const payload = {
model: "gpt-5",
input: [
{ role: "system", content: [{ type: "input_text", text: systemPrompt }] },
{
role: "user",
content: [
{ type: "input_text", text: userInstruction },
{ type: "input_text", text: email }
]
}
]
};
const options = {
method: 'post',
contentType: 'application/json',
headers: {
Authorization: 'Bearer ' + OPENAI_API_KEY
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
try {
const response = UrlFetchApp.fetch(url, options);
const json = JSON.parse(response.getContentText());
const reply = json.output[1].content[0].text.trim();
Logger.log(reply);
// JSON形式をパース
const parsed = JSON.parse(reply);
return {
summary: parsed.summary || "",
action: parsed.action || "",
due_date: parsed.due_date || "",
priority: parsed.priority || ""
};
} catch (error) {
Logger.log("[ERROR] ChatGPT解析エラー: " + error);
return {
summary: "(要約エラー)",
action: "",
due_date: "",
priority: ""
};
}
}
このGASを実行すると、スプレッドシートには次のように出力されます。

スプレッドシートへの出力例
| Time | Account | Title | Summary | Action | Limit | Proproty |
|---|---|---|---|---|---|---|
| 2025/10/13 17:52:42 | hoge-prod | [アクションが必要な場合があります] Amazon S3 は、2025 年 11 月 21 日より Owner.DisplayName パラメータの返却を中止します | Amazon S3は、2025/11/21以降、US-EAST-1、US-WEST-1、US-WEST-2、AP-SOUTHEAST-1、AP-SOUTHEAST-2、AP-NORTHEAST-1、EU-WEST-1、SA-EAST-1の一部S3 APIレスポンスでOwner.DisplayNameパラメータの返却を終了します。期日までは段階的に返却が停止され、期日後は一切返されません。 | 期日までに、Owner.DisplayNameに依存するアプリケーションやスクリプト、パース処理・監視/ログ設定を洗い出し、所有者判定には正規ID(Canonical ID)、AWSアカウントID、またはIAM ARNを使用するよう実装を置き換えてテスト・本番反映する必要がある。期日までに対応しなかった場合には、該当APIのレスポンスからOwner.DisplayNameが消失するため、所有者判定の誤り、パースエラー、処理失敗が発生する可能性がある。 | 2025/11/21 | 低 |
何が起きるのか・いつまでに何をしなければならないのか・放置するとどうなるのか、が一目で把握できるようになりました。
とはいえ精度は完璧ではなく、この要約だけで正しく伝わるかどうかは人力でチェックしています。
とにかく早く運用を開始したかった初期段階では、この出力をもとに手動でJiraチケットを起票し、利用者にアナウンスしていました。
自動処理2: チケット起票
Jiraチケットの起票まで自動化すべく、2つ目のGASを実装しました。
自動処理1が出力したスプレッドシートを見て、同じ件名の通知を1つのJiraチケットに集約して起票します。件名にAWSアカウントやリージョンが含まれる場合もあるため、それらは同じ件名として扱われるようにしています。
コード
実行トリガーはスプレッドシート更新時としたかったですが、GASによる更新は検知できなかったので6時間おきの実行としています。結果的に、同じ内容の通知が異なるAWSアカウントに時間差で複数届くことがあるので、6時間おきの実行でちょうどよかったです。
Jiraチケットのフォーマットが独特なため、かなり長いです。
実装には生成AIの協力を得ています。
const SHEET_ID = "シートID";
const SHEET_NAME = "シート名"
// [NOTE] 個人のアカウントで認証しているため自分が退職すると動かなくなる
const JIRA_URL = "https://example.atlassian.net";
const JIRA_EMAIL = "自分のメールアドレス";
const JIRA_TOKEN = '自分のトークン'
const JIRA_PROJECT_KEY = "Jiraのプロジェクトキー";
const JIRA_PARENT_ISSUE = "Jiraの親チケット番号";
const SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/...";
function readAWSNotifications() {
// init sheet
const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
if (!sheet) {
Logger.log("[ERROR] 指定のシートが見つかりません");
return;
}
// ヘッダー行を除いて、データが入力されている範囲を取得します。
const range = sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn());
const values = range.getValues();
// 通知をグループ化するためのオブジェクトを作成します。
// キーは「件名_期限」とし、値はグループ化された通知のデータとします。
const groupedNotifications = {};
values.forEach((row, index) => {
const isCompleted = row[8]; // I列は配列のインデックス8
// H列がチェックされている場合はスキップします。
if (isCompleted === true) {
return;
}
// 現在のデータの行番号を計算します。ヘッダー行と0-basedインデックスを考慮します。
const rowNumber = index + 2;
// B列: AWSアカウント(インデックス1)
const awsAccount = row[1];
// C列: 件名(インデックス2)
const subject = row[2];
// D列: Summary(インデックス3)
const summary = row[3];
// E列: Action(インデックス4)
const action = row[4];
// F列: 期限(インデックス5)
const limit = row[5];
// G列: 優先度(インデックス6)
const priority = row[6];
// 件名からAWSアカウントIDとリージョン情報を除外する正規表現。
// "[AWS Account" という文字列が現れた時点で、それ以降の文字列を削除する
const cleanSubject = subject.replace(/ \[AWS Account.*/, "").trim();
// グループ化のキーを「クリーンな件名_期限」で作成
const key = `${cleanSubject}_${limit}`;
if (!groupedNotifications[key]) {
groupedNotifications[key] = {
title: cleanSubject,
accounts: [],
summary: summary,
action: action,
limit: limit,
priority: priority,
rowNumbers: []
};
}
groupedNotifications[key].accounts.push(awsAccount);
groupedNotifications[key].rowNumbers.push(rowNumber);
});
const result = Object.values(groupedNotifications);
return result;
}
// url: String
// rows: Int[]
function writeJiraIssue(url, rows) {
// init sheet
const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
if (!sheet) {
Logger.log("[ERROR] 指定のシートが見つかりません");
return;
}
// ループ内のすべての行番号に対して、H列にチケットURLを書き込みます。
rows.forEach(row => {
sheet.getRange(row, 8).setValue(url);
sheet.getRange(row, 9).setValue('TRUE');
});
}
function main() {
const headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Basic " + Utilities.base64Encode(JIRA_EMAIL + ":" + JIRA_TOKEN)
};
const groupedData = readAWSNotifications();
groupedData.forEach(notificationGroup => {
const accountsList = notificationGroup.accounts.map(account => `- ${account}`).join('\n');
const formattedLimit = notificationGroup.limit ? Utilities.formatDate(notificationGroup.limit, 'JST', 'yyyy-MM-dd') : 'なし';
let payload = {
"fields": {
"project": {
"key": JIRA_PROJECT_KEY
},
"summary": notificationGroup.title,
"description": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "※このチケットは管理者用のチケットです。利用者側で対応が必要な場合は各自で別途チケットを作成してください。"
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "\n期限\n",
"marks": [
{
"type": "strong"
}
]
},
{
"type": "text",
"text": formattedLimit
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "\n優先度\n",
"marks": [
{
"type": "strong"
}
]
},
{
"type": "text",
"text": notificationGroup.priority
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "\n対象アカウント\n",
"marks": [
{
"type": "strong"
}
]
},
{
"type": "text",
"text": accountsList
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "\n変更内容\n",
"marks": [
{
"type": "strong"
}
]
},
{
"type": "text",
"text": notificationGroup.summary
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "\n対応すべき内容\n",
"marks": [
{
"type": "strong"
}
]
},
{
"type": "text",
"text": notificationGroup.action
}
]
},
{
"type": "paragraph",
"content": [
{
"text": "\n通知の全文および影響を受けるリソースは、AWS Health Dashboardにてご確認ください。\nhttps://health.console.aws.amazon.com/health/home?region=us-east-1#/event-log",
"type": "text"
}
]
},
]
},
"issuetype": {
"id": "10008"
},
"parent": {
"key": JIRA_PARENT_ISSUE
}
},
"update": {}
};
if (notificationGroup.limit) {
payload.fields.duedate = formattedLimit;
}
const options = {
"method": "post",
"headers": headers,
"payload": JSON.stringify(payload),
"muteHttpExceptions": true // エラー時にスクリプトが停止しないようにする
};
const endpoint = JIRA_URL + "/rest/api/3/issue"; // Jira CloudのREST APIエンドポイント
try {
const response = UrlFetchApp.fetch(endpoint, options);
const result = JSON.parse(response.getContentText());
if (response.getResponseCode() === 201) {
Logger.log("チケット作成に成功しました。キー: " + result.key);
// 起票したIssueをシートに書き込み、対応済みチェックを入れる
const url = JIRA_URL + "/browse/" + result.key
writeJiraIssue(url, notificationGroup.rowNumbers);
// Slack通知
const message = {
"text": `AWS通知のチケットが新しく作成されました。内容を確認し、必要に応じて対象AWSアカウントの担当者に #announce-aws-user で連絡してください。\n${url}`
};
const options = {
"method": "post",
"contentType": "application/json",
"payload": JSON.stringify(message)
};
UrlFetchApp.fetch(SLACK_WEBHOOK_URL, options);
Logger.log("OK");
} else {
Logger.log("チケット作成に失敗しました。");
Logger.log("レスポンスコード: " + response.getResponseCode());
Logger.log("エラーメッセージ: " + JSON.stringify(result.errors || result.errorMessages));
}
} catch (e) {
Logger.log("エラーが発生しました: " + e.message);
}
});
Logger.log("実行完了");
}
Jiraチケットは、そのままコピペして利用者へのアナウンスとできる形式にしています。
以下がチケット文面の例です。
期限
2025-11-21
優先度
低
対象アカウント
- genda-hoge-prod
- genda-hoge-stg
変更内容
Amazon S3は、特定のS3 APIレスポンスで返していたOwner.DisplayNameの返却を段階的に停止し、2025年11月21日以降は一切返却しない。移行期間中もレスポンスから当該フィールドが順次消える可能性がある。対象リージョンはUS-EAST-1、US-WEST-1、US-WEST-2、AP-SOUTHEAST-1、AP-SOUTHEAST-2、AP-NORTHEAST-1、EU-WEST-1、SA-EAST-1。
対応すべき内容
アプリケーションやスクリプトがOwner.DisplayNameに依存していないかを洗い出し、正規ID(Canonical ID)、AWSアカウントID、またはIAM ARNの利用へ置換する。該当S3 APIのレスポンス処理・監視・所有者判定ロジックを更新し、テストのうえデプロイする。期日までに対応しない場合、Owner.DisplayNameに依存する機能(レスポンスのパースや所有者判定など)が失敗し、エラーや誤判定・機能不全が発生する可能性がある。
通知の全文および影響を受けるリソースは、AWS Health Dashboardにてご確認ください。
https://health.console.aws.amazon.com/health/home?region=us-east-1#/event-log
アナウンス文を組み立てるうえで注意したのは以下の2点です。
- 期限、優先度、対象アカウントを端的に列挙する
- Slackでのアナウンスは要約に留め、詳細はHealth Dashboardに誘導する
長文を投げると読まれにくくなるため、アナウンス文の中では詳しさを捨てて読みやすさに全振りしています。逆に、もし詳しく知りたい人がいた場合は、各自でHealth Dashboardを見てもらえるようリンクを添えています。
なお、利用者へのアナウンスまでは自動化しておらず、チケットが起票されたらSlackの管理者チャンネルに通知されます。

管理者への通知
この通知を受けて管理者が対応するのが次のステップです。
人力処理: 利用者へアナウンス
管理者はチケットが起票されたら内容を確認し、利用者にアナウンスします。
このステップでは、「この通知はアナウンスすべきかどうか」「生成AIの要約が適切か」といったチェックを行っています。重要度の低い通知は、意図的にアナウンスしない判断を下すこともあります。

人力処理の流れ。「アナウンスしない」という対応になることもよくある
アナウンスする場合もしない場合も、Jiraチケットをクローズして対応完了とします。
どうしてこんな構成にしたのか
客観的に見ると、ツギハギ感のある構成だなと自分でも思います。しかしこの形に至るまでの経緯があります。
自動処理1・2が分かれている理由
これは、初期に「自動処理1」ができた時点で運用開始していたからです。
本件に着手する前は、Health通知をほとんど確認できていない状態でした。なので、まずは部分的にでも短期間で実装して、最低ラインとしてHealth通知を全件チェックしている状態に持っていきたかったのです。
Speed is Kingの精神ですね。
自動化システムをAWS上に構築しない理由
自動化するにしてもEventBridgeを使ってAWSだけで完結する構成にできるのに、わざわざメールを受信して解読するのはなぜか。これは、GENDAという組織の特殊性からきています。
GENDAには複数のグループ企業があります。各社がAWSを利用するとき、請求を分ける目的でグループ企業ごとにOrganizationを分けています。そのため マルチOrganization × マルチアカウント の多重構造になっています。
それらのAWSアカウント全てに自動化システムを構築するのは大変ですが、メールであれば簡単に1箇所にまとめられるため、メールを見て処理する方法を採用しました。
よかったこと
アナウンスチャンネルに必要な情報だけが流れている
全体を通して「不要なアナウンスを流さない」ことを徹底しています。
自動化システムで起票したチケットをそのまま利用者へメンションすると、要約がイマイチで意味の分からない通知や重要度の低い通知でチャンネルが埋め尽くされかねません。そうなれば、アナウンスチャンネルそのものが敬遠されるようになってしまいます。
そうならないよう、自動部分と人力部分の2ステップでフィルタリングしています。
将来的にこのシステムをさらに作り込むとしても、完全な自動化はせず、投稿の直前で人間が承認ボタンを押すような作りにすると思います。
利用者と会話が成立している
必要な情報だけを流すよう徹底した結果、利用者側もしっかり読んでくれているなと感じます。
「こんな対応が必要です」とアナウンスすれば、「使っているのでアップデートの計画を立てます」「今は余裕がないので◯月に対応予定です」「使ってないリソースなので削除します」といった反応が返ってきています。
導入より運用が大変とよく言われ、仕組みを作っても使われないことも多くある世界で、自分の作った仕組みが運用として成立しているのはとてもうれしいです。
管理者が各AWSアカウントの状況を把握できる
管理者としても、各AWSアカウントの状況を把握できるようになったのはよかったです。
各AWSアカウントにどんなリソースを作成するかは利用者に任せているため、全てのリソースを把握するのは不可能です。もし何かトラブルがあったとしても、利用者からの申告により初めて気付くことになってしまいます。
今回の仕組み化により、全てのHealth通知を管理者のうち誰か1人は必ず確認するようになったため、アカウントごとの要対応な点を先回りして把握できるようになりました。
課題
メンテナンス性の低さ
OpenAIのAPIを実行している箇所は、新しいモデルが出るごとに陳腐化していきます。
また、OpenAI以外にもJiraやSlackといった外部APIの呼び出しや、一度スプレッドシートを介する点、構成要素が多いためメンテナンス難易度は高いです。
GASやJiraが個人のアカウントに紐づいているため、退職時の引き継ぎも厄介そうです。
代替案として、Google Workspaceでのメール受信を軸にするのであれば最近登場したGoogle Workspace Studioが適任です。すでに「自動処理1」のGASはWorkspace Studioで置き換えられることを確認済みのため、順次置き換えを進めていく予定です。

Workspace Studioで自動処理1を実装
管理者メールアドレスの設定忘れがあると拾えない
これはM&Aの多いGENDAならではのトラブルです。
グループインした企業がもともと所持していたAWSアカウントをGENDAの管理下に移管する際、自分たちのメールアドレスに変え忘れるとこのシステムでは拾えなくなります。
実際に、移管前後のバタバタしていた時期にHealth通知が旧管理者メールアドレス宛てに届き、GENDAメンバーが把握できていなかったトラブルがありました。
移管直後はメールの受信を待たず、Health Dashboardを見に行くとよいでしょう。
おわりに
どうしても対応が部分的になりがちなHealth通知を、過去イチ真面目に対応してみました。
よければみなさんのHealth通知ソリューションを教えてください!
Discussion