オブザーバビリティ設計:CloudWatch と Sentry による監視・通知の実践
CloudWatch + Sentryを組み合わせたオブザーバビリティ設計の実践。LP・Lambda・CloudFront・SESを対象とした監視メトリクス設定とTerraformによる自動化まで解説。
はじめに:「動いているから大丈夫」が最も危険な状態である
監視を「サーバーが落ちたら知らせてくれるもの」と捉えている間は、本当の障害には気づけない。
「大丈夫」に見えて実は壊れているパターン:
ケース①: CloudFront は 200 を返している
→ しかし Lambda のエラーを握りつぶして空レスポンスを返している
→ ユーザーには「フォーム送信が完了しました」と表示されている
→ 実際にはメールは一通も届いていない
ケース②: Lambda の実行成功率は 99%
→ 残り 1% は timeout で静かに失敗している
→ CloudWatch Logs には "Task timed out" が埋もれている
→ そのログを誰も見ていない
ケース③: SES の送信成功率は 98%
→ バウンス率が 2% を超えていた
→ 2週間後、AWS からアカウント送信制限の通知が届く
→ 原因: 古い顧客リストへの一斉送信
これは監視ツールの問題ではない。「何を観測すれば障害の予兆を捉えられるか」という設計思想の問題だ。
この記事では、LP + 連絡フォーム(S3・CloudFront・Lambda・SES)の構成を対象に、CloudWatch でインフラを観測し、Sentry でアプリケーションを観測する 2 層設計の実装を解説する。
Part 1:観測すべき対象と「見逃しやすいメトリクス」の整理
監視の 3 層と役割分担
オブザーバビリティの 3 層:
Layer 1: インフラ(CloudWatch)
├─ 「リソースが正常に動いているか」を観測
├─ メトリクス: エラー率・レイテンシ・リソース使用量
└─ アラート: 閾値超過で SNS → Slack/メール通知
Layer 2: アプリケーション(Sentry)
├─ 「コードレベルの例外・エラーを観測」
├─ スタックトレース・コンテキスト・ユーザー操作の記録
└─ アラート: 新規エラー・急増パターンで通知
Layer 3: ユーザー体験(CloudWatch RUM / Synthetic)
├─ 「ユーザーが実際に体験していることを観測」
├─ Core Web Vitals・ページロード時間・エラー率
└─ アラート: LCP や FID が閾値を超えたら通知
見逃しやすいメトリクスのリスト
CloudWatch のデフォルト設定では捕捉できないが、本番で実際に問題になりやすいメトリクスを整理する。
設定しないと見えないが重要なメトリクス:
CloudFront:
✗ デフォルトでは OFF: 5xxErrorRate(オリジンエラー率)
✗ デフォルトでは OFF: CacheHitRate(キャッシュ効率)
→ 追加メトリクスを有効化する必要がある(月額 $0.01/メトリクス)
Lambda:
✗ 捕捉できない: サイレントエラー(例外を握りつぶしてる場合)
✓ 重要: Throttles(スロットリング数)
✓ 重要: ConcurrentExecutions(同時実行数の急増)
✓ 重要: IteratorAge(Kinesis/DynamoDB Streams の処理遅延)
SES:
✗ デフォルト通知なし: Bounce / Complaint の急増
→ SNS 通知を自分で設定しないと気づけない
→ バウンス率 5% / 苦情率 0.1% を超えると送信制限
API Gateway / ALB:
✓ 重要: 4xxErrorRate(クライアントエラー急増 = 攻撃の予兆)
✓ 重要: IntegrationLatency(Lambda 呼び出しのレイテンシ)
Part 2:Terraform による CloudWatch 監視リソースの構築
全監視リソースをコード化する。アラートの追加・変更が terraform apply 一発で反映できる状態を作る。
ファイル構成
monitoring/
├── main.tf
├── variables.tf
├── outputs.tf
├── cloudwatch_alarms.tf # アラーム定義
├── cloudwatch_dashboards.tf # ダッシュボード定義
└── sns_notifications.tf # 通知チャネル
sns_notifications.tf(通知チャネル)
# アラート通知用 SNS トピック
resource "aws_sns_topic" "alerts" {
name = "${var.project_name}-monitoring-alerts"
}
# Slack への通知(Lambda 経由)
resource "aws_sns_topic_subscription" "slack" {
topic_arn = aws_sns_topic.alerts.arn
protocol = "lambda"
endpoint = aws_lambda_function.slack_notifier.arn
}
# メールへの通知
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.alerts.arn
protocol = "email"
endpoint = var.alert_email
}
# Lambda(Slack 通知用)に SNS からの invoke を許可
resource "aws_lambda_permission" "allow_sns" {
statement_id = "AllowSNSInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.slack_notifier.function_name
principal = "sns.amazonaws.com"
source_arn = aws_sns_topic.alerts.arn
}
cloudwatch_alarms.tf(アラーム定義)
# ── CloudFront: 5xx エラー率 ─────────────────────────
resource "aws_cloudwatch_metric_alarm" "cloudfront_5xx" {
alarm_name = "${var.project_name}-cloudfront-5xx-rate"
alarm_description = "CloudFront のオリジンエラー率が 1% を超えました"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2 # 2回連続で閾値超過したら発火
threshold = 1.0 # 1%
treat_missing_data = "notBreaching"
metric_name = "5xxErrorRate"
namespace = "AWS/CloudFront"
period = 300 # 5分間隔
statistic = "Average"
dimensions = {
DistributionId = var.cloudfront_distribution_id
Region = "Global"
}
alarm_actions = [aws_sns_topic.alerts.arn]
ok_actions = [aws_sns_topic.alerts.arn] # 復旧時も通知
}
# ── CloudFront: キャッシュヒット率 ───────────────────
resource "aws_cloudwatch_metric_alarm" "cloudfront_cache_hit" {
alarm_name = "${var.project_name}-cloudfront-cache-hit-low"
alarm_description = "キャッシュヒット率が 80% を下回りました。S3 への直接アクセスコスト増加の可能性"
comparison_operator = "LessThanThreshold"
evaluation_periods = 3
threshold = 80.0
treat_missing_data = "notBreaching"
metric_name = "CacheHitRate"
namespace = "AWS/CloudFront"
period = 3600 # 1時間単位(キャッシュヒット率は短期間で変動しにくい)
statistic = "Average"
dimensions = {
DistributionId = var.cloudfront_distribution_id
Region = "Global"
}
alarm_actions = [aws_sns_topic.alerts.arn]
}
# ── Lambda: エラー率 ──────────────────────────────────
resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
alarm_name = "${var.project_name}-lambda-error-rate"
alarm_description = "Lambda のエラー率が 5% を超えました"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
threshold = 5.0
treat_missing_data = "notBreaching"
# エラー率 = Errors / Invocations × 100 を計算する数式メトリクス
metric_query {
id = "error_rate"
expression = "(errors / invocations) * 100"
label = "Error Rate (%)"
return_data = true
}
metric_query {
id = "errors"
metric {
metric_name = "Errors"
namespace = "AWS/Lambda"
period = 300
stat = "Sum"
dimensions = { FunctionName = var.lambda_function_name }
}
}
metric_query {
id = "invocations"
metric {
metric_name = "Invocations"
namespace = "AWS/Lambda"
period = 300
stat = "Sum"
dimensions = { FunctionName = var.lambda_function_name }
}
}
alarm_actions = [aws_sns_topic.alerts.arn]
}
# ── Lambda: タイムアウト(サイレントエラー検知) ──────
resource "aws_cloudwatch_log_metric_filter" "lambda_timeout" {
name = "${var.project_name}-lambda-timeout"
pattern = "\"Task timed out\"" # CloudWatch Logs のパターンマッチ
log_group_name = "/aws/lambda/${var.lambda_function_name}"
metric_transformation {
name = "LambdaTimeoutCount"
namespace = "Custom/${var.project_name}"
value = "1"
default_value = "0"
}
}
resource "aws_cloudwatch_metric_alarm" "lambda_timeout" {
alarm_name = "${var.project_name}-lambda-timeout"
alarm_description = "Lambda がタイムアウトしています。処理時間またはタイムアウト設定を見直してください"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "LambdaTimeoutCount"
namespace = "Custom/${var.project_name}"
period = 300
statistic = "Sum"
threshold = 0 # 1件でも発生したら即通知
treat_missing_data = "notBreaching"
alarm_actions = [aws_sns_topic.alerts.arn]
}
# ── Lambda: スロットリング ────────────────────────────
resource "aws_cloudwatch_metric_alarm" "lambda_throttles" {
alarm_name = "${var.project_name}-lambda-throttles"
alarm_description = "Lambda がスロットリングされています。同時実行数の上限引き上げを検討してください"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "Throttles"
namespace = "AWS/Lambda"
period = 60
statistic = "Sum"
threshold = 0
treat_missing_data = "notBreaching"
dimensions = { FunctionName = var.lambda_function_name }
alarm_actions = [aws_sns_topic.alerts.arn]
}
SES バウンス・苦情監視
# ── SES: バウンス率 ───────────────────────────────────
resource "aws_cloudwatch_metric_alarm" "ses_bounce_rate" {
alarm_name = "${var.project_name}-ses-bounce-rate"
alarm_description = "SES バウンス率が 3% を超えました(AWS 制限の警告水準: 5%)"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "Reputation.BounceRate"
namespace = "AWS/SES"
period = 3600 # 1時間単位
statistic = "Average"
threshold = 0.03 # 3%(AWS の制限水準 5% の手前で警告)
treat_missing_data = "notBreaching"
alarm_actions = [aws_sns_topic.alerts.arn]
}
# ── SES: 苦情率 ───────────────────────────────────────
resource "aws_cloudwatch_metric_alarm" "ses_complaint_rate" {
alarm_name = "${var.project_name}-ses-complaint-rate"
alarm_description = "SES 苦情率が 0.08% を超えました(AWS 制限の警告水準: 0.1%)"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "Reputation.ComplaintRate"
namespace = "AWS/SES"
period = 3600
statistic = "Average"
threshold = 0.0008 # 0.08%
treat_missing_data = "notBreaching"
alarm_actions = [aws_sns_topic.alerts.arn]
}
Part 3:CloudWatch ダッシュボードの設計
アラートは「異常を知らせる」ものだが、ダッシュボードは「正常な状態を定義する」ものだ。何が正常かを知らなければ、異常を正しく判断できない。
# cloudwatch_dashboards.tf
resource "aws_cloudwatch_dashboard" "main" {
dashboard_name = "${var.project_name}-overview"
dashboard_body = jsonencode({
widgets = [
# ── 行1: CloudFront の全体像 ──────────────────────
{
type = "metric"
x = 0; y = 0; width = 8; height = 6
properties = {
title = "CloudFront: リクエスト数と 5xx エラー率"
view = "timeSeries"
stacked = false
metrics = [
["AWS/CloudFront", "Requests", "DistributionId", var.cloudfront_distribution_id, "Region", "Global"],
[".", "5xxErrorRate", ".", ".", ".", ".", { yAxis = "right", stat = "Average" }]
]
period = 300
yAxis = { left = { label = "Requests" }, right = { label = "Error Rate (%)" } }
}
},
{
type = "metric"
x = 8; y = 0; width = 8; height = 6
properties = {
title = "CloudFront: キャッシュヒット率"
view = "timeSeries"
metrics = [
["AWS/CloudFront", "CacheHitRate", "DistributionId", var.cloudfront_distribution_id, "Region", "Global",
{ stat = "Average", color = "#2ca02c" }]
]
period = 3600
yAxis = { left = { min = 0, max = 100 } }
annotations = {
horizontal = [{ value = 80, label = "目標値 80%", color = "#ff7f0e" }]
}
}
},
# ── 行2: Lambda の詳細 ────────────────────────────
{
type = "metric"
x = 0; y = 6; width = 8; height = 6
properties = {
title = "Lambda: 実行回数・エラー・スロットリング"
view = "timeSeries"
metrics = [
["AWS/Lambda", "Invocations", "FunctionName", var.lambda_function_name, { stat = "Sum" }],
[".", "Errors", ".", ".", { stat = "Sum", color = "#d62728" }],
[".", "Throttles", ".", ".", { stat = "Sum", color = "#ff7f0e" }]
]
period = 300
}
},
{
type = "metric"
x = 8; y = 6; width = 8; height = 6
properties = {
title = "Lambda: 実行時間 (P50 / P90 / P99)"
view = "timeSeries"
metrics = [
["AWS/Lambda", "Duration", "FunctionName", var.lambda_function_name, { stat = "p50", label = "p50", color = "#2ca02c" }],
["...", { stat = "p90", label = "p90", color = "#ff7f0e" }],
["...", { stat = "p99", label = "p99", color = "#d62728" }]
]
period = 300
}
},
# ── 行3: SES レピュテーション ─────────────────────
{
type = "metric"
x = 0; y = 12; width = 16; height = 6
properties = {
title = "SES: バウンス率・苦情率(制限値との比較)"
view = "timeSeries"
metrics = [
["AWS/SES", "Reputation.BounceRate", { stat = "Average", label = "バウンス率", color = "#d62728" }],
[".", "Reputation.ComplaintRate", { stat = "Average", label = "苦情率", color = "#ff7f0e" }]
]
period = 3600
annotations = {
horizontal = [
{ value = 0.05, label = "バウンス制限 5%", color = "#d62728" },
{ value = 0.001, label = "苦情制限 0.1%", color = "#ff7f0e" }
]
}
}
}
]
})
}
Part 4:Sentry によるアプリケーション層の監視
CloudWatch はインフラの異常を捉えるが、「どのコード行で何が起きたか」はわからない。Lambda の Errors が増えても、CloudWatch からはスタックトレースを追えない。そこで Sentry を組み合わせる。
Lambda への Sentry 組み込み
# Lambda レイヤー or 依存に追加
npm install @sentry/aws-serverless
// lambda/contact-form/index.ts
import * as Sentry from '@sentry/aws-serverless'
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'
// ハンドラを Sentry でラップ(このひとつで全例外・パフォーマンスを計測)
export const handler = Sentry.wrapHandler(async (event: APIGatewayProxyEvent) => {
const client = new SESv2Client({ region: 'ap-northeast-1' })
let body: { name: string; email: string; message: string }
try {
body = JSON.parse(event.body ?? '{}')
} catch (e) {
// Sentry に送られるエラーには自動でリクエスト情報が付与される
throw new Error('Invalid JSON body')
}
// バリデーション(ここで弾いたエラーも Sentry に記録される)
if (!body.email || !body.message) {
// Sentry の「タグ」でフィルタリングできるようにする
Sentry.setTag('error_type', 'validation_error')
throw new Error('Missing required fields: email, message')
}
// ユーザー情報をコンテキストとして付与(デバッグ時に「誰の操作か」がわかる)
Sentry.setUser({ email: body.email })
Sentry.setContext('form_data', {
name: body.name,
message_length: body.message.length,
})
try {
await client.send(new SendEmailCommand({
FromEmailAddress: `noreply@${process.env.DOMAIN_NAME}`,
Destination: { ToAddresses: [process.env.ADMIN_EMAIL!] },
Content: {
Simple: {
Subject: { Data: `お問い合わせ:${body.name}`, Charset: 'UTF-8' },
Body: { Text: { Data: body.message, Charset: 'UTF-8' } },
},
},
}))
} catch (e) {
// SES エラーは「外部サービス障害」として分類
Sentry.setTag('error_type', 'ses_send_failure')
// Sentry にエラーを明示的に送信してからリスローする
Sentry.captureException(e)
throw e
}
return {
statusCode: 200,
body: JSON.stringify({ ok: true }),
}
}, {
// トレースサンプリングレート(本番では 0.1〜0.2 で十分)
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? '0.1'),
})
Sentry の DSN を Secrets Manager で管理
# Terraform で Sentry DSN を Secrets Manager に格納
resource "aws_secretsmanager_secret" "sentry_dsn" {
name = "${var.project_name}/sentry-dsn"
}
# Lambda の環境変数に注入
resource "aws_lambda_function" "contact_form" {
# ...(他の設定は省略)
environment {
variables = {
SENTRY_DSN = data.aws_secretsmanager_secret_version.sentry_dsn.secret_string
SENTRY_TRACES_SAMPLE_RATE = "0.1"
SENTRY_ENVIRONMENT = var.env
# Sentry の "release" にデプロイ識別子を入れるとエラー発生バージョンを追跡できる
SENTRY_RELEASE = var.app_version
}
}
}
Part 5:実際のトラブルシューティング 3 パターン
設計の価値は、実際に問題が起きたときに何分で原因を特定できるかで測られる。
パターン①:「フォーム送信が成功しているのにメールが届かない」
症状:
・ユーザーから「メールが届いていない」という報告
・CloudFront の 5xxErrorRate: 0%(正常)
・Lambda の Errors: 0%(正常)
調査手順:
1. CloudWatch Logs Insights で Lambda ログを確認
fields @timestamp, @message
| filter @message like /SES/
| sort @timestamp desc
| limit 20
2. → "MessageRejected: Email address is not verified" を発見
→ SES がサンドボックスモードのまま!
→ 送信先のメールアドレスが未検証のため拒否されている
3. Sentry のエラーログを確認
→ "ses_send_failure" タグのイベントが大量発生している
→ 例外は SES から返されていたが、Lambda は
catch して 200 を返していた(例外の握りつぶし)
根本原因:
Lambda のエラーハンドリングが try/catch で
200 を返していた。CloudWatch の Errors が増えない理由は
「Lambda 自体は正常終了していたから」。
対策:
1. SES をサンドボックス解除、または検証済みアドレスを設定
2. SES 送信失敗時は 500 を返すように修正
3. Sentry の "ses_send_failure" アラートを即時通知に設定
パターン②:「特定の時間帯だけレスポンスが遅い」
症状:
・平日 9 時〜10 時にフォーム送信が遅くなるとの報告
・CloudFront の 5xxErrorRate: 正常
・Lambda の Errors: 正常
調査手順:
1. CloudWatch ダッシュボードで Lambda の Duration P99 を確認
→ 平日 9 時台だけ P99 が 8000ms(タイムアウト設定 10 秒に近い)
2. Lambda の ConcurrentExecutions を確認
→ 9 時台に急増している(他の Lambda と合算してリージョン上限に近い)
3. Lambda の Throttles を確認
→ スロットリングは発生していない(ぎりぎり上限以下)
4. 実行時間の内訳を X-Ray で確認
→ SES への接続時間が長い
→ VPC Lambda ではないが、SES エンドポイントへの
TCP 接続が毎回確立されている
根本原因:
Lambda の実行環境が 9 時台にコールドスタートしやすい
環境(夜間停止後の初回起動)+ SES への接続確立コスト。
対策:
1. SES クライアントをハンドラ外で初期化(コネクション再利用)
2. 9-10 時に EventBridge で空実行をスケジュール(ウォームアップ)
3. 根本: 該当 Lambda は SES 送信専用なので
Provisioned Concurrency(最小 1)で恒久対応
パターン③:「突然 AWS からアカウント制限の通知が来た」
症状:
・AWS からメール: "SES sending suspended"
・バウンス率が 7.3% を超えていた
調査手順:
1. CloudWatch の SES Reputation.BounceRate を遡る
→ 3 日前から段階的に上昇していた
→ アラームが設定されていなかったため気づかなかった
2. SES のバウンス通知(SNS)を確認
→ "permanent" バウンス(存在しないアドレス)が多数
3. 送信ログを確認
→ 3 日前に一斉メール配信を実施していた
→ リストが 2 年前のもので、退職・廃止アドレスが多数含まれていた
根本原因:
定期的なリストクリーニングがなく、古いアドレスに
送り続けていた。SES のバウンス監視がなかったため
制限まで気づけなかった。
対策:
1. 今回の Terraform 設定でバウンス率 3% アラームを追加
2. バウンスした宛先を D1/DynamoDB に記録し、再送を防止する仕組みを実装
3. 一斉配信前にリストの最終送信日でフィルタリング(6ヶ月以内のみ)
Part 6:Cloudflare Workers の監視(Cloudflare Analytics)
CloudWatch は AWS サービスの監視だが、Hono + Cloudflare Pages で構築したフロントエンドには別の観測手段が必要だ。
// Hono ミドルウェア:エラーを Sentry に転送する
// Cloudflare Workers 環境では @sentry/cloudflare を使う
import * as Sentry from '@sentry/cloudflare'
export default {
fetch: Sentry.withSentry(
(env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 0.1,
environment: env.ENVIRONMENT ?? 'production',
}),
async (request, env) => {
// Hono アプリを実行
return app.fetch(request, env)
}
),
} satisfies ExportedHandler<Env>
Cloudflare の組み込み Analytics では以下が確認できる(追加設定不要)。
Cloudflare Workers Analytics で確認できるもの:
・リクエスト数(PoP 別)
・エラー率(4xx / 5xx)
・CPU 時間の分布(50ms 制限への接近を監視)
・コールドスタート率(Workers は基本ゼロだが参考値として)
注意:
→ クライアントサイドのエラーは Workers Analytics では見えない
→ ユーザー操作中の JS エラーは Sentry RUM を別途設定する
Conclusion:「気づける設計」こそがオブザーバビリティの本質
監視は「アラートが来たら対応する」という受動的な活動ではない。「何を観測すれば障害の予兆を捉えられるか」を設計段階で決め、その観測基盤をコードとして管理する——それがオブザーバビリティ設計だ。
この記事で構築した監視体系をまとめると次のようになる。
| 対象 | ツール | 重要指標 | 閾値の考え方 |
|---|---|---|---|
| CloudFront | CloudWatch | 5xxErrorRate | 1%(通常 < 0.1%) |
| CloudFront | CloudWatch | CacheHitRate | 80% 下回ったらコスト増の予兆 |
| Lambda | CloudWatch | Errors(数式) | 5%(エラー率) |
| Lambda | CloudWatch ログフィルター | Timeout | 1件で即通知 |
| Lambda | Sentry | 例外・スタックトレース | 新規エラーは即通知 |
| SES | CloudWatch | BounceRate | 3%(制限の 5% の手前) |
| SES | CloudWatch | ComplaintRate | 0.08%(制限の 0.1% の手前) |
| Cloudflare Workers | Sentry for Cloudflare | 例外・レイテンシ | P99 > 500ms |
Terraform でこれらをコード管理することで、新しいリソースを追加するたびに監視設定を手動で追加する必要がなくなる。インフラの変更と監視の変更が同一の Pull Request に含まれる——それが「構築して終わりではない」運用設計の起点だ。
この記事をシェア