mybook

モニタリングとオブザーバビリティ — 見えないものは直せない

深夜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使用率やエラーレートを監視する。 オブザーバビリティは「未知の問題を診断できる状態を作る」もの。任意の問いに答えられるシステム。

Loading diagram...
観点モニタリングオブザーバビリティ
目的既知の問題を検知未知の問題を診断
データメトリクス・アラートログ・トレース・メトリクス
問いへの回答「正常か?」「なぜ?」「どこで?」「誰に?」
限界想定外の問題は検知できない計装コストが高い

オブザーバビリティの三本柱:

  1. メトリクス(Metrics) — 数値で状態を把握。RED/USEメソッドで設計する
  2. ログ(Logs) — イベントの記録。構造化してクエリ可能にする
  3. トレース(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
end

INFO

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 + GrafanaCloudWatch
コスト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 SlackSNSTopic

SLOとエラーバジェット管理

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

インシデント対応フロー

システムが壊れたとき、パニックにならないためのフローを定めておく。

Loading diagram...

ポストモーテムは「犯人探し」ではなく「システム改善」のためのもの。 ハルトのチームでは「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で防ぐ