オブザーバビリティ設計:CloudWatch と Sentry による監視・通知の実践

|
AWS CloudWatch Sentry 監視 Lambda アラート オブザーバビリティ

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 に含まれる——それが「構築して終わりではない」運用設計の起点だ。

この記事をシェア

Twitter / X LinkedIn
記事一覧に戻る
🤖
Cloud Assistant
IBM Watson powered

こんにちは!クラウドエンジニアのポートフォリオサイトへようこそ。AWS構成・副業サービス・お仕事のご相談など、何でも聞いてください 👋