この記事でわかること
- 「なぜそのしきい値?」に答えられる、SLOから逆算するしきい値設計の手順
- 瞬間スパイクと持続的異常を見分ける
M out of N評価パターン - ALBの5xx率のような「分母が小さいと壊れる指標」を Metric Math で安全に扱う方法
- Missing Dataの4種類の挙動を使い分ける早見表
- EventBridge + Lambda で Slack(Block Kit)へ通知する構成と、よくあるハマりポイント
- 上記をまるごと再現する Terraform コード
この記事を書いた経緯
きっかけは、ある運用案件を引き継いだ初日の出来事でした。
Slackの #alerts チャンネルを開くと、未読が 3桁 溜まっていました。1日あたりおよそ80件。深夜帯にも休日にも容赦なく鳴り続けるのに、チームメンバーは全員ミュートしていて、誰も中身を見ていない。「アラート? ああ、あれ全部無視で大丈夫っす」と引き継ぎで言われたとき、正直ぞっとしました。監視が存在しないのと同じ状態だったからです。
中身を1件1件追っていくと、ほぼ全てが次の3パターンに収まりました。
- 根拠のないしきい値(「とりあえずCPU 70%」「とりあえずエラー数 10件」)
- 評価期間が短すぎる設定(period=60秒、1回でも超えたら即発火)
- Missing Data の挙動を考えていない(インスタンス停止のたびに発火)
これは設計の問題で、AWSの機能の問題ではない。そう確信したので、しきい値の決め方そのものを SLO から逆算するロジック に置き換え、評価ロジックを M out of N と Metric Math で誤検知に強くしていきました。結果として アラート数は1日80件から週20件以下 まで減り、「鳴ったら本当に何かが起きている」状態を取り戻せました。[※実数値はご自身の環境に合わせて要調整]
同じ状態で苦しんでいるチームは多いはずです。この記事は、当時の自分が引き継いだ初日に手元にあったら一番助かったであろう内容を、設計の考え方からコードまで一気通貫でまとめたものです。
実務での背景
CloudWatchアラームは設計を間違えると「アラーム疲れ」が起きます。しきい値が雑だと毎日アラートが鳴り、誰も気にしなくなり、本当に重要なアラートが埋もれます。
アラームの理想は「本当に対応が必要なときだけ鳴る」。そのために必要なのは派手なテクニックではなく、しきい値を根拠ある数字にすることと、評価ロジックを誤検知に強くすることの2点です。
解決方法:しきい値はSLOから逆算する
「CPU 80%でアラート」のようなマジックナンバーをやめ、しきい値は SLO(Service Level Objective)から逆算 します。
しきい値設計の3ステップ
- SLOを決める: 例「APIの p95レイテンシは 月間99%の時間で 500ms 以下」
- 誤差バジェットを計算する: 月間 99% を切ったら違反 = 月間 1% (約7.2時間) まで悪化を許容できる
- しきい値を逆算する: 「バジェットを1時間あたりにペース換算して、それを超える時間が連続したらアラート」とする
数字の決め方の基本ルール
| 観点 | やってはいけない | 推奨 |
|---|---|---|
| しきい値の根拠 | 「なんとなく80%」 | 過去30日のp95ベースラインの1.5〜2倍 |
| 評価期間 | period=60秒, 1回で発火 | 5分粒度で M out of N (例: 5回中3回) |
| 統計関数 | 常に Average | リソース系は Average、エラー率は Sum + RequestCount を併用 |
| 異常検知が難しい指標 | 固定しきい値で粘る | CloudWatch Anomaly Detection を検討 |
ベースライン取得は aws cloudwatch get-metric-statistics を30日ぶん回せば取れます。「数字に根拠が無いなら、まず計測する」 が原則です。
具体的な手順
Step 1: M out of N でスパイクを許容しつつ持続異常を捕まえる
evaluation-periods と datapoints-to-alarm を別々に設定するのがポイント。「5分粒度で直近5回のうち3回しきい値超過したらアラート」とすると、瞬間スパイクを許容しつつ持続的な異常はきっちり検知できます。
aws cloudwatch put-metric-alarm \
--alarm-name "prod-app-api-p95-latency-high" \
--alarm-description "API p95レイテンシが 直近25分のうち15分間 500msを超過" \
--namespace MyApp \
--metric-name ApiLatency \
--extended-statistic p95 \
--period 300 \
--evaluation-periods 5 \
--datapoints-to-alarm 3 \
--threshold 500 \
--comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:ap-northeast-1:123456789012:alert-warning \
--ok-actions arn:aws:sns:ap-northeast-1:123456789012:alert-warning
| 設定値 | 役割 |
|---|---|
--period 300 |
5分粒度で集計 |
--evaluation-periods 5 |
直近5回ぶん(=25分)の評価窓 |
--datapoints-to-alarm 3 |
5回のうち3回しきい値超過でALARM |
--extended-statistic p95 |
平均ではなく p95 を見る |
Step 2: ALBの5xx率は Metric Math で「分母が小さいときは無視」する
ALB の HTTPCode_Target_5XX_Count を RequestCount で割って「5xx率」を出すアラームはよくありますが、深夜のリクエスト数が1〜2件の時に1件5xxが出ると瞬時に50%超えになり誤検知の温床になります。
Metric Math の IF で 分母が一定以上のときだけ評価 することで解決します。
aws cloudwatch put-metric-alarm \
--alarm-name "prod-alb-5xx-rate-high" \
--alarm-description "ALB 5xx率が3%超 かつ 1分あたりのリクエスト数が10以上" \
--evaluation-periods 5 \
--datapoints-to-alarm 3 \
--threshold 3 \
--comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--metrics '[
{
"Id": "e1",
"Expression": "IF(req > 10, 100*(err/req), 0)",
"Label": "5xx率(%) (リクエスト10件以上のときのみ評価)",
"ReturnData": true
},
{
"Id": "err",
"MetricStat": {
"Metric": {
"Namespace": "AWS/ApplicationELB",
"MetricName": "HTTPCode_Target_5XX_Count",
"Dimensions": [{"Name": "LoadBalancer", "Value": "app/prod-alb/abc123"}]
},
"Period": 60,
"Stat": "Sum"
},
"ReturnData": false
},
{
"Id": "req",
"MetricStat": {
"Metric": {
"Namespace": "AWS/ApplicationELB",
"MetricName": "RequestCount",
"Dimensions": [{"Name": "LoadBalancer", "Value": "app/prod-alb/abc123"}]
},
"Period": 60,
"Stat": "Sum"
},
"ReturnData": false
}
]'
IF(req > 10, 100*(err/req), 0) がすべてです。「1分あたり10リクエスト未満なら0扱い」とすることで、低トラフィック時間帯の誤検知を物理的にゼロにします。
Step 3: Composite Alarm で「複数条件が同時に満たされたとき」だけ昇格させる
「CPU高だけ」「エラーレート高だけ」は Warning。「CPU高 AND エラーレート高」になった瞬間に Critical (PagerDuty) に昇格させる、という二段構えにします。
aws cloudwatch put-composite-alarm \
--alarm-name "prod-critical-cpu-and-errors" \
--alarm-description "CPU高 AND エラーレート高 = ユーザー影響あり、即対応" \
--alarm-rule "ALARM('prod-cpu-high') AND ALARM('prod-alb-5xx-rate-high')" \
--actions-suppressor "prod-deployment-in-progress" \
--actions-suppressor-wait-period 300 \
--actions-suppressor-extension-period 60 \
--alarm-actions arn:aws:sns:ap-northeast-1:123456789012:alert-critical
--actions-suppressor を併用すると デプロイ中はアラーム通知を抑制 できます。これは2022年以降の機能ですが意外と知られていません。デプロイ起因の誤検知ノイズを大きく減らせます。
Step 4: EventBridge → Lambda で Slack に Block Kit 通知する
CloudWatchアラームの通知経路にはいくつか選択肢があります。
| 経路 | 向いている用途 |
|---|---|
| アラーム → SNS → AWS Chatbot → Slack | 一番ラク。整形不要、IaCも短い。フォーマットを自由にカスタムしたくないなら第一候補 |
| アラーム → EventBridge → Lambda → Slack | 通知本文を自社ルールで整形したい・複数宛先に振り分けたい場合に強い |
| アラーム → SNS → Lambda → Slack | レガシー。EventBridgeに揃えるほうが拡張時に楽 |
ここではカスタム整形が必要なケースを想定し、EventBridge → Lambda で組みます。CloudWatchアラームの状態変化は aws.cloudwatch ソースの CloudWatch Alarm State Change イベントとして EventBridge に流れます。
EventBridge ルール
aws events put-rule \
--name "cw-alarm-state-change-to-slack" \
--event-pattern '{
"source": ["aws.cloudwatch"],
"detail-type": ["CloudWatch Alarm State Change"],
"detail": {
"state": { "value": ["ALARM", "OK"] }
}
}'
aws events put-targets \
--rule "cw-alarm-state-change-to-slack" \
--targets "Id=1,Arn=arn:aws:lambda:ap-northeast-1:123456789012:function:notify-slack"
Slack通知Lambda(Block Kit + リトライ + Secrets Manager)
Webhook URL は環境変数ベタ書きではなく Secrets Manager から取得します。urllib 直叩きではなく、HTTPエラーとタイムアウトをきちんとハンドリングし、5xx系のレスポンスは指数バックオフでリトライします。
import json
import os
import time
import urllib.request
import urllib.error
import boto3
SECRET_ID = os.environ["SLACK_WEBHOOK_SECRET_ID"]
TIMEOUT_SEC = 5
MAX_RETRIES = 3
_secrets = boto3.client("secretsmanager")
_cached_webhook_url = None
def _get_webhook_url() -> str:
global _cached_webhook_url
if _cached_webhook_url is None:
resp = _secrets.get_secret_value(SecretId=SECRET_ID)
_cached_webhook_url = json.loads(resp["SecretString"])["url"]
return _cached_webhook_url
def _build_blocks(detail: dict) -> list:
alarm_name = detail["alarmName"]
new_state = detail["state"]["value"]
reason = detail["state"]["reason"]
region = detail.get("region", "ap-northeast-1")
console_url = (
f"https://{region}.console.aws.amazon.com/cloudwatch/home"
f"?region={region}#alarmsV2:alarm/{urllib.parse.quote(alarm_name)}"
)
header = ":rotating_light: ALARM" if new_state == "ALARM" else ":white_check_mark: OK"
return [
{"type": "header", "text": {"type": "plain_text", "text": f"{header} — {alarm_name}"}},
{"type": "section", "fields": [
{"type": "mrkdwn", "text": f"*State*\n{new_state}"},
{"type": "mrkdwn", "text": f"*Region*\n{region}"},
]},
{"type": "section", "text": {"type": "mrkdwn", "text": f"*Reason*\n```{reason}```"}},
{"type": "actions", "elements": [
{"type": "button", "text": {"type": "plain_text", "text": "Open in Console"}, "url": console_url}
]},
]
def _post_with_retry(url: str, payload: dict) -> None:
data = json.dumps(payload).encode("utf-8")
for attempt in range(1, MAX_RETRIES + 1):
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
if 200 <= resp.status < 300:
return
if resp.status < 500:
raise RuntimeError(f"Slack rejected payload: {resp.status}")
except (urllib.error.URLError, TimeoutError) as e:
if attempt == MAX_RETRIES:
raise
time.sleep(2 ** (attempt - 1))
def lambda_handler(event, context):
detail = event["detail"]
payload = {"blocks": _build_blocks(detail)}
_post_with_retry(_get_webhook_url(), payload)
return {"ok": True}
ポイントは3つ。
- Webhook URL を Secrets Manager から。環境変数ベタ書きは漏洩リスクが大きい。
-
Block Kit に統一。
attachments形式は Slack 側でレガシー扱い。 - 5xx系はリトライ、4xx系は即失敗。リトライ無限ループとリトライしないでデータロスの両方を防ぐ。
構成図
Composite Alarm を経由するルートと、個別 Metric Alarm が直接 EventBridge に流れるルートの両方が有効です。EventBridge ルールの event-pattern で振り分ければ、宛先(Slack / PagerDuty / Jira起票 など)を後から増減しやすくなります。
Missing Data の4種類の使い分け
--treat-missing-data の挙動は4種類あります。「とりあえず notBreaching」は思考停止アドバイスで、ワークロード次第で正解は変わります。
| 設定 | 挙動 | 推奨ケース |
|---|---|---|
notBreaching |
欠損は正常扱い | 静的なサーバー群のCPU/メモリ。インスタンス停止時に誤検知させたくない |
breaching |
欠損は異常扱い | 常時データが届くべき指標(外形監視・ヘルスチェック)。沈黙=障害 |
ignore |
欠損時は前回の状態を維持 | Auto Scaling環境のリソース指標。スケールイン時の自然な欠損で状態を変えたくない |
missing (デフォルト) |
評価不能扱い(しきい値判定スキップ) | メトリクス発生が不規則な指標(バッチ・低頻度API)。発火も解消もしない |
「沈黙したら問題か、沈黙が正常運転か」で選ぶ、と覚えると間違えにくいです。
Terraform で全部まとめてデプロイする
ここまでの構成をまとめて再現できる Terraform を載せます。組織標準の Provider バージョンに合わせて使ってください。
# main.tf
resource "aws_cloudwatch_metric_alarm" "api_p95_latency" {
alarm_name = "prod-app-api-p95-latency-high"
alarm_description = "API p95レイテンシが 直近25分のうち15分間 500msを超過"
namespace = "MyApp"
metric_name = "ApiLatency"
extended_statistic = "p95"
period = 300
evaluation_periods = 5
datapoints_to_alarm = 3
threshold = 500
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [aws_sns_topic.warning.arn]
ok_actions = [aws_sns_topic.warning.arn]
}
resource "aws_cloudwatch_metric_alarm" "alb_5xx_rate_safe" {
alarm_name = "prod-alb-5xx-rate-high"
alarm_description = "ALB 5xx率が3%超 かつ 1分あたりのリクエスト数が10以上"
evaluation_periods = 5
datapoints_to_alarm = 3
threshold = 3
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
metric_query {
id = "e1"
expression = "IF(req > 10, 100*(err/req), 0)"
label = "5xx率(%) (リクエスト10件以上のときのみ評価)"
return_data = true
}
metric_query {
id = "err"
metric {
namespace = "AWS/ApplicationELB"
metric_name = "HTTPCode_Target_5XX_Count"
dimensions = { LoadBalancer = "app/prod-alb/abc123" }
period = 60
stat = "Sum"
}
}
metric_query {
id = "req"
metric {
namespace = "AWS/ApplicationELB"
metric_name = "RequestCount"
dimensions = { LoadBalancer = "app/prod-alb/abc123" }
period = 60
stat = "Sum"
}
}
}
resource "aws_cloudwatch_composite_alarm" "critical_cpu_and_errors" {
alarm_name = "prod-critical-cpu-and-errors"
alarm_description = "CPU高 AND エラーレート高 = ユーザー影響あり、即対応"
alarm_rule = "ALARM('prod-cpu-high') AND ALARM('${aws_cloudwatch_metric_alarm.alb_5xx_rate_safe.alarm_name}')"
alarm_actions = [aws_sns_topic.critical.arn]
}
resource "aws_cloudwatch_event_rule" "alarm_state_change" {
name = "cw-alarm-state-change-to-slack"
event_pattern = jsonencode({
source = ["aws.cloudwatch"]
"detail-type" = ["CloudWatch Alarm State Change"]
detail = {
state = { value = ["ALARM", "OK"] }
}
})
}
resource "aws_cloudwatch_event_target" "to_slack_lambda" {
rule = aws_cloudwatch_event_rule.alarm_state_change.name
arn = aws_lambda_function.notify_slack.arn
}
SNS Topic / Lambda 関数 / IAM ロール / Secrets Manager のリソース定義はプロジェクト共通モジュールに寄せておくのが現実的です。
ハマりポイント
❌ 「この記事でわかること」と実装が一致していない、を自分でやらない
冗談ではなく、自分で見直す前にレビューに出して指摘されがちなのがこれです。冒頭の「わかること」に書いた要素はすべて本文で実装される必要があります。書けない要素は冒頭から削るのが先。
❌ Composite Alarm の alarm-rule のクオート
alarm-rule 中の対象アラーム名は シングルクオートで囲む 必要があります。ALARM(prod-cpu-high) ではなく ALARM('prod-cpu-high')。CLI から渡すときは外側をダブルクオートにする、Terraform は文字列中にシングルクオートを書く、で吸収します。
❌ Metric Math の period 一致
metric_query で参照する各メトリクスの period は揃えるのが原則です。揃わないと Math 側で正しく集計できず、結果が無音 or 不正確になります。
❌ EventBridge から Lambda を呼ぶ権限を忘れる
EventBridge → Lambda の場合、Lambda側に AWS::Lambda::Permission (lambda:InvokeFunction を events.amazonaws.com に許可) が必要です。CDK や Terraform のモジュール経由なら自動付与されますが、CLI で組むときは抜けがち。
❌ Anomaly Detection を使うべき指標で固定しきい値に粘る
夜間と日中で正常値が数倍違うような指標(リクエスト数・ジョブ実行数など)に固定しきい値を当てると永遠にチューニングし続けることになります。CloudWatch Anomaly Detection の ANOMALY_DETECTION_BAND を使うと、過去パターンから動的に「正常範囲」を計算してくれます。
ANOMALY_DETECTION_BAND(m1, 2)
2 は標準偏差の倍率。固定しきい値で苦しんでいる指標は、まずこっちを試す価値があります。
まとめ:明日から手を付ける順番
設計論として頭から全部やろうとすると詰むので、着手順 を提案します。
| 優先度 | やること | 理由 |
|---|---|---|
| 1 | アラート1日件数を集計する | 改善のベースラインがないと効果測定ができない |
| 2 | 「毎日鳴っているのに対応されていない」アラームをリストアップ | これが「アラーム疲れ」の主犯。M out of N と Metric Math で潰す |
| 3 | Missing Data 設定を全アラームで見直す | 1か所5分で直せて、誤検知を一気に減らせる |
| 4 | SLO を1つだけ決め、それに紐づくアラームをSLO逆算で組み直す | 全体やる前に1個で型を作る |
| 5 | EventBridge ルートに通知を寄せる / Composite Alarm で重要度分離 | 通知先を増やしやすい運用基盤に整える |
アラームは「数を増やすこと」ではなく「鳴ったら必ず行動が起きる」状態を作るのが目的です。沈黙を恐れず、根拠ある数字で組み直していきましょう。
