slogによるGoの構造化ログ:観測性とアラートのための実践

トレースに接続するクエリ可能なJSONログ

目次

ログは、システムが火災状態(重大な障害)でも使用できるデバッグ用インターフェースです。 問題なのは、プレーンテキストのログは陳腐化しやすいという点にあります。フィルタリング、集計、アラートが必要になった瞬間に、文章の解析作業が始まるのです。

より良いログ記録のためのラップトップとGoのマスコットが配置されたワークスペース

構造化ログこそがその対抗策です。 構造化ログは、各ログ行を安定したフィールドを持つ小さなイベントに変換し、ツールが信頼性を持って検索・集計できるようにします。 ログがより広範なスタックにおけるメトリクス、ダッシュボード、アラートとどのように連携するかについては、Observability: Monitoring, Metrics, Prometheus & Grafana Guideをご参照ください。

構造化ログとは、なぜスケーラブルなのか

構造化ログとは、レコードが単なる文字列ではなく、メッセージと型付きのキー・値属性からなるログ記録方式です。その考え方は最も良い意味で退屈なものです:ログが機械可読になれば、インシデント対応はgrepの競争ではなくなります。

簡単な比較:

プレーンテキスト(人間中心、ツールに不親和)

failed to charge card user=42 amount=19.99 ms=842 err=timeout

構造化ログ(ツール中心、それでも読みやすい)

{"msg":"failed to charge card","user_id":42,"amount":19.99,"duration_ms":842,"error":"timeout"}

本番環境では、ログをプロセスが放出するイベントストリームとして捉え、ルーティングとストレージはアプリケーションの外側にあると考えることが役立ちます。このメンタルモデルは、1行に1つのイベントを書き、イベントを容易に転送・再処理できるようにする方向へ導きます。

共有ログフロントエンドとしてのGoのSlog

Goには古くからlogパッケージがありましたが、モダンなサービスにはレベルとフィールドが必要です。log/slogパッケージ(Go 1.21以降)は標準ライブラリに構造化ログを導入し、ログレコードの共通の形状(時刻、レベル、メッセージ、属性)を形式化しました。 このガイドと併せて、コンパクトな言語およびコマンドの復習については、Go Cheatsheetをご参照ください。

このモデルの主要な構成要素は以下の通りです:

Record(レコード)

レコードとは、発生した事象そのものです。slogの用語では、時刻、レベル、メッセージ、および属性のセットを含みます。InfoやErrorなどのメソッド、あるいはレベルを明示的に指定したい場合はLogメソッドを使用してレコードを作成します。

Attributes(属性)

属性は、ログをクエリ可能にするキー・値ペアです。同じ概念を3つの異なるキー(user, userId, uid)でログ記録すると、3つの異なるデータセットができてしまいます。一貫性のあるキーこそが、真の価値が隠れている場所です。

Handler(ハンドラ)

ハンドラとは、レコードがバイト列になる仕組みです。組み込みのTextHandlerはkey=value形式の出力を行い、JSONHandlerは行区切りJSONを出力します。また、ハンドラは機密情報の抹消(redaction)、キーの名前変更、出力ルーティングが行われる箇所でもあります。

評価が低い機能の一つとして、slogが既存コードのフロントエンドに立てるという点があります。デフォルトのslogロガーを設定すると、トップレベルのslog関数はそれを使用し、従来のlogパッケージもそれへリダイレクトできます。これにより、段階的な移行が可能になります。

Groups(グループ)

グループは、「すべてのサブシステムがidを使用する」という問題を解決します。リクエスト固有の属性(request.method, request.path)をグループ化したり、WithGroupを使用してサブシステム全体に名前空間を割り当ててキーの衝突を防いだりできます。

本番環境向けのslog設定

以下の設定は、一般的な目標を達成しています。 例では小さなlogxパッケージを使用していますが、実際のモジュールでそのようなパッケージがどこに配置されるべきかについては、Go Project Structure: Practices & Patternsをご参照ください。

  • 1行に1つのJSONイベント
  • 収集用にstdoutへログを出力
  • 安定したサービスメタデータを一括して付加
  • リクエストおよびトレースIDのためのコンテキスト対応ログ
  • 機密キーのための集中的な抹消
package logx

import (
	"log/slog"
	"os"
)

var level slog.LevelVar // デフォルトはINFO

func New() *slog.Logger {
	opts := &slog.HandlerOptions{
		Level:     &level, // 実行時に変更可能
		AddSource: true,   // 利用可能な場合、ファイルと行番号を含める
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			// 集中的な抹消:一貫性があり、偶発的に回避されにくい。
			switch a.Key {
			case "password", "token", "authorization", "api_key":
				return slog.String(a.Key, "[redacted]")
			}
			return a
		},
	}

	h := slog.NewJSONHandler(os.Stdout, opts)

	return slog.New(h).With(
		"service", os.Getenv("SERVICE_NAME"),
		"env", os.Getenv("ENV"),
		"version", os.Getenv("VERSION"),
	)
}

func SetLevel(l slog.Level) { level.Set(l) }

大きな影響を及ぼす小さな詳細として、組み込みのJSONハンドラは標準キー(time, level, msg, source)を使用します。ログバックエンドが別のスキーマを期待する場合、ReplaceAttrは呼び出し箇所を書き直すことなくキーを正規化できる圧力解放弁となります。

スキーマはロジャーよりも重要

「構造化ログ」の失敗のほとんどは、スキーマの失敗です。

常に価値を生み続ける必須フィールド

すべてのログバックエンドは、タイムスタンプ、レベル、メッセージを保存します。実際のところ、有用なアプリケーションスキーマは、しばしば以下の小さなセットの安定したフィールドを追加します:

  • service, env, version
  • component(またはsubsystem)
  • event(発生した事象の安定した名前)
  • request_id(リクエストが存在する場合)
  • trace_idおよびspan_id(トレーシングが存在する場合)
  • error(文字列)およびerror_kind(安定したバケット)

パターンに注目してください:これらのフィールドは、開発者の好奇心ではなく、運用上の質問に答えるものです。

セマンティックな規約は安価で一貫性を保つための工夫

すでにOpenTelemetryを使用している場合、そのセマンティックな規約はテレメトリシグナル全体における属性の標準語彙を提供します。OpenTelemetry経由でログをエクスポートしなくても、属性名を借用することで、「サービスBでこのフィールドを何と呼んだっけ?」という税負担を軽減できます。

高基数(High Cardinality)とログがコスト高になる理由

高基数とは「一意の値が多すぎる」ことを意味します。JSONペイロード内であれば問題ありませんが、バックエンドが一部のフィールドをインデックス付きラベルやストリームキーとして扱う場合、痛みを伴うものになります。ユーザーID、IPアドレス、ランダムなリクエストトークン、完全なURLは、組み合わせを爆発的に増加させる傾向があります。

実用的な結果は単純です:ラベルとインデックスキーは退屈なもの(サービス、環境、リージョン)に保ち、高基数のフィールドはクエリ時のフィルタリングのために構造化ペイロード内に保持します。

リクエストIDとトレースによる相関

相関こそが、ログが単なるテキストから抜け出し、テレメトリとして振る舞い始める地点です。

リクエストIDを最小摩擦の相関キーとして

リクエストIDは、incomingリクエストとそれによって発生するすべての出来事との間の最もシンプルな橋渡しです。分散トレーシングがなくても機能し、トレースがサンプリングされていても依然として有用です。リクエストIDやその他のメタデータをコンテキストにどのように格納・取得すべきか、型付きキーパターンやミドルウェアの例を含む完全な概要については、Go context.Context Done Rightをご参照ください。

一般的なパターンとして、リクエストごとにロガーをコンテキストにアタッチします:

package logx

import (
	"context"
	"log/slog"
)

type ctxKey struct{}

func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
	return context.WithValue(ctx, ctxKey{}, l)
}

func FromContext(ctx context.Context) *slog.Logger {
	if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok && l != nil {
		return l
	}
	return slog.Default()
}

W3C Trace ContextおよびOpenTelemetryによるトレース相関

W3C Trace Contextは、トレースIDを伝播するための標準的な方法(HTTPでは、traceparentおよびtracestate経由)を定義しています。OpenTelemetryはこれを拡張し、トレースIDおよびスパンIDをコンテキストから抽出できるようにします。

このミドルウェアの例は、利用可能な場合にrequest_idおよびトレース識別子の両方をログに記録します:

package middleware

import (
	"crypto/rand"
	"encoding/hex"
	"net/http"

	"go.opentelemetry.io/otel/trace"
	"log/slog"

	"example.com/project/logx"
)

func requestID() string {
	var b [16]byte
	_, _ = rand.Read(b[:])
	return hex.EncodeToString(b[:])
}

func WithRequestLogger(base *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			rid := r.Header.Get("X-Request-Id")
			if rid == "" {
				rid = requestID()
			}

			l := base.With(
				"request_id", rid,
				"method", r.Method,
				"path", r.URL.Path,
			)

			if sc := trace.SpanContextFromContext(r.Context()); sc.IsValid() {
				l = l.With(
					"trace_id", sc.TraceID().String(),
					"span_id", sc.SpanID().String(),
				)
			}

			ctx := logx.WithLogger(r.Context(), l)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

相関フィールドが存在すれば、ログ行は他のデータへのインデックスとなります。ライブインシデントにおけるこの違いは、微妙なものではありません。

構造化ログをモニタリングおよびアラートシグナルに変える

ログは「何が起きたか」を答えるのに優れています。アラートは通常、「どのくらいの頻度で、どれほど深刻か」に関するものです。

実用的なアプローチとして、特定のログイベントをカウンターとして扱うことです:

  • event=payment_failed
  • event=db_timeout
  • event=cache_miss

多くのプラットフォームは、ウィンドウ内の一致するレコードをカウントすることで、ログベースのメトリクスを派生させることができます。構造化ログはこのカウントを強靭にします。なぜなら、もろな文字列マッチではなく、フィールド値に基づいているからです。 これらのシグナルを可視化・探索する準備が整ったら、Install and Use Grafana on Ubuntu: Complete Guideは、一般的なログおよびメトリクスバックエンドを対象にできる完全なGrafana設定を解説しています。

ここでもまた、ログレベルの重要性が現れます。デバッグログはしばしば価値がありますが、同時にコストとノイズが隠れている場所でもあります。動的レベル(LevelVar)を使用することで、システムはデフォルトで静かな状態を保ちつつ、必要に応じてターゲットを絞った詳細情報を許可できます。

結び

Goにおける構造化ログは、もはやライブラリの論争ではありません。興味深いのは、ログレコードが一貫性があり、相関可能で、保存コストが抑えられているかどうかです。

ログがevent、request_id、trace_idといった安定したフィールドを保持する場合、それらは「誰かが書いた文字列」から抜け出し、運用可能なデータセットへと変化します。

注記

GoチームはGo 1.21でlog/slogを導入し、構造化ログがキー・値ペアを使用することで、解析、フィルタリング、検索、分析を信頼性高く行えることを強調しました。また、エコシステム全体で共通のフレームワークを提供するという動機についても言及しています。

log/slogパッケージのドキュメントは、レコードモデル(時刻、レベル、メッセージ、キー・値ペア)および組み込みハンドラ(key=value用のTextHandler、行区切りJSON用のJSONHandler)を定義し、従来のlogパッケージとのSetDefault統合を文書化しています。

分散相関については、W3C Trace Context仕様はtraceparentおよびtracestateの伝播を標準化しており、OpenTelemetryはそのSpanContextがW3C Trace Contextに準拠し、TraceIdおよびSpanIdを公開することを規定しています。これにより、スパンが存在する場合、ログとトレースの相関は容易になります。

ログストレージのコストとパフォーマンスについては、Grafana Lokiのドキュメントは、有界で静的なラベルを強く推奨し、高基数ラベルが多くのストリームと巨大なインデックスを作成することを警告しています。これは、何をラベルとし、何をインデックス未設定のJSONフィールドとして残すかの決定において直接的に関連します。

購読する

システム、インフラ、AIエンジニアリングの新記事をお届けします。