slog を用いた Go の構造化ログ出力:可観測性とアラート機能の実現

トレースと連携可能なクエリ可能な JSON ログ。

目次

ログは、システムが炎上している状況でも使用できるデバッグインターフェースです。 問題となるのは、プレーンテキストのログは古くなりやすいという点です。フィルタリング、集計、アラートが必要になった瞬間、文章をパースし始めることになります。

workplace with laptop and Go mascots for better logging

構造化ログはその解です。 構造化ログは、各ログ行を安定したフィールドを持つ小さなイベントに変換するため、ツールが信頼性の高い検索や集計を行うことを可能にします。 ログがより広範なスタックにおけるメトリクス、ダッシュボード、アラートとどのように連携するかについては、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 を参照してください。

このモデルの主要な部分は以下の通りです。

レコード

レコードは「何が起きたか」を表します。slog の用語では、時刻、レベル、メッセージ、および属性のセットを含みます。レコードは InfoError などのメソッド、またはレベルを明示的に指定したい場合は Log を通じて作成します。

属性

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

ハンドラ

ハンドラは、レコードがバイト列になるための仕組みです。組み込みの TextHandlerkey=value 形式の出力を書き、JSONHandler は行区切り JSON を書き出します。ハンドラはまた、機密情報のマスキング(redaction)、キーの改名、出力のルーティングが行われる場所でもあります。

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

グループ

グループは、「すべてのサブシステムが id を使用する」という問題を解決します。リクエストの属性のセット(request.methodrequest.path)をグループ化したり、WithGroup を使用してサブシステム全体をネームスペース化してキーの衝突を防いだりできます。

本番環境向けの slog セットアップ

以下のセットアップは、通常の目標を達成します。 例では小さな logx パッケージを使用していますが、そのようなパッケージが実際のモジュールで通常どこに置かれるかについては、Go Project Structure: Practices & Patterns を参照してください。

  • 1 行に 1 つの JSON イベント
  • 収集のためにログを stdout に書き出す
  • 安定したサービスメタデータを 1 回だけ付与
  • リクエスト ID とトレース 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 ハンドラは標準のキー(timelevelmsgsource)を使用します。ログバックエンドが異なるスキーマを期待する場合、ReplaceAttr は呼び出し場所を書き直さずにキーを正規化できる圧力解放弁となります。

スキーマはロガーよりも重要

多くの「構造化ログ」の失敗は、スキーマの失敗です。

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

すべてのログバックエンドは、タイムスタンプ、レベル、メッセージを保存します。実務的には、有用なアプリケーションスキーマは、いくつかの安定したフィールドを追加することがよくあります。

  • service, env, version
  • component(またはサブシステム)
  • event(何が起きたかを示す安定した名前)
  • request_id(リクエストが存在する場合)
  • trace_id および span_id(トレーシングが存在する場合)
  • error(文字列)および error_kind(安定したバケット)

パターンの共通点に気づいてください。これらは開発者の好奇心ではなく、運用上の質問に答えるフィールドです。

セマンティックな規約は安価な一貫性のハック

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

高カーディナリティとログが高額になる理由

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

実用的な結論は単純です。ラベルとインデックスキーは地味なもの(service, environment, region)に保ち、高カーディナリティのフィールドは構造化ペイロード内に保持し、クエリ時のフィルタリングに利用してください。

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

相関付けは、ログが単なるテキストではなくなり、テレメトリとして振る舞い始める地点です。

リクエスト ID を最低限の摩擦を持つ相関キーとして

リクエスト ID は、インバウンドリクエストとそれによって発生するすべての出来事をつなぐ最もシンプルな橋渡しです。分散トレーシングがなくても機能し、トレースがサンプリングされていても依然として有用です。

一般的なパターンとして、リクエストごとにロガーをコンテキストに付与します。

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 は、トレースのアイデンティティを伝播するための標準的な方法(HTTP の場合、traceparenttracestate を経由)を定義しています。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 における構造化ログは、もはやライブラリに関する議論ではありません。興味深いのは、ログレコードが一貫しており、相関付け可能であり、保存コストが安いかどうかです。

ログが eventrequest_idtrace_id といった安定したフィールドを持っていると、それらは「誰かが書いた文字列」ではなくなり、運用できるデータセットへと変わります。

備考

Go チームは Go 1.21 で log/slog を導入し、構造化ログはキーバリューペアを使用するため、信頼性のあるパース、フィルタリング、検索、分析が可能であると強調しました。また、エコシステム全体で共有される共通フレームワークを提供する動機についても言及しています。

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

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

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