AWS実践 #5 モニタリング — CloudWatch アラームと X-Ray
#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 単位で積み上がるとコストが膨らみます。
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 がキー単位でクエリできます。
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 100B) 応答時間の分布 (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 20D) request_id で 1 リクエストを追跡 #
fields @timestamp, level, message, path, status, duration_ms
| filter request_id = "abc-123-xyz"
| sort @timestamp ascE) スタックトレースが出ている行 #
fields @timestamp, message
| filter @message like /Traceback|exception/
| sort @timestamp descF) ログイン失敗 #
fields @timestamp, source_ip, username
| filter event = "auth_fail"
| stats count(*) by source_ip
| sort count(*) descG) コスト — どの path が最も呼ばれているか #
fields path
| stats count(*) by path
| sort count(*) desc
| limit 30Saved Queries #
よく使うクエリはコンソールで 保存 — チーム全体で共有。CloudFormation / Terraform で IaC 化可能 (aws_cloudwatch_query_definition)。
3) CloudWatch Metrics — 中核の指標 #
ECS Container Insights #
デフォルトの ECS メトリクスは貧弱。Container Insights をオンにすると task / service 単位の CPU / メモリ / ネットワーク / ディスク / running task 数が一気に。
aws ecs update-cluster-settings \
--cluster blog-cluster \
--settings name=containerInsights,value=enabled追加コスト (小クラスタで ~$1~3/月) はかかりますが本番では必須。
モニタリング表 — 何を見るべきか #
| メトリクス | リソース | 意味 | アラーム閾値 (例) |
|---|---|---|---|
HTTPCode_Target_5XX_Count | ALB | バックエンド 5xx | 5 分合計 ≥ 5 |
HTTPCode_ELB_5XX_Count | ALB | ALB 自体の 5xx (ほぼ healthy host 0) | 5 分合計 ≥ 1 |
TargetResponseTime (p99) | ALB | 応答時間 p99 | 5 分平均 ≥ 1.0s |
UnHealthyHostCount | Target Group | 死んだ task 数 | 5 分平均 ≥ 1 |
CPUUtilization (Service) | ECS | サービス平均 CPU | 5 分平均 ≥ 80% |
MemoryUtilization (Service) | ECS | メモリ | 5 分平均 ≥ 85% |
RunningTaskCount | ECS | 立ち上がっている task 数 | desired と乖離 |
CPUUtilization | RDS | DB CPU | 5 分平均 ≥ 80% |
DatabaseConnections | RDS | 接続数 | max_connections の 80% |
FreeStorageSpace | RDS | 残ディスク | < 5GB |
ReadLatency / WriteLatency | RDS | ディスク遅延 | > 50ms |
Custom Metrics #
アプリから直接出すメトリクス。ログに EMF (Embedded Metric Format) で埋め込めば別途呼び出しなしで。
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) アラーム — 閾値を越えたら人を呼ぶ #
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 正常」 → 真にバックエンドの問題。
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 / OpsGenieSNS → Slack Lambda #
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 が答えます。
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.28sFastAPI/Django の統合 #
pip install aws-xray-sdkfrom 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 として:
{
"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% のみ:
{
"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 か所に:
[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 FreeStorageresource "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 つのシステムに集約される姿をまとめます。