目次
26 章

モニタリング — CloudWatch アラームと X-Ray

CloudWatch Logs Insights の運用クエリ、ECS / RDS / ALB の核となるメトリクスとアラーム閾値、SNS → Slack 通知、X-Ray 分散トレースで遅いリクエストをすぐ把握するところまで。運用の目をともす流れを整理します。

第22章 ~ 第25章 でインフラがコードになり、デプロイが自動になりました。ところが肝心のこのシステムがうまく動いているか — 5xx が増えたか、RDS CPU が 80% か、どのリクエストが5秒かかったか — を1画面で見られていません。

本章はその状態をすぐ見えるようにします。4部の五番目の章として、扱う内容は次のとおりです。

  • CloudWatch Logs + Logs Insights の運用クエリ
  • CloudWatch Metrics — ECS / RDS / ALB の核となるメトリクスとアラーム閾値
  • アラーム → SNS → Slack の流れ
  • X-Ray — 分散トレースで「どこが遅いのか」をすぐ把握
  • ダッシュボード — 一つの画面でシステムの状態

大きな絵 — モニタリングの4つの構成 #

可観測性の構成
┌──────────────┬──────────────┬──────────────┬──────────────┐
│   Metrics    │     Logs     │    Traces    │    Events    │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ "どれだけ"    │ "何が"        │ "どこで"      │ "いつ"        │
│  リクエスト数, 5xx │ stacktrace   │ DB が5秒    │ デプロイ, スケール │
│  CPU, メモリ │ access log   │ 外部 API 1秒 │ フェイルオーバー │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ CloudWatch   │ CloudWatch   │ X-Ray        │ EventBridge  │
│ Metrics      │ Logs         │              │              │
└──────────────┴──────────────┴──────────────┴──────────────┘

本章は Metrics + Logs + Traces の三領域です。Events は 第19章 EventBridge / SQS / SNS で扱いました。

1) CloudWatch Logs — すでに流れている #

第22章 の Task Definition に awslogs が含まれているので、すべてのコンテナの stdout/stderr が自動で CloudWatch Logs へ行きます。

ログの階層
Log Group: /ecs/blog-api
   ├── Log Stream: api/<task-id-1>     ← Task ごとに stream 一つ
   ├── Log Stream: api/<task-id-2>
   └── Log Stream: api/<task-id-3>

Retention 設定 — コストの分離 #

デフォルトは無期限保管です。小さなトラフィックでも1か月分のログが GB 単位で積もるとコストが大きくなります。

30 日保管
aws logs put-retention-policy \
  --log-group-name /ecs/blog-api \
  --retention-in-days 30

推奨値は次のとおりです。

  • 運用 access log: 30 ~ 90日
  • デバッグ / verbose: 7日
  • 監査ログ: 365日(または S3 export 後に削除)

構造化ログが核心 #

print() は検索が難しいです。JSON で出力すれば Logs Insights がキーごとにクエリできます。

FastAPI — JSON ロギング
import logging, json, sys

class JsonFormatter(logging.Formatter):
    def format(self, record):
        return json.dumps({
            "level":   record.levelname,
            "logger":  record.name,
            "message": record.getMessage(),
            "ts":      self.formatTime(record),
            **getattr(record, "extra", {}),
        })

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
リクエストごとのログ
@app.middleware("http")
async def access_log(request, call_next):
    start = time.time()
    response = await call_next(request)
    logging.info("access", extra={"extra": {
        "method": request.method,
        "path": str(request.url.path),
        "status": response.status_code,
        "duration_ms": int((time.time() - start) * 1000),
        "request_id": request.state.request_id,
    }})
    return response

このように出力すると、Logs Insights が次のようなクエリに正確に答えます。

2) Logs Insights — 運用クエリ7個 #

よく使うクエリのまとめです。ブックマークしておくとよいです。

A) 5xx だけ抽出 #

fields @timestamp, status, path, request_id, message
| filter status >= 500
| sort @timestamp desc
| limit 100

B) 応答時間の分布 (p50/p90/p99) #

fields @timestamp, duration_ms
| filter ispresent(duration_ms)
| stats
    count(*) as requests,
    pct(duration_ms, 50) as p50,
    pct(duration_ms, 90) as p90,
    pct(duration_ms, 99) as p99
  by bin(5m)

C) 最も遅い path #

fields path, duration_ms
| filter duration_ms > 1000
| stats count(*), avg(duration_ms), max(duration_ms) by path
| sort avg(duration_ms) desc
| limit 20

D) request_id で一つのリクエストを追跡 #

fields @timestamp, level, message, path, status, duration_ms
| filter request_id = "abc-123-xyz"
| sort @timestamp asc

E) Stacktrace が出力された行 #

fields @timestamp, message
| filter @message like /Traceback|exception/
| sort @timestamp desc

F) ログイン試行 (認証失敗) #

fields @timestamp, source_ip, username
| filter event = "auth_fail"
| stats count(*) by source_ip
| sort count(*) desc

G) コスト — どの path が最も多く呼び出されたか #

fields path
| stats count(*) by path
| sort count(*) desc
| limit 30

Saved Queries #

よく使うクエリはコンソールで保存してチーム全体で共有します。CloudFormation / Terraform で IaC 化できます(aws_cloudwatch_query_definition)。

3) CloudWatch Metrics — 核となる指標 #

ECS Container Insights #

デフォルトの ECS メトリクスは貧弱です。Container Insights をオンにすると、task / service 単位の CPU / メモリ / ネットワーク / ディスク / running task 数をまとめて見られます。

Container Insights の有効化
aws ecs update-cluster-settings \
  --cluster blog-cluster \
  --settings name=containerInsights,value=enabled

追加コスト(小さなクラスターで ~$1 ~ 3/月)がかかりますが、運用では必須です。

モニタリング表 — 何を見るべきか #

メトリクス資源意味アラーム閾値 (例)
HTTPCode_Target_5XX_CountALBバックエンド 5xx5分合計 ≥ 5
HTTPCode_ELB_5XX_CountALBALB 自体の 5xx(大半は healthy host 0)5分合計 ≥ 1
TargetResponseTime (p99)ALB応答時間 p995分平均 ≥ 1.0s
UnHealthyHostCountTarget Group死んだ task 数5分平均 ≥ 1
CPUUtilization (Service)ECSサービス平均 CPU5分平均 ≥ 80%
MemoryUtilization (Service)ECSメモリ5分平均 ≥ 85%
RunningTaskCountECS立ち上がっている task 数desired と異なる
CPUUtilizationRDSDB CPU5分平均 ≥ 80%
DatabaseConnectionsRDS接続数max_connections の 80%
FreeStorageSpaceRDS残りのディスク< 5GB
ReadLatency / WriteLatencyRDSディスク遅延> 50ms

Custom Metrics #

アプリから直接出力するメトリクスです。ログに EMF (Embedded Metric Format) で埋め込めば、別途呼び出しなしでメトリクスが作られます。

EMF でビジネスメトリクス
import json, time, logging

def emit_metric(metric_name, value, unit="Count", **dims):
    payload = {
        "_aws": {
            "Timestamp": int(time.time() * 1000),
            "CloudWatchMetrics": [{
                "Namespace": "BlogApp",
                "Dimensions": [list(dims.keys())],
                "Metrics": [{"Name": metric_name, "Unit": unit}],
            }],
        },
        metric_name: value,
        **dims,
    }
    logging.info(json.dumps(payload))

emit_metric("PostCreated", 1, env="prod")
emit_metric("CommentCreated", 1, env="prod")
emit_metric("LoginFailed", 1, source_ip="...")

CloudWatch がログをパースして自動で BlogApp/PostCreated メトリクスを作ります。別途 PutMetricData API 呼び出しがないので、コストと遅延の両方を節約します。

4) アラーム — 閾値を超えると人を呼ぶ #

ALB 5xx アラーム
aws cloudwatch put-metric-alarm \
  --alarm-name "blog-alb-5xx-burst" \
  --metric-name HTTPCode_Target_5XX_Count \
  --namespace AWS/ApplicationELB \
  --statistic Sum \
  --period 60 \
  --evaluation-periods 5 \
  --datapoints-to-alarm 3 \
  --threshold 5 \
  --comparison-operator GreaterThanOrEqualToThreshold \
  --treat-missing-data notBreaching \
  --dimensions Name=LoadBalancer,Value=app/blog-alb/abc123 \
  --alarm-actions arn:aws:sns:ap-northeast-2:123456789012:ops-alerts

核となるオプションを整理すると次のとおりです。

オプション意味
periodデータポイントの単位(60 = 1分)
evaluation-periods何個のポイントを評価するか
datapoints-to-alarmそのうち何個が閾値を超えたらアラーム
treat-missing-dataデータがないとき — notBreaching 推奨
comparison-operator>= / > / < / <=

5/3 パターン(「直近5分のうち3分が閾値を超えたら」)が、一時的なスパイクをふるい落としつつ本当の事故を掴む基準です。

Composite Alarm #

複数のアラームをまとめます。「ALB 5xx アラーム AND task running 正常」なら本当のバックエンドの問題です。

Composite Alarm
aws cloudwatch put-composite-alarm \
  --alarm-name "blog-real-incident" \
  --alarm-rule "ALARM('blog-alb-5xx-burst') AND OK('blog-running-tasks-low')"

OK() は普段は ok なのに別の一つのアラームが alarm である場合を意味し、ノイズを減らします。

5) SNS → Slack — 人に届く部分 #

通知の流れ
CloudWatch Alarm
SNS Topic (ops-alerts)
   ├── Email subscription   (運用陣)
   ├── SMS subscription      (oncall)
   ├── Lambda subscription   ← Slack webhook へ変換
   └── PagerDuty / OpsGenie

SNS → Slack Lambda #

lambda_handler.py
import json, os, urllib.request

WEBHOOK = os.environ["SLACK_WEBHOOK"]

def handler(event, context):
    for record in event["Records"]:
        msg = json.loads(record["Sns"]["Message"])
        text = (
            f":rotating_light: *{msg['AlarmName']}*\n"
            f"Region: {msg['Region']}\n"
            f"State: {msg['NewStateValue']} (was {msg['OldStateValue']})\n"
            f"Reason: {msg['NewStateReason']}\n"
        )
        req = urllib.request.Request(
            WEBHOOK,
            data=json.dumps({"text": text}).encode(),
            headers={"Content-Type": "application/json"},
        )
        urllib.request.urlopen(req)

第17章 Lambda 基礎 のパターンです。SNS subscription で SNS が Lambda を呼び出せば終わりです。

アラームメッセージの様式 #

良いアラームメッセージには次が入ります。

  • 何が壊れたか(alarm name)
  • どれだけ壊れたか(threshold / actual value)
  • どこで(region, service)
  • いつ(timestamp)
  • リンク — コンソール / ダッシュボード / Logs Insights へすぐに

リンクが最も重要です。深夜3時に Slack を見た oncall が、クリック一つでコンテキストに入れなければなりません。

6) X-Ray — 分散トレース #

「5xx が増えた」までは Metrics が知らせます。「なぜ 5xx が増えたのか?」は Logs が答えます。「このリクエストがどこで5秒を費やしたのか?」は X-Ray が答えます。

X-Ray Trace の形
Request: POST /posts                       4.2s
   ├── ALB                                 0.01s
   └── ECS api                             4.15s
         ├── auth.verify_token             0.05s
         ├── db.posts.insert               3.80s   ← 犯人
         │     └── RDS PostgreSQL          3.78s
         │           └── (slow query)
         └── notify.publish (SNS)          0.30s
               └── SNS:Publish             0.28s

FastAPI/Django 統合 #

インストール
pip install aws-xray-sdk
FastAPI
from aws_xray_sdk.core import xray_recorder
from aws_xray_sdk.ext.fastapi.middleware import XRayMiddleware
from aws_xray_sdk.ext.sqlalchemy.query import XRayQuery

xray_recorder.configure(service="blog-api")

app = FastAPI()
app.add_middleware(XRayMiddleware, recorder=xray_recorder)

# SQLAlchemy 追跡
from aws_xray_sdk.ext.sqlalchemy_core import unpatch
# (engine 生成時に自動 patch)

Sidecar — X-Ray Daemon #

ECS では X-Ray Daemon コンテナを同じ task definition の中に sidecar として置きます。

task definition に sidecar を追加
{
  "containerDefinitions": [
    { "name": "api", ... },
    {
      "name": "xray-daemon",
      "image": "public.ecr.aws/xray/aws-xray-daemon:latest",
      "portMappings": [{ "containerPort": 2000, "protocol": "udp" }],
      "essential": false
    }
  ]
}

アプリは 127.0.0.1:2000 へ trace を送り、daemon が X-Ray サービスへ一括送信します。別途 IAM アクション(xray:PutTraceSegments)が task role に必要です。

どこで価値が最も大きいか #

状況X-Ray の価値
単一コンテナ + 単一 DB普通 — Logs だけでも十分
複数のマイクロサービス呼び出し非常に大きい — どの段階が遅いかすぐ分かる
外部 API 依存非常に大きい — 外部が本当に遅いか検証
Lambda + DynamoDB非常に大きい — Lambda コールドスタート、外部呼び出しの分離

サンプリング #

すべてのリクエストを追跡するとコストが大きくなります。サンプリングルールで 5 ~ 10% だけ追跡します。

x-ray.json
{
  "version": 2,
  "rules": [{
    "description": "Default",
    "service_name": "*",
    "http_method": "*",
    "url_path": "*",
    "fixed_target": 1,
    "rate": 0.05
  }],
  "default": { "fixed_target": 1, "rate": 0.05 }
}

/health のようなヘルスチェックは 0% に抜いてしまわないと、trace がノイズで埋め尽くされます。

7) ダッシュボード — 一つの画面 #

CloudWatch Dashboard に運用のシグナルを一つの画面に集めます。

推奨ウィジェット9個
[1] Requests/s (ALB)         [2] 5xx rate (ALB)         [3] p99 latency
[4] ECS CPU (Service)         [5] ECS Memory             [6] Running tasks
[7] RDS CPU                   [8] RDS Connections        [9] RDS FreeStorage
ダッシュボード IaC (Terraform 抜粋)
resource "aws_cloudwatch_dashboard" "blog" {
  dashboard_name = "blog-overview"
  dashboard_body = jsonencode({
    widgets = [
      {
        type   = "metric",
        x      = 0, y = 0, width = 8, height = 6,
        properties = {
          metrics = [["AWS/ApplicationELB", "RequestCount", "LoadBalancer", "app/blog-alb/abc123"]],
          period  = 60, stat = "Sum", region = "ap-northeast-2",
          title   = "Requests/min"
        }
      }
      # ... あと 8 個
    ]
  })
}

定期レビュー #

毎週一度 oncall がダッシュボードを見渡しながら、徐々に悪くなる指標を探します。アラームは即時の事故だけを掴み、徐々に悪化するものは人の目のほうが速いです。

落とし穴 — 運用モニタリングの落とし穴 #

1) Alert Fatigue — アラームが多すぎる #

アラームが一日30個ならすぐに全員がアラームを無視します。推奨等級は次のとおりです。

アラーム等級頻度チャネル
Critical月 1 ~ 2回PagerDuty / SMS
Warning週 1 ~ 2回Slack #ops
Info頻繁にSlack #ops-info(静かなチャネル)

本当に人を起こすアラームは 5個未満に保ちます。

2) Logs が無限に大きくなる #

Retention 設定を欠落させると6か月後に請求書ショックが来ます。すべての log group に retention を適用します(Terraform で一括で)。

3) Logs が小さすぎる #

運用事故の直後に「あのときのログを見よう」とするのに、7日 retention ですでに消えている場合です。事故発生後すぐの export では遅すぎます。 核となるグループは30日以上に置きます。

4) X-Ray 100% サンプリング #

コストが暴走します。5 ~ 10% サンプル + エラー / 遅いリクエストのみ 100%(X-Ray の sampling rule で可能)に置きます。

5) SLO なしでアラームだけ #

アラームの閾値がどこから来たか — 「私が 80% と言いました」 — になります。SLO(例: p99 < 500ms を 99% の時間)が明示されていないと閾値が恣意的になります。SLO の定義からアラーム閾値を導きます。

6) Dashboard はあるが見ない #

作っておいて見ないダッシュボードはないのと同じです。週次 oncall 会議に30分のダッシュボードレビューを入れます。

7) アラームが人に届かない #

メールだけ使うと受信トレイの奥深くに入ります。SMS / PagerDuty / Slack の mention のような自分を呼ぶチャネルを使います。

練習問題 #

  1. モニタリングの4つの構成(Metrics / Logs / Traces / Events)がそれぞれどんな問い(「どれだけ / 何が / どこで / いつ」)に答えるかを §「大きな絵」を見ずに書いてみてください。本章がそのうちどの三領域を扱うかも示しておいてください。
  2. ALB 5xx アラームの periodevaluation-periodsdatapoints-to-alarm の三つの値がどう 5/3 パターンを作るかを §「アラーム」を根拠に説明し、このパターンが一時的なスパイクをふるい落とす理由を一文で書いてみてください。
  3. X-Ray の価値が最も大きい状況を二つ §「どこで価値が最も大きいか」の表から選び、100% サンプリングがなぜ危険かを 第27章 コスト最適化 と結びつけて説明してみてください。

一行まとめ: 可観測性は Metrics(どれだけ)・Logs(何が)・Traces(どこで)・Events(いつ)に分かれる。awslogs で自動収集されるログは構造化 JSON で出力して Logs Insights でクエリし、Container Insights で ECS/RDS/ALB のメトリクスを見る。アラームは period × evaluation × datapoints パターンでノイズをふるい落とし、SNS → Lambda → Slack で人に届かせ、X-Ray は分散トレースで遅い段階を一行で掴みつつサンプリングでコストを制御する。

次の章 #

これでシステムがうまく動き、事故が起きればアラームが鳴る構造まで来ました。最後に — どれだけかかっているか、そしてそのコストをどう減らすかです。次の 第27章 コスト最適化とダッシュボード では、Cost Explorer 分析、Savings Plans / Spot Fargate / Graviton、Right Sizing、タグ付けの強制、コストダッシュボードまで整理し、4部を締めくくります。

X