0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

監視と可観測性を一から学ぶ② 構造化ログ【図解】

0
Posted at

前回 ① では可観測性の3本柱のうち メトリクス外形監視 を学びました。

前回:監視と可観測性を一から学ぶ① メトリクスと外形監視【図解】

メトリクスは「数値の折れ線」で 異常に“気づく” ための柱でした。でも気づいた後、こう思いますよね。

「エラー率が25%に上がったのは分かった。で、誰の・どのリクエストで・何が起きたの?」

この「なぜ」を1件1件掘るための柱が、今回の ログ です。この記事では「ただ puts するだけ」から一歩進んで、後から機械的に検索・分析できるログ構造化ログを、Rails を例に図解で一から組み立てます。


🌑 おさらい:3本柱の役割分担

 ┌──────────┬──────────────────────────┐
 │ 📊メトリクス │ 数値の折れ線。“異常に気づく”      │
 │ 📜ログ      │ 1件1件の出来事。“なぜ?を掘る” ←今回│
 │ 🧵トレース   │ 処理の流れ全体を追う(次回③)      │
 └──────────┴──────────────────────────┘
   気づく(📊) → 掘る(📜) → 流れを追う(🧵)

たとえ話で言うと:

   📊メトリクス = 車のダッシュボード(速度・水温の“針”)→「今ヤバい」が分かる
   📜ログ       = ドライブレコーダー(何が起きたかの記録)→ 後から“なぜ”を再生

メトリクスで「ヤバい」に気づき、ログで「なぜ」を再生する。役割分担です。


1️⃣ なぜ「構造化」なのか

ログには大きく2つの書き方があります。まずダメな例から。

❌ 非構造化ログ(人間がなんとなく読む用・ベタ書き)
14:30:27 Request succeeded response_time=0.5s for user john from 192.168.1.100

一見読めます。でも「user=john のログだけ抜き出したい」と思った瞬間に地獄が始まります。

  • 正規表現でこねくり回すしかない
  • User / user の表記揺れで壊れる
  • フォーマットを少し変えた瞬間、過去の抽出スクリプトが全部死ぬ

そこで 構造化ログ=「1行1JSON」にします。

 構造化ログ(機械が読む用)
{"time":"14:30:27","event":"request","status":"success",
 "response_time":0.5,"user":"john","ip":"192.168.1.100"}

こうしておくと、「user=john かつ status=error」を フィールド指定で機械検索 できます。

   非構造化 = 日記を文章でダラダラ書く → 「先月の雨の日」を探すのが地獄
   構造化   = 日付・天気・出来事を“表の列”で記録 → 一発で絞り込める

設計の勘所:前回メトリクスで学んだ tags{dependency, status} のような切り口)と、ログの フィールド は同じ発想です。「切り口で絞り込めるようにしておく」のが、メトリクスでもログでも探索性の肝。可観測性の本では「ディメンション(キーの数)とカーディナリティ(値の種類)が高いほど探索が精密になる」と表現されます。


2️⃣ ログはどこへ流れるのか(収集パイプライン)

「JSONで出す」の前に、出したログがどこへ行くのかを押さえます。実はメトリクスとそっくりな旅をします。

   📊メトリクス: アプリ → /metrics → Prometheus(pull) → Grafana(PromQL)
   📜ログ:       アプリ → stdout  → 収集役       → Loki   → Grafana(LogQL)
                                    ▲              ▲        ▲
                                Promtail/fluentd  ログDB   検索言語

図でじっくり:

   ┌─ アプリの Pod ───────────┐
   │ logger                   │
   │   {"time":..,"event":..} │ ← 1行1JSONを
   │        │                 │
   │        ▼ 標準出力(stdout)  │
   └────────┼─────────────────┘
            │ ① 収集役が「コンテナのstdout」を拾う
            ▼
   ┌─ Promtail / fluentd ─┐   ← 各ノードに常駐し、全Podのstdoutを集める“集荷係”
   └────────┼──────────────┘
            │ ② Loki へ転送
            ▼
   ┌─ Loki ──────────────┐   ← ログ版のPrometheus。ログを貯めて検索させるDB
   └────────┼─────────────┘
            │ ③ Grafana が LogQL で問い合わせ
            ▼
   ┌─ Grafana 🖥️ ────────┐   ← {namespace="myapp"} |= "error" で検索
   └──────────────────────┘

用語が増えてきたので整理します。

用語 ざっくり言うと
Loki ログ版のPrometheus。ログを貯めて検索させるDB
Promtail ログ版のscrape役。Podのstdoutを拾って Loki へ送る
fluentd Promtail と同類の“集荷係”(環境によってどちらか)
LogQL ログ版のPromQL。{label} |= "文字列" で絞り込む

ここ超重要:ログは ファイルではなく標準出力(stdout)に出す。Kubernetes の世界では収集役(Promtail/fluentd)が「コンテナの stdout」を拾う仕組みなので、/var/log/myapp/app.log のようにファイルへ書くと収集されず Loki まで届きません。「ログがGrafanaに出ない」の典型原因No.1です。

そしてアプリ側から見ると、ログ収集は 「stdout に垂れ流すだけ(fire-and-forget)」。アプリは Loki の存在すら知りません。

   アプリは「画面(stdout)に出すだけ」。送り先(Loki)を知らない=疎結合
   → 収集基盤を入れ替えても、アプリのコードを書き直さなくていい

これは前回のメトリクス(pull型:アプリは送り先を知らない)と共通の思想です。アプリをインフラ構成から切り離しておくのがポイント。

「立てる」のは誰か:Prometheus と同じく、Loki / Promtail / fluentd / Grafana は監視基盤側に既設であることが多いです(Kubernetes なら共有のobservabilityスタックとして用意済み)。アプリ開発者がやるのは「stdout に 1行1JSON を出す」ところまで。そこから先は基盤に任せます。


3️⃣ JsonLogFormatter:全ログを統一JSONに変える心臓部

ここから実装です。Rails を例にします。

Rails 標準の logger は、こんな人間向けのベタテキストを吐きます。

Rails.logger.info("注文完了")
↓
I, [2026-06-15T14:30:27.123 #1]  INFO -- : 注文完了

これを JSON に化けさせるのが、自作の Formatter です。

# config/initializers/logging.rb
class JsonLogFormatter < ActiveSupport::Logger::SimpleFormatter
  def call(severity, time, _progname, message)
    base = {
      time: time.iso8601(3),                            # 共通フィールド
      level: severity.downcase,
      request_id: Current.request_id,                   # ← 縦串(後述)
      user_id: Current.user_id,
      app_version: ENV.fetch('APP_VERSION', 'unknown'),
    }

    case message
    when Hash                 # ← Hashを渡すと「独立フィールド」に展開!
      base.merge!(message)
    when Exception            # ← 例外を渡すと error 情報を自動展開
      base[:event] = 'exception'
      base[:error_class]   = message.class.name
      base[:error_message] = message.message
      base[:backtrace]     = message.backtrace&.first(10)
    else                      # ← 普通の文字列は message フィールドへ(互換)
      base[:message] = message.to_s
    end

    base.compact!             # nil のフィールドは消す
    "#{base.to_json}\n"       # JSON 1行 + 改行
  end
end

一番賢いのは message の「型」で振る舞いを変える ところ。

   logger.info("注文完了")
     → {"time":..,"level":"info","message":"注文完了"}          (文字列)

   logger.info({event:"order.confirmed", order_id:999})
     → {"time":..,"level":"info","event":"order.confirmed","order_id":999}
        ↑ Hashを渡すと order_id が“独立した検索可能フィールド”になる!

   logger.error(exception)
     → {"time":..,"level":"error","event":"exception",
        "error_class":"Timeout::Error","backtrace":[...]}

開発者は「意味のある Hash を渡すだけ」で構造化フィールドが増やせて、従来の文字列ログも壊れない(互換)。1つの変換器が、文字列・Hash・例外を全部いい感じの JSON にしてくれます。

設計の勘所base.compact! で nil フィールドを消しています。ログイン前のリクエストだと user_id は nil ですが、"user_id":null を毎行出すと Loki 側でノイズ・無駄なインデックスになります。値が無いキーは最初から出さないのがクリーン。


4️⃣ lograge:1リクエスト = ぐちゃ複数行 → 綺麗な1行

手動の logger.info は JSON 化できました。でも HTTP アクセスログ はどうでしょう。Rails 標準は1リクエストで何行も吐きます。

Started POST "/graphql" for 10.0.0.1 at 2026-06-15 14:30:27
Processing by GraphqlController#execute as JSON
  Parameters: {...}
Completed 200 OK in 152ms (Views: 3ms | ActiveRecord: 40ms)
   ↑ 4行バラバラ。検索しづらい・量が膨らむ

これを「意味のある1行JSON」に集約してくれるのが lograge

config.lograge.enabled = true
config.lograge.formatter = Lograge::Formatters::Json.new

# /healthz は秒単位で叩かれてノイズになるのでログから除外
config.lograge.ignore_actions = ['HealthController#show']

# リクエストごとに固定で詰める情報(縦串フィールドもここで足す)
config.lograge.custom_options = lambda do |event|
  {
    event: 'http.request',
    request_id: Current.request_id,
    user_id: Current.user_id,
    duration_ms: event.duration,   # ← 命名をプロジェクトで統一しておく
    app_version: ENV.fetch('APP_VERSION', 'unknown'),
  }.compact
end

結果はこうなります。

{"event":"http.request","method":"POST","path":"/graphql",
 "status":200,"duration_ms":152,"request_id":"abc-123","user_id":42}
   JsonLogFormatter (3️⃣) … “手動で書いたログ” を JSON化
   lograge          (4️⃣) … “HTTPアクセスログ” を1行JSON化
   → 2つで「手動ログ」と「アクセスログ」の両方を、同じフィールド名で揃える

設計の勘所/healthz(ヘルスチェック)は外形監視や Kubernetes の probe が秒単位で叩きます。そのままだとログが healthz で埋まり、本物のエラーがノイズに沈み、Loki のストレージも食います。監視のための叩きが、ログを汚すという現場あるある。ignore_actions で先回りして除外しておきましょう。


5️⃣ 相関ID(request_id):バラバラのログを「1本の糸」で束ねる

ここがログ柱の心臓です。1リクエストは内部で大量のログを撒き散らします。

   ユーザー42が「購入」ボタン → 内部で連鎖的に処理:
   {event:"http.request"}            ← どこの?
   {event:"external.call", ...}       ← 誰の?
   {event:"external.call", ...}       ← 同じ購入の?
   {event:"order.failed", ...}        ← 別の人のかも?
        ↑ 何千リクエストのログが入り乱れる中、
          「user42 のあの購入」だけを拾えない

解決策は 全ログに同じ request_id を埋める こと。

{"request_id":"abc-123","event":"http.request","user_id":42}
{"request_id":"abc-123","event":"external.call","dependency":"unleash"}
{"request_id":"abc-123","event":"external.call","dependency":"payment","duration_ms":3200}
{"request_id":"abc-123","event":"order.failed","error_class":"Timeout"}

Loki で {request_id="abc-123"} と1発検索すれば、1リクエストの物語が時系列で再生されます。

「user42 の購入は、payment が 3.2 秒かかって Timeout で失敗した」と即わかる \(^o^)/

どうやって全ログに同じIDを付ける?

毎回手で request_id: を渡すのは現実的じゃありません。そこで リクエストごとの“黒板” を使います。Rails なら ActiveSupport::CurrentAttributes

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :request_id, :user_id
end
# Rack middleware:リクエスト着信時に request_id を黒板へ書く
class CurrentRequestId
  def initialize(app) = @app = app
  def call(env)
    Current.request_id = env['action_dispatch.request_id']
    @app.call(env)
  end
end
   ┌─ middleware ─────────────────────────────────┐
   │ リクエスト着信時に request_id を Current に保存  │
   └──────────────────────────────────────────────┘
              │ 以降、処理中はどこからでも Current.request_id が読める
              ▼
   JsonLogFormatter が全ログ行に request_id を自動で差し込む(3️⃣で見た通り)

開発者は普通にログを書くだけ。フォーマッタが毎行勝手に縦串を通してくれます。

設計の勘所(一番ハマる所)Current の中身は スレッドローカル(そのスレッド専用の置き場)に保たれます。これは必須です。Web サーバ(Puma など)は複数スレッドが同時に別々のリクエストを処理するため、もし「グローバル変数1個」で持つと——

   スレッド1(user42) ┐
   スレッド2(user99) ├→ 同じ箱を奪い合い → IDが混線💥
   スレッド3(user7)  ┘   user42 のログに user99 の ID が付く

という個人情報の取り違え事故が起きます。CurrentAttributes はリクエスト境界で自動リセットされ、スレッド安全に設計されているので、自前のグローバル変数で代用しないこと。


6️⃣ イベント別ログ:出来事に「名前」を付ける

ログを撒く時は、出来事に 名詞.動詞 の名前を付けると探索性が上がります。

Rails.logger.info(
  event: 'order.confirmed',
  order_id: order.id,
  amount: order.total_price,
)
   order.confirmed / order.failed / order.stock_released
   job.start / job.complete / job.failed
   external.call / http.request
   event を「名詞.動詞」で階層化すると…
     {event=~"order\\..*"}  で「注文系すべて」
     {event="order.failed"} で「失敗だけ」
   → 出来事を“分類”できる=探索性が上がる

ここで前回のメトリクスと相補的になるのが面白いところ。同じ「payment 呼び出し」を:

   📊メトリクス: payment_duration{...} → 「payment全体、p95が3秒に悪化」(気づく)
   📜ログ:       {event:"external.call", dependency:"payment",
                  duration_ms:3200, request_id:"abc-123"} → 「user42のあの1回」(掘る)
   📊メトリクスで「全体で遅い」と気づく
        │ request_id で突合してドリルダウン
        ▼
   📜ログで「user42 のこの注文のこの呼び出しが犯人」と特定

7️⃣ PII・機密マスキング:ログに残してはいけないものを潰す

ログは Loki に保存・検索される=広く見られる。だからこそ、入れてはいけないものがあります。

 もしマスクしないと…
{"event":"login","password":"hunter2","email":"taro@example.com",
 "card_number":"4111-1111-1111-1111","token":"secret-xyz"}
    パスワード・カード番号・個人情報が丸見え=重大インシデント

Rails なら filter_parameters で該当キーを自動マスクできます。

config.filter_parameters += [
  :password, :token, :access_token, :secret,
  :card_number, :cvv, :email, :phone, :authorization, :cookie,
]
 登録したキーは出力時に [FILTERED] 
{"event":"login","password":"[FILTERED]","email":"[FILTERED]",
 "card_number":"[FILTERED]"}

設計の勘所(穴を作らない)filter_parameters が効くのは主に HTTP(コントローラ)経由のログです。非同期ジョブ(Sidekiq など)のログは別経路で、ここを通りません。「HTTP はマスクしたのに、ジョブの引数にカード番号が平文で残っていた」は典型的な事故。ログが生まれる経路すべてでマスクを掛ける必要があります。

なお email のような一見地味な項目も PII(個人を識別できる情報) なので保護対象です。「危険そうな物」だけでなく「個人を特定できる物すべて」を守りましょう。


8️⃣ 設計ガード:壊さない・後で困らないための配慮

最後に、目立たないけど大事な安全装置を3つ。

ガード①:テスト環境ではログ機能を“あえてOFF”

unless Rails.env.test?    # ← test 以外でだけ有効化
  Rails.application.config.log_formatter = JsonLogFormatter.new
  config.lograge.enabled = true
end

アプリは 3つの環境(モード) で動きます。

環境 何の世界か
production(本番) 実ユーザーが使う本物。ここでこそ観測したい
development(開発) 自分のPCで作る世界。人間が読みやすいログがいい
test(テスト) 自動テスト(RSpec 等)が走る世界。静かに素のまま動かしたい
   今 production → testじゃない → ✅ JSONログ ON(本物を観測したい)
   今 test       → testだ!    → 🚫 JSONログ OFF(既存テストを邪魔しない)

ログの“出方”を変えると、ログの形を前提にした既存テストが誤爆することがあります。「観測の追加が、既存テストの破壊を招かない」ように、テスト中だけは素の Rails に保ちます。観測を諦めているのではなく、テスト中だけ静かにしているだけ。本番ではちゃんとONです。

ガード②:app_version で「どのリリースで壊れた?」に答える

app_version: ENV.fetch('APP_VERSION', 'unknown')  # CD でデプロイ時に Git SHA を注入

全ログに**コードの“製造ロット番号”(Git SHA)**を刻んでおくと——

{"event":"order.failed", "app_version":"a1b2c3d"}
{"event":"order.failed", "app_version":"a1b2c3d"}
    失敗が全部「a1b2c3d のリリース」のもの!

「金曜にリリースしたら土曜からエラー急増」のとき、エラーログの app_version を見れば犯人リリースを即特定し、前の版に戻す(ロールバック)判断ができます。食品の製造ロット番号と同じ発想ですね。

ガード③:正直な「未検証」の明記

これは技術というより姿勢の話。「動作確認した」と嘘をつかず、検証できた所とできなかった所を分けて明記する(例:「構文チェックはOK、ただしテストはローカル環境制約で未実行、CI に委ねる」)。レビュアーが正しく判断できるようになります。観測の本質である**「事実を正直に出力する」**は、ログだけでなく PR 報告にも効く話です。


🏆 まとめ

 ┌─ ログ柱・全体マップ ───────────────────────────────────┐
 │ 1️⃣ なぜ構造化   検索できる形=1行1JSON。切り口で絞る        │
 │ 2️⃣ パイプライン  stdout→収集役→Loki→Grafana(LogQL) ※基盤既設│
 │ 3️⃣ Formatter   文字列/Hash/例外 を統一JSONに(心臓)        │
 │ 4️⃣ lograge      1リクエスト=1行のアクセスログ。/healthz除外  │
 │ 5️⃣ 縦串request_id 1リクエストの物語を再生。スレッドローカル必須 │
 │ 6️⃣ イベント別ログ 名詞.動詞 / メトリクスと相補(気づく→掘る) │
 │ 7️⃣ マスキング    PII/機密を[FILTERED]。全経路を塞ぐ          │
 │ 8️⃣ 設計ガード    test無効化 / app_version / 正直な報告       │
 └────────────────────────────────────────────────────────┘

メトリクス(前回)で 「ヤバい」に気づき、ログ(今回)で 「なぜ」を1件まで掘れる ようになりました。残るは最後の柱。

次回 ③ は 🧵 トレース。「1リクエストの中で DB に101回問い合わせてた!」のような N+1 問題を“滝グラフ”で一発で見つける 話、OpenTelemetry と Tempo を扱います。お楽しみに。

前回:監視と可観測性を一から学ぶ① メトリクスと外形監視【図解】

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?