1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

安全なエラーメッセージとログの記録

1
Posted at

Go言語でのエラーハンドリングにおいて、「ユーザーに詳細を見せない」かつ「開発者には詳細を記録する」という切り分けは、セキュリティと保守性の両面で非常に重要です。

攻撃者にスタックトレースやDB構造を教えないための、クリーンな実装パターンを見ていきましょう。

1. 基本的な考え方:情報の分離

Webサーバーにおいて、エラー情報は以下の2つのルートに分ける必要があります。

  • HTTPレスポンス (External): ユーザー向け。不透明で抽象的なメッセージ(例: "Internal Server Error")と、追跡用のリクエストIDのみを返します。
  • アプリケーションログ (Internal): 開発者向け。スタックトレース、エラーの根本原因(ラップされたエラー)、実行時のコンテキスト(User IDなど)を詳細に記録します。

2. Goでの実装パターン

カスタムエラー構造体の定義

まず、ユーザーに返すための「安全なメッセージ」と、ログ用の「詳細なエラー」を保持する仕組みを作ります。

type AppError struct {
    Err     error  // 内部デバッグ用(ログに出力)
    Message string // ユーザー用メッセージ(レスポンスとして返却)
    Code    int    // HTTPステータスコード
}

func (e *AppError) Error() string {
    return e.Err.Error()
}

ミドルウェアでの一括処理

各ハンドラーでログ出力を書くのではなく、ミドルウェアで一括管理するのがクリーンです。

func ErrorHandler(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // コンテキストにリクエストIDを付与(追跡用)
        requestID := uuid.New().String()
        
        // 実際の処理
        // (ここでは簡略化のため、ハンドラーからのエラーをどう受け取るかの概念のみ)
        
        // エラーが発生した場合の処理例:
        if err != nil {
            // 1. ログには詳細を記録(リクエストIDを紐付ける)
            log.Printf("[ID:%s] ERROR: %v", requestID, err)

            // 2. ユーザーには安全なメッセージとIDのみを返す
            w.WriteHeader(http.StatusInternalServerError)
            fmt.Fprintf(w, `{"error": "予期せぬエラーが発生しました", "request_id": "%s"}`, requestID)
        }
    })
}


3. 実務で使えるTips

エラーのラッピング (fmt.Errorf)

Go 1.13以降、%w を使ってエラーをラップすることで、ログにはコールスタックのような文脈を持たせつつ、プログラム内部でエラーの判定が可能になります。

// 悪い例:情報を捨てている
if err != nil {
    return errors.New("database error")
}

// 良い例:文脈を追加してラップする
if err != nil {
    return fmt.Errorf("failed to update user (ID: %d): %w", userID, err)
}

構造化ログの活用 (slog)

標準ライブラリの log/slog を使うと、JSON形式でログを出力でき、後からCloudWatch LogsやDatadogなどで検索しやすくなります。

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Error("database connection failed",
    "err", err,
    "request_id", reqID,
    "user_id", userID,
)


4. チェックリスト

  • 本番環境で debug=true になっていないか?(フレームワークのデフォルト設定に注意)
  • SQL文やスタックトレースがレスポンスに含まれていないか?
  • ログにパスワードやクレカ番号などの個人情報(PII)が含まれていないか?
  • ログとレスポンスを結びつける Request-ID を発行しているか?

「このエラーが発生した際、ユーザーにはどう見え、ログにはどう残るべきか」を常に意識することが、プロフェッショナルなバックエンド開発への第一歩ですね。


Goの標準ライブラリ log/slog を活用し、「内部エラーの詳細はログへ、ユーザーにはリクエストIDのみを返す」 という実践的なミドルウェアの実装例を紹介します。

この構成にすることで、ユーザーから問い合わせがあった際に「リクエストID」を教えてもらうだけで、開発者はログから即座に原因(スタックトレースやSQLエラーなど)を特定できるようになります。

実践的なエラーハンドリングの実装

1. エラー情報の分離(構造体とインターフェース)

まず、ハンドラーから「ユーザー向けメッセージ」を吸い上げるための仕組みを作ります。

package main

import (
	"errors"
	"log/slog"
	"net/http"
	"os"

	"github.com/google/uuid"
)

// APIError はユーザーに返す情報を持つカスタムエラー
type APIError struct {
	StatusCode int
	Message    string // ユーザー向け
	Internal   error  // 開発者向け(ログ用)
}

func (e *APIError) Error() string {
	return e.Internal.Error()
}

2. slog を使ったミドルウェアの実装

ここで、レスポンスの書き込みとログの出力を一括管理します。

func ErrorMiddleware(next func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
	// 構造化ロガーの設定(JSON形式)
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	return func(w http.ResponseWriter, r *http.Request) {
		// 追跡用IDの発行
		traceID := uuid.New().String()

		// ハンドラーを実行し、エラーを受け取る
		err := next(w, r)
		if err == nil {
			return
		}

		var apiErr *APIError
		if errors.As(err, &apiErr) {
			// 明示的なAPIエラーの場合
			logger.Error("Application Error",
				"trace_id", traceID,
				"status", apiErr.StatusCode,
				"error", apiErr.Internal.Error(), // 詳細をログへ
				"path", r.URL.Path,
			)
			w.WriteHeader(apiErr.StatusCode)
			// ユーザーには安全なメッセージとIDを返す
			fmt.Fprintf(w, `{"error": "%s", "trace_id": "%s"}`, apiErr.Message, traceID)
		} else {
			// 予期せぬエラー(パニックや未知のエラー)
			logger.Error("Unexpected System Error",
				"trace_id", traceID,
				"error", err.Error(),
			)
			w.WriteHeader(http.StatusInternalServerError)
			fmt.Fprintf(w, `{"error": "予期せぬエラーが発生しました", "trace_id": "%s"}`, traceID)
		}
	}
}

3. ハンドラーでの利用例

ハンドラー内では、具体的なエラー(DBエラーなど)を Internal に隠蔽します。

func GetUserHandler(w http.ResponseWriter, r *http.Request) error {
	// 例えばDBでエラーが発生したとする
	dbErr := errors.New("sql: relation 'users' does not exist") 

	// ユーザーには「取得失敗」だけ伝え、ログには dbErr を残す
	return &APIError{
		StatusCode: http.StatusNotFound,
		Message:    "ユーザーが見つかりませんでした",
		Internal:   dbErr,
	}
}


この実装のメリット

  1. セキュリティ: relation 'users' does not exist のようなテーブル構造が漏洩する情報を、ユーザーは一切見ることができません。
  2. 追跡可能性 (Observability): ユーザーに表示された trace_id をログマネージャー(CloudWatch, Datadog等)で検索するだけで、一発で該当のエラーログに辿り着けます。
  3. 一貫性: JSON形式でログを吐くため、後から解析ツールを通しやすくなります。

また、パニック(Panic)が発生した際にも同様に安全なメッセージを返す「リカバリ・ミドルウェア」 の実装についても臨機応変に対応が必要な場合もあるでしょう。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?