AWS実践 #5 モニタリング — CloudWatch アラームと X-Ray

読了 9分

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

今回はその目を開きます。

  • CloudWatch Logs + Logs Insights の本番クエリ
  • CloudWatch Metrics — ECS / RDS / ALB の核心メトリクスとアラーム閾値
  • アラーム → SNS → Slack の流れ
  • X-Ray — 分散トレースで「どこが遅いか」を 1 行で
  • ダッシュボード — 1 画面でシステムの状態

全体像 — モニタリングの 4 つの柱 #

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

今回は Metrics + Logs + Traces の 3 つ。Events は 上級 #5 ですでに扱っています。

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

#1 の Task Definition に awslogs が刺さっているので すべてのコンテナの stdout/stderr が自動で CloudWatch Logs に行きます。

ログの階層
Log Group: /ecs/blog-api
   ├── Log Stream: api/<task-id-1>     ← Task ごとに stream 1 つ
   ├── 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 で 1 リクエストを追跡 #

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

E) スタックトレースが出ている行 #

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 だがもう 1 つのアラームが 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)

上級 #3 Lambda のパターン。SNS subscription で SNS が Lambda を呼ぶようにすれば完了。

アラームメッセージのフォーマット #

良いアラームメッセージには:

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

リンクが最も重要。深夜 3 時に Slack を見た oncall が、クリック 1 つでコンテキストへ。

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 だけでも足りる
複数のマイクロサービス呼び出し非常に大きい — どの段階が遅いか 1 行で
外部 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) ダッシュボード — 1 画面に #

CloudWatch Dashboard に運用のシグナルを 1 か所に:

推奨ウィジェット 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 個
    ]
  })
}

定期レビュー #

週に 1 度 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 のような 自分を呼ぶ チャネル。

まとめ #

今回押さえたこと:

  • 4 つの柱 — Metrics / Logs / Traces / Events
  • CloudWatch Logs — awslogs 自動、retention 設定、構造化 JSON、本番クエリ 7 つ
  • CloudWatch Metrics — Container Insights を有効化、ECS / RDS / ALB の核心メトリクスと閾値
  • EMF — PutMetricData なしでログからメトリクス発行
  • アラームperiod × evaluation × datapoints のパターン、treat-missing-data
  • Composite Alarm — ノイズを減らす
  • SNS → Lambda → Slack — アラームを人に届ける
  • X-Ray — 分散トレース、sidecar daemon、サンプリングでコスト管理
  • ダッシュボード — 9 ウィジェットを 1 画面、IaC 化
  • 落とし穴 — alert fatigue、retention、サンプリング、SLO 抜け、ダッシュボード未観察、アラームのチャネル

次回 — コストとトラックの締めくくり #

これでシステムがうまく回り、事故ならアラームが鳴る段階まで来ました。最後に — どれだけコストがかかっているか、そして 27 編のトラック を 1 か所で振り返ります。

#6 コスト最適化とダッシュボード — トラックの締めくくり では Cost Explorer 分析、Savings Plans / Spot Fargate、Right Sizing、タグ強制、コストダッシュボード、そして AWS トラック 27 編が 1 つのシステムに集約される姿をまとめます。

X