モニタリングとオブザーバビリティ — 見えないものは直せない
深夜2時の恐怖
PagerDutyの通知音がハルトを叩き起こした。
「API全体のレスポンスタイムが急悪化しています」
時刻は深夜2時17分。ハルトはノートPCを開き、ターミナルに向かった。
$ grep "ERROR" /var/log/echo_task/production.log | tail -20
# エラーはない。でも遅い。なぜ?
$ top
# CPU: 30%、メモリ: 45%。問題なさそう。
$ psql -c "SELECT * FROM pg_stat_activity WHERE state = 'active';"
# 接続数も正常範囲。情報が足りない。ログは「何が起きたか」を記録しているが、「なぜ遅いのか」が分からない。 エラーが出ていないのに、ユーザーはタスク一覧の読み込みに5秒かかっていた。
「見えない障害ほど怖いものはない」——ハルトはそう実感した。
モニタリング vs オブザーバビリティ
モニタリングは「既知の問題を検知する」もの。CPU使用率やエラーレートを監視する。 オブザーバビリティは「未知の問題を診断できる状態を作る」もの。任意の問いに答えられるシステム。
| 観点 | モニタリング | オブザーバビリティ |
|---|---|---|
| 目的 | 既知の問題を検知 | 未知の問題を診断 |
| データ | メトリクス・アラート | ログ・トレース・メトリクス |
| 問いへの回答 | 「正常か?」 | 「なぜ?」「どこで?」「誰に?」 |
| 限界 | 想定外の問題は検知できない | 計装コストが高い |
オブザーバビリティの三本柱:
- メトリクス(Metrics) — 数値で状態を把握。RED/USEメソッドで設計する
- ログ(Logs) — イベントの記録。構造化してクエリ可能にする
- トレース(Traces) — リクエストが複数サービスをまたぐ経路を可視化する
構造化ログとリクエストID伝播
テキストログは人間が読めるが、機械的な分析が難しい。JSON形式の構造化ログにし、 さらに**correlation_id(相関ID)**を全サービスに伝播させることで、分散システムでも 1本のリクエストを端から端まで追跡できる。
# config/initializers/logging.rb
module EchoTask
class StructuredLogger < ActiveSupport::Logger
def format_message(severity, timestamp, progname, msg)
payload = {
timestamp: timestamp.iso8601(3),
level: severity,
service: 'echo-task-api',
host: Socket.gethostname,
}
case msg
when Hash then payload.merge!(msg)
when String then payload[:message] = msg
else payload[:message] = msg.to_s
end
# スレッドローカルに保存した相関IDを自動付与
payload[:correlation_id] = Thread.current[:correlation_id] if Thread.current[:correlation_id]
payload[:request_id] = Thread.current[:request_id] if Thread.current[:request_id]
"#{payload.to_json}\n"
end
end
end
Rails.logger = EchoTask::StructuredLogger.new($stdout)# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
before_action :set_correlation_context
private
# X-Correlation-IDヘッダーを受け取るか、新規生成して伝播する
def set_correlation_context
# 上流サービス(APIゲートウェイ等)から渡された場合はそれを使う
correlation_id = request.headers['X-Correlation-ID'] || SecureRandom.uuid
request_id = request.uuid
Thread.current[:correlation_id] = correlation_id
Thread.current[:request_id] = request_id
# レスポンスヘッダーにも返す(クライアントが問い合わせ時に提示できる)
response.headers['X-Correlation-ID'] = correlation_id
response.headers['X-Request-ID'] = request_id
end
around_action :log_request_lifecycle
def log_request_lifecycle
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
ensure
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
Rails.logger.info(
event: 'http_request',
method: request.method,
path: request.path,
status: response.status,
duration_ms: duration_ms,
user_id: current_user&.id,
ip: request.remote_ip,
user_agent: request.user_agent,
)
end
end下流サービスへのHTTPリクエスト時は Faraday ミドルウェアでIDを引き継ぐ。
# app/services/http_client.rb
class HttpClient
def self.connection(base_url)
Faraday.new(base_url) do |f|
f.request :json
f.use :correlation_id_propagator # 下記のカスタムミドルウェア
f.adapter Faraday.default_adapter
end
end
end
# lib/faraday/correlation_id_propagator.rb
class Faraday::CorrelationIdPropagator < Faraday::Middleware
def call(env)
env[:request_headers]['X-Correlation-ID'] = Thread.current[:correlation_id]
env[:request_headers]['X-Request-ID'] = Thread.current[:request_id]
@app.call(env)
end
endINFO
correlation_id の重要性
マイクロサービス環境では、1つのユーザーリクエストが API → 通知サービス → 決済サービス → メールサービスと渡り歩く。
correlation_id を全サービスで伝播しておくと、CloudWatch Logs Insights で
filter correlation_id = "abc-123" と絞り込むだけで全サービスのログを横断検索できる。
Go 1.21+ での構造化ログ(slog)
EchoTaskのバックエンドには通知配信用のGoサービスもある。Go 1.21から標準ライブラリに
log/slog が追加され、外部ライブラリなしで構造化ログが書ける。
// internal/logger/logger.go
package logger
import (
"context"
"log/slog"
"os"
)
type contextKey string
const correlationIDKey contextKey = "correlation_id"
func New() *slog.Logger {
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: true,
}))
}
// WithCorrelationID はコンテキストに相関IDを付与する
func WithCorrelationID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, correlationIDKey, id)
}
// FromContext はコンテキストから相関IDを取り出してロガーを返す
func FromContext(ctx context.Context, base *slog.Logger) *slog.Logger {
if id, ok := ctx.Value(correlationIDKey).(string); ok {
return base.With("correlation_id", id)
}
return base
}// internal/handler/notification.go
package handler
import (
"context"
"log/slog"
"net/http"
"time"
"echo-task/internal/logger"
)
func NotifyHandler(log *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
correlationID := r.Header.Get("X-Correlation-ID")
ctx := logger.WithCorrelationID(r.Context(), correlationID)
l := logger.FromContext(ctx, log)
start := time.Now()
// ... ビジネスロジック
l.InfoContext(ctx, "notification_sent",
slog.String("user_id", r.URL.Query().Get("user_id")),
slog.Duration("duration", time.Since(start)),
slog.Int("status", http.StatusOK),
)
}
}OpenTelemetryによるマルチベンダートレーシング
AWS X-Rayで始めたが、将来的にDatadogやJaegerへ移行したい——そんなときに **OpenTelemetry(OTel)**が力を発揮する。ベンダーに依存しない計装層を提供し、 バックエンドはOTLPプロトコル経由で自由に切り替えられる。
# Gemfile
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
gem 'opentelemetry-instrumentation-rails'
gem 'opentelemetry-instrumentation-active_record'
gem 'opentelemetry-instrumentation-faraday'# config/initializers/opentelemetry.rb
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
require 'opentelemetry/instrumentation/all'
OpenTelemetry::SDK.configure do |c|
c.service_name = 'echo-task-api'
c.service_version = ENV.fetch('APP_VERSION', '0.0.0')
# OTLP経由でAWS X-Ray / Datadog / Jaeger など好きなバックエンドへ
c.add_span_exporter(
OpenTelemetry::Exporter::OTLP::Exporter.new(
endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318/v1/traces')
)
)
# Rails/AR/Faradayを自動計装
c.use_all
endアプリコードでカスタムスパンを追加する:
# app/services/task_service.rb
class TaskService
TRACER = OpenTelemetry.tracer_provider.tracer('task-service', '1.0')
def create_task(params)
TRACER.in_span('TaskService.create_task',
attributes: { 'user.id' => params[:user_id].to_s }) do |span|
task = nil
TRACER.in_span('db.task.insert') do
task = Task.create!(params)
span.set_attribute('task.id', task.id.to_s)
end
TRACER.in_span('cache.invalidate') do
Rails.cache.delete("user:#{params[:user_id]}:tasks")
end
TRACER.in_span('event.publish') do
EventBus.publish('task.created', task.as_json)
end
task
rescue => e
span.record_exception(e)
span.status = OpenTelemetry::Trace::Status.error(e.message)
raise
end
end
endサンプリング戦略
全リクエストをトレースするとコストが跳ね上がる。適切なサンプリング戦略を選ぶ。
| 戦略 | 仕組み | 向いているケース |
|---|---|---|
| ヘッドサンプリング | リクエスト開始時に確率で決める(例: 5%) | 低コスト、均一な統計が欲しい場合 |
| テールサンプリング | 完了後にエラーや遅延を見てから決める | エラーを漏らしたくない場合 |
| レートリミット | 毎秒N件まで保証 | スパイク時も一定量を確保 |
# OTel Collector でのテールサンプリング設定例
processors:
tail_sampling:
decision_wait: 10s # 全スパンが揃うまで待機する時間
num_traces: 50000
policies:
- name: errors-policy
type: status_code
status_code: { status_codes: [ERROR] } # エラーは100%収集
- name: slow-traces-policy
type: latency
latency: { threshold_ms: 1000 } # 1秒超えも100%収集
- name: probabilistic-policy
type: probabilistic
probabilistic: { sampling_percentage: 5 } # それ以外は5%WARNING
ヘッドサンプリングの落とし穴
5%サンプリングでは、低頻度のエラートレースが確率的に捨てられる可能性がある。 本番では「エラーは必ず収集」するテールサンプリングポリシーをエラーポリシーとして追加すること。 OTel CollectorのTail Sampling Processorで設定できる。
Prometheus + Grafana vs CloudWatch
自前でメトリクス基盤を持つ選択肢もある。
| 観点 | Prometheus + Grafana | CloudWatch |
|---|---|---|
| コスト | OSS、インフラ費のみ | メトリクス数・APIコール課金 |
| セットアップ | 自前でサーバー運用が必要 | マネージド、即使える |
| クエリ言語 | PromQL(強力・柔軟) | CloudWatch Metrics Insights |
| アラート | Alertmanager(細かく制御可) | CloudWatch Alarms(シンプル) |
| ダッシュボード | Grafana(豊富なパネル) | CloudWatch Dashboards(基本的) |
| マルチクラウド | どこでも使える | AWS専用 |
| 向き | SRE文化、カスタム可視化が多い | AWS中心、素早く始めたい |
EchoTaskはAWS中心のため CloudWatch から始め、規模が大きくなったら Prometheus へ移行するロードマップを持つ。
USE / RED メソッドによるダッシュボード設計
闇雲にメトリクスを並べてもノイズが増えるだけ。2つのメソッドで「見るべきもの」を絞る。
USEメソッド(インフラ向け):
- Utilization(使用率): CPUが何%使われているか
- Saturation(飽和度): キューの長さ、待ちスレッド数
- Errors(エラー数): デバイスエラー、パケットロス
REDメソッド(サービス向け):
- Rate(レート): 毎秒のリクエスト数
- Errors(エラー率): 失敗したリクエストの割合
- Duration(レイテンシ): 処理にかかった時間(p50/p95/p99)
# CloudFormationによるCloudWatchダッシュボード(REDメソッド準拠)
Dashboard:
Type: AWS::CloudWatch::Dashboard
Properties:
DashboardName: EchoTask-RED-Dashboard
DashboardBody: !Sub |
{
"widgets": [
{
"type": "metric",
"properties": {
"title": "Rate — リクエスト数/分",
"metrics": [
["AWS/ApplicationELB", "RequestCount",
"LoadBalancer", "${LoadBalancer.LoadBalancerFullName}",
{"stat": "Sum", "period": 60}]
],
"view": "timeSeries"
}
},
{
"type": "metric",
"properties": {
"title": "Errors — 5xxエラー率",
"metrics": [
["AWS/ApplicationELB", "HTTPCode_Target_5XX_Count",
"LoadBalancer", "${LoadBalancer.LoadBalancerFullName}",
{"stat": "Sum", "period": 60, "color": "#d62728"}]
]
}
},
{
"type": "metric",
"properties": {
"title": "Duration — レイテンシ p50/p95/p99",
"metrics": [
["AWS/ApplicationELB", "TargetResponseTime",
"LoadBalancer", "${LoadBalancer.LoadBalancerFullName}",
{"stat": "p50", "period": 60, "label": "p50"}],
["...", {"stat": "p95", "period": 60, "label": "p95", "color": "#ff7f0e"}],
["...", {"stat": "p99", "period": 60, "label": "p99", "color": "#d62728"}]
]
}
}
]
}アラート疲れの防止(Alert Fatigue対策)
「アラートが鳴りすぎて誰も見なくなった」——これがAlert Fatigueだ。 対策は優先度の分類とノイズリダクション。
| 優先度 | 条件例 | 対応 | 通知手段 |
|---|---|---|---|
| P1(即時対応) | 5xxエラー率 > 1%、可用性 < 99% | 15分以内に対応 | PagerDuty電話 |
| P2(1時間以内) | p99レイテンシ > 2秒、エラーバジェット50%消費 | 業務時間内に対応 | Slack #incident |
| P3(翌日確認) | キャッシュヒット率低下、ディスク使用率80% | 次のスプリントで対応 | Slackの静かなチャンネル |
# CloudFormation: P1アラームの例(不要なノイズを減らす設定)
HighErrorRateAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: "[P1] EchoTask-HighErrorRate"
MetricName: HTTPCode_Target_5XX_Count
Namespace: AWS/ApplicationELB
Statistic: Sum
Period: 60
EvaluationPeriods: 3 # 3分連続して超えた場合のみ発報(瞬間的なスパイクを無視)
DatapointsToAlarm: 3
Threshold: 10
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching # データなし=問題なしとみなす
AlarmActions:
- !Ref PagerDutySNSTopic
OKActions:
- !Ref PagerDutySNSTopic # 回復通知も送る
P2LatencyAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: "[P2] EchoTask-HighLatency-p99"
MetricName: TargetResponseTime
Namespace: AWS/ApplicationELB
ExtendedStatistic: p99
Period: 300 # 5分単位で評価
EvaluationPeriods: 2 # 10分連続で超えた場合
DatapointsToAlarm: 2
Threshold: 2.0
ComparisonOperator: GreaterThanThreshold
AlarmActions:
- !Ref SlackSNSTopicSLOとエラーバジェット管理
SLO(Service Level Objective)を定め、消費したエラーバジェットを追跡することで 「今どれだけ余裕があるか」を数値で把握できる。
# app/services/error_budget_service.rb
class ErrorBudgetService
# SLO: 月間可用性 99.9%(エラー率 0.1%以下)
AVAILABILITY_SLO = 0.999
# 月の総分数(30日換算)
WINDOW_MINUTES = 30 * 24 * 60
def initialize(cloudwatch_client = Aws::CloudWatch::Client.new)
@cw = cloudwatch_client
end
def current_status
total = fetch_metric('RequestCount', stat: 'Sum')
errors = fetch_metric('HTTPCode_Target_5XX_Count', stat: 'Sum')
success = total - errors
actual_availability = success.to_f / total
error_budget_total = (1 - AVAILABILITY_SLO) * total # 予算の総量
error_budget_spent = errors # 消費量
error_budget_remain = error_budget_total - error_budget_spent
burn_rate = error_budget_spent / error_budget_total
{
slo: "#{(AVAILABILITY_SLO * 100).round(2)}%",
actual_availability: "#{(actual_availability * 100).round(3)}%",
error_budget_total: error_budget_total.round,
error_budget_spent: error_budget_spent.round,
error_budget_remaining: error_budget_remain.round,
burn_rate_percent: "#{(burn_rate * 100).round(1)}%",
status: burn_rate < 0.5 ? :healthy : burn_rate < 0.9 ? :warning : :critical
}
end
private
def fetch_metric(name, stat:)
resp = @cw.get_metric_statistics(
namespace: 'AWS/ApplicationELB',
metric_name: name,
start_time: 30.days.ago,
end_time: Time.current,
period: 30 * 24 * 3600,
statistics: [stat],
dimensions: [{ name: 'LoadBalancer', value: ENV['ALB_FULL_NAME'] }]
)
resp.datapoints.first&.send(stat.downcase.to_sym) || 0
end
end
# 使い方
status = ErrorBudgetService.new.current_status
# =>
# {
# slo: "99.9%",
# actual_availability: "99.92%",
# error_budget_total: 4320, # 許容エラー数(リクエスト数ベース)
# error_budget_spent: 345,
# error_budget_remaining: 3975,
# burn_rate_percent: "8.0%",
# status: :healthy
# }エラーバジェットが50%を超えたらP2アラートで開発チームに通知し、 90%を超えたらフィーチャーリリースを一時停止してリライアビリティ改善に集中する。
CloudWatch Logs Insightsでの分析
構造化ログがあれば、CloudWatch Logs InsightsのSQLライクなクエリで素早く分析できる。
-- 遅いAPIエンドポイントを探す(1000ms以上)
fields @timestamp, method, path, duration_ms, user_id, correlation_id
| filter duration_ms > 1000
| sort duration_ms desc
| limit 20
-- エラーを相関IDでグループ化し、根本原因を特定する
fields @timestamp, event, message, correlation_id, path
| filter level = "ERROR"
| stats count() as error_count by message, path
| sort error_count desc
-- 特定リクエストをcorrelation_idで全サービス横断追跡
fields @timestamp, service, event, message, duration_ms
| filter correlation_id = "abc-123-def-456"
| sort @timestamp ascインシデント対応フロー
システムが壊れたとき、パニックにならないためのフローを定めておく。
ポストモーテムは「犯人探し」ではなく「システム改善」のためのもの。 ハルトのチームでは「5 Whys」で根本原因を掘り下げ、再発防止策をチケット化するルールを設けた。
EchoTaskの改善結果
Before: テキストログ + 手動確認
障害検知: 5〜15分(ユーザー報告頼み)
根本原因特定: 30分〜2時間
MTTR: 120分
After: OTel + CloudWatch + 構造化ログ + correlation_id
障害検知: 1〜2分(P1アラームで自動通知)
根本原因特定: 5〜10分(Logs Insightsで即検索)
MTTR: 15分
副次効果:
- エラーバジェットが可視化され、リリース判断が客観的に
- Alert Fatigue解消(P1アラームの誤報率: 40% → 5%)
- correlation_idでマイクロサービス間の追跡が容易に
深夜2時のあの障害は「Notification Serviceのバッチ処理がDBコネクションプールを使い果たし、 APIサーバーへの応答が詰まっていた」ことが、後にX-Rayのトレースで判明した。 構造化ログとトレースがなければ、あのまま原因不明で終わっていただろう。
「見えないものは直せない。でも、見えるようにすることはできる」
ハルトはそう確信した。次は、EchoTaskへの不正アクセスと戦う番だ。
INFO
この章のキーポイント
- オブザーバビリティの三本柱はメトリクス・ログ・トレース
- correlation_idを全サービスで伝播してリクエストを横断追跡する
- OpenTelemetryでベンダーロックインを避けた計装層を作る
- テールサンプリングでエラーを漏らさずコストを抑える
- REDメソッド(Rate / Errors / Duration)でダッシュボードを設計する
- エラーバジェットで「どれだけリリースできるか」を数値で管理する
- Alert FatigueはP1/P2/P3の分類とEvaluationPeriodsで防ぐ