ブログに戻る

本番環境のための Go (Golang) ミドルウェアのベストプラクティス

本番環境向けの Go ミドルウェアのベストプラクティス。panic が 200 としてログに残るのを防ぐ順序、Unwrap を備えた ResponseWriter のラッパー、リクエスト ID、タイムアウト、認証を解説します。

本番環境のための Go (Golang) ミドルウェアのベストプラクティス

Go のミドルウェアは、http.Handler を受け取って新しい http.Handler を返す関数です。シグネチャは func(http.Handler) http.Handler です。返されたハンドラーは、ラップしたハンドラーを呼ぶ前か後に自分の処理を行います。パターンはこのシグネチャがすべてです。フレームワークは必要ありません。Go 1.22 からは http.ServeMux がメソッドとパスのワイルドカードでマッチするので、サードパーティのルーターも不要です(Go 1.22 リリースノート)。

ミドルウェア自体は数行で書けます。本番環境でのベストプラクティスは、その周辺の細部にあります。スタックの順序、ストリーミングを壊さない ResponseWriter のラッパー、失敗を正しく報告する panic からの復旧、衝突しない context の値です。このガイドでは、注文と決済の API のミドルウェアスタックを 1 層ずつ組み立てます。どのスニペットも Go 1.27 でコンパイルでき、テストは go test -race で通ります。

要約

  • ミドルウェアは func(http.Handler) http.Handler として書き、10 行ほどのヘルパーでチェーンします。グローバルなミドルウェアは mux をラップします。ルートごとのミドルウェアは、登録するハンドラーをラップします。
  • 順序は重要です。リクエスト ID とログは panic のリカバリーより外側に置きます。そうすれば、ログの行にはリカバリーが書き込んだ 500 が記録され、リクエスト ID も付きます。
  • ResponseWriter のラッパーには Unwrap() http.ResponseWriter メソッドが必要です。これがないと、http.ResponseController はミドルウェア越しにフラッシュしたりデッドラインを設定したりできません。
  • リカバリーでは http.ErrAbortHandler を再度 panic させる必要があります。また、ハンドラーがすでにステータスを送信していた場合は、500 を書き込まずに中断しなければなりません。
  • サーバーには ReadHeaderTimeout を設定し、ルートごとに context のデッドラインを与え、ボディには http.MaxBytesReader で上限を設けます。
  • 認証ミドルウェアは principal を context に入れます。呼び出し元が不明なら 401 を、権限が足りなければ 403 を返します。
  • レート制限のキーには、左端の X-Forwarded-For の値ではなく、自分のプロキシが追加した IP を使います。

Go の HTTP ミドルウェアはどんな形をしているのか?

ミドルウェアは次のハンドラーを受け取り、それを呼び出すハンドラーを返します。型に名前を付けると、チェーンが読みやすくなります。

example.gogo
type Middleware func(http.Handler) http.Handler

// Chain wraps h so that the first middleware in the list runs first.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

Chain はリストを逆順に適用するので、最初の要素が最も外側になり、リクエストを最初に受け取ります。

各ミドルウェアの中では、http.HandlerFunc がクロージャをハンドラーに変換します。この変換ができるのは、HandlerFunc が ServeHTTP メソッドを持つ関数型だからです。これについてはクロージャと合わせて Go の func キーワードで解説しています。Middleware 型自体は、http.Handler を受け取って http.Handler を返すだけです。http.Handler のような 1 メソッドの interface がなぜこれほどうまく組み合わさるのかは、Go の interface キーワードで説明しています。

http.ServeMux でのグローバルなミドルウェアとルートごとのミドルウェア

ServeMux には Use メソッドがありません。すべてのリクエストに必要なミドルウェアは mux 全体をラップし、それ以外は個々のハンドラーをラップします。

example.gogo
func newServer(logger *slog.Logger, tokens TokenVerifier, limiter *RateLimiter) (*http.Server, error) {
	auth := requireAuth(tokens)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders", listOrders)
	mux.Handle("GET /orders/{id}", auth(http.HandlerFunc(getOrder)))
	mux.Handle("POST /payments", Chain(http.HandlerFunc(createPayment),
		auth,
		requireScope("payments:write"),
		withDeadline(5*time.Second),
	))

	csrf := http.NewCrossOriginProtection()
	if err := csrf.AddTrustedOrigin("https://admin.example.com"); err != nil {
		return nil, err
	}

	handler := Chain(mux,
		requestID,
		logRequests(logger),
		recoverPanics(logger),
		csrf.Handler,
		limiter.Middleware,
		limitBody(1<<20),
	)

	return &http.Server{
		Addr:              ":8080",
		Handler:           handler,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       15 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       120 * time.Second,
	}, nil
}

公開の注文一覧は認証を省略し、1 件の注文を取得するには有効なトークンが必要です。決済の作成にはさらにスコープが必要で、5 秒の時間予算が与えられます。ルートごとのチェーンはルート登録の一部なので、誰が何を呼べるのかを 1 画面分のコードで把握できます。

この配線は 1 か所にまとめます。たとえば cmd/orders/server.go や internal/httpapi パッケージです。より大きな構成の中でそのパッケージをどこに置くかは、Go のプロジェクト構成で解説しています。

Go のミドルウェアはどの順番で実行すべきか?

チェーンの最初のミドルウェアは、その後ろにあるすべてをラップします。リクエストを最初に受け取り、レスポンスを最後に受け取ります。外側の層からは内側の層の動きが見えますが、内側の層からは外側で起きることが見えません。HTTP API の本番環境に適した順序は次のとおりです。

#ミドルウェアここに置く理由
1リクエスト IDログの行や panic のレポートを含め、後に続くすべての層が ID を読めます。
2アクセスログリカバリーをラップしているので、リカバリーが書き込んだステータスを記録できます。
3panic のリカバリー内側のすべての層とハンドラーで起きた panic を捕まえ、500 に変えます。
4クロスオリジン保護クロスサイトのブラウザからの書き込みを、処理コストがかかる前に拒否します。
5クライアント IP ごとのレート制限認証がトークンの検索にコストをかける前に、悪質なトラフィックを落とします。
6ボディサイズの制限どのハンドラーが読むよりも前に、すべてのリクエストボディに上限を設けます。
7認証とスコープ(ルートごと)呼び出し元の確認が必要なルートだけが、その検証コストを負担します。
8デッドライン(ルートごと)各ルートに、処理内容に見合った時間予算を与えます。

多くのガイドは、すべての panic を捕まえられるようにリカバリーを最初に置きます。次のログは、panic する同じハンドラーを両方の順序で実行したときのものです。

example.texttext
# requestID → logRequests → recoverPanics
level=ERROR msg="handler panic" panic="assignment to entry in nil map" request_id=U5UTAL72SXLQQQPQYWOHHEJVUO
level=INFO msg=request method=GET path=/orders/ord_9 status=500 bytes=22 request_id=U5UTAL72SXLQQQPQYWOHHEJVUO

# recoverPanics → requestID → logRequests
level=INFO msg=request method=GET path=/orders/ord_9 status=200 bytes=0 request_id=NBJGZZOPM4YXB2FT3PS66BJE7K
level=ERROR msg="handler panic" panic="assignment to entry in nil map"

どちらの場合も、クライアントは 500 を受け取ります。リカバリーを最も外側に置くと、アクセスログには 200 と記録され、panic の行にはリクエストと結び付けるためのリクエスト ID がありません。内側のリカバリーで守られないのはリクエスト ID とログのミドルウェアだけで、どちらも小さなコードです。さらに net/http のサーバーは、ハンドラーから漏れた panic を復旧してログに出力するので、プロセスがクラッシュすることはありません(net/http の Handler のドキュメント)。

Flush を壊さずに http.ResponseWriter をラップする

ログにはステータスコードとバイト数が必要ですが、http.ResponseWriter はどちらも公開していません。よくある解決策は、それらを記録するラッパーです。

example.gogo
type statusWriter struct {
	http.ResponseWriter
	status int
	bytes  int
}

func (w *statusWriter) WriteHeader(code int) {
	if w.status == 0 {
		w.status = code
	}
	w.ResponseWriter.WriteHeader(code)
}

func (w *statusWriter) Write(b []byte) (int, error) {
	if w.status == 0 {
		w.status = http.StatusOK
	}
	n, err := w.ResponseWriter.Write(b)
	w.bytes += n
	return n, err
}

// Unwrap lets http.ResponseController reach Flush, Hijack and deadlines.
func (w *statusWriter) Unwrap() http.ResponseWriter {
	return w.ResponseWriter
}

http.ResponseWriter を埋め込んでも、昇格するのはその interface の 3 つのメソッドだけです。このルールは Go の struct キーワードの埋め込みの節で解説しています。その下にある本物の writer は http.Flusher と http.Hijacker も実装していますが、ラッパーは実装していません。そのため、w.(http.Flusher) で型アサーションしている server-sent events のハンドラーは、ログのミドルウェアでラップした途端にストリーミングできなくなります。

これは Unwrap を追加すれば解決します。Go 1.20 から、http.ResponseController はこのメソッドを呼んで元の writer を見つけます。ハンドラーは型アサーションの代わりに http.NewResponseController(w).Flush() を呼びます。Unwrap がないと、この呼び出しは http.ErrNotSupported にマッチするエラーを返し、このガイドの後半にあるテストは失敗します。

同じメソッドのおかげで、リクエストごとの書き込みデッドラインも機能し続けます。CSV のエクスポートは、サーバーの 30 秒の WriteTimeout より長くかかることがあります。ドキュメントによると、WriteTimeout では「ハンドラーがリクエストごとに判断する」ことはできません(http.Server)。ResponseController ならそれができます。

example.gogo
func exportOrders(w http.ResponseWriter, r *http.Request) {
	rc := http.NewResponseController(w)
	if err := rc.SetWriteDeadline(time.Now().Add(5 * time.Minute)); err != nil {
		slog.WarnContext(r.Context(), "cannot extend write deadline", "error", err)
	}
	w.Header().Set("Content-Type", "text/csv")
	// stream rows from the database, flushing every few hundred rows
}

Go のミドルウェアで panic から復旧するには?

リカバリーミドルウェアは recover を defer し、スタックをログに出力して 500 を書き込みます。本番用には、さらに 2 つの細部が必要です。

example.gogo
func recoverPanics(logger *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			sw := &statusWriter{ResponseWriter: w}
			defer func() {
				v := recover()
				if v == nil {
					return
				}
				if v == http.ErrAbortHandler {
					panic(v)
				}
				logger.ErrorContext(r.Context(), "handler panic",
					"panic", v, "stack", string(debug.Stack()))
				if sw.status != 0 {
					// The status line is already on the wire. Abort the
					// connection so the client doesn't take a cut-off
					// body for a complete 200.
					panic(http.ErrAbortHandler)
				}
				http.Error(w, "internal server error", http.StatusInternalServerError)
			}()
			next.ServeHTTP(sw, r)
		})
	}
}

1 つ目は http.ErrAbortHandler です。これはレスポンスを中断するために意図的に panic させるときに使うセンチネル値で、httputil.ReverseProxy はバックエンドがコピーの途中で失敗したときにこれを使います。ドキュメントには「ErrAbortHandler で panic すると、サーバーのエラーログへのスタックトレースの出力も抑制される」とあります(net/http)。ミドルウェアがこれを握りつぶすと、日常的なプロキシの中断がスタックトレース付きのエラーログになり、すでに半分送信されたレスポンスに 500 を書き込もうとしてしまいます。

2 つ目は sw.status != 0 のチェックです。ハンドラーがステータスを書き込んだ後では、http.Error を呼んでもステータスは変えられません。net/http は「superfluous response.WriteHeader call」とログに出力し、ストリーミング中だったボディの後ろにエラーテキストを付け足します。途中で途切れて internal server error で終わる JSON 配列が、それでも 200 として届いてしまいます。ErrAbortHandler で再度 panic させると、サーバーはコネクションを閉じるか HTTP/2 ストリームをリセットします。そのため、クライアントには壊れた成功ではなく、失敗したリクエストとして見えます。

リクエスト ID などの値を context で渡すには?

context.WithValue は、リクエストスコープのデータをミドルウェアからハンドラーへ運びます。ドキュメントには、キーは「context を使うパッケージ間の衝突を避けるため、string やその他の組み込み型にすべきではない」とあります(context)。エクスポートされない空の struct 型なら、ほかのパッケージからは名前を指定できないので、何とも衝突しません。

example.gogo
type requestIDKey struct{}

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := rand.Text()
		w.Header().Set("X-Request-ID", id)
		ctx := context.WithValue(r.Context(), requestIDKey{}, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func RequestIDFrom(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey{}).(string)
	return id, ok
}

Go 1.24 で追加された crypto/rand.Text は、26 文字のランダムな base32 文字列を返します。ID としては十分です。このガイドの後半では、同じパターンで認証済みのユーザーを運びます。context の値は、ID や呼び出し元など、リクエストに属するデータだけにします。context のドキュメントは、値を「関数にオプションのパラメータを渡す」ために使わないよう警告しています。データベースのハンドルや feature flag のクライアントは、ハンドラーの struct のフィールドに置きます。

slog による構造化ログ

リクエスト ID は、リクエスト中のすべてのログ行に付いて初めて役に立ちます。呼び出しのたびに ID を渡すのではなく、slog.Handler をラップして、InfoContext などが受け取る context から ID を読むようにします。

example.gogo
type requestIDHandler struct {
	slog.Handler
}

func (h requestIDHandler) Handle(ctx context.Context, rec slog.Record) error {
	if id, ok := RequestIDFrom(ctx); ok {
		rec.AddAttrs(slog.String("request_id", id))
	}
	return h.Handler.Handle(ctx, rec)
}

func (h requestIDHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	return requestIDHandler{h.Handler.WithAttrs(attrs)}
}

func (h requestIDHandler) WithGroup(name string) slog.Handler {
	return requestIDHandler{h.Handler.WithGroup(name)}
}

ロガーは slog.New(requestIDHandler{slog.NewJSONHandler(os.Stdout, nil)}) で作ります。2 つの With メソッドが重要です。埋め込みによって内側のハンドラーの WithAttrs が昇格しますが、それはラッパーを含まない内側のハンドラーを返します。すると最初の logger.With("service", "orders") 以降、すべての行からリクエスト ID が消えてしまいます。

アクセスログのミドルウェアは、先ほどの statusWriter を使い、defer の中でログを出力します。そのため、panic が通過したときでもログの行を書き込みます。

example.gogo
func logRequests(logger *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			sw := &statusWriter{ResponseWriter: w}
			defer func() {
				status := sw.status
				if status == 0 {
					status = http.StatusOK
				}
				logger.InfoContext(r.Context(), "request",
					"method", r.Method,
					"path", r.URL.Path,
					"status", status,
					"bytes", sw.bytes,
					"duration", time.Since(start),
				)
			}()
			next.ServeHTTP(sw, r)
		})
	}
}

何も書き込まないハンドラーには net/http が 200 を返すので、ステータスが 0 のときは 200 として記録します。

Go のミドルウェアでのタイムアウトとボディサイズの制限

サーバーのタイムアウトはコネクションを守り、ミドルウェアは個々のリクエストを守ります。

サーバーには必ず ReadHeaderTimeout を設定します。0 のままだと ReadTimeout の値が使われ、それも 0 なら制限はまったくありません。そうなると、クライアントはコネクションを開いて、好きなだけ時間をかけてヘッダーを 1 バイトずつ送れてしまいます。gosec は、この値の設定漏れを Slowloris 攻撃の可能性として G112 で報告します(gosec rules)。上の newServer 関数のように、ReadTimeout、WriteTimeout、IdleTimeout で、コネクションの残りの寿命に上限を設けます。

リクエスト自体の時間予算には、その context にデッドラインを付けます。

example.gogo
func withDeadline(d time.Duration) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx, cancel := context.WithTimeout(r.Context(), d)
			defer cancel()
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

これが機能するのは、ハンドラーがデータベースや HTTP クライアントなど、ブロックする可能性のあるすべてのものに r.Context() を渡している場合だけです。デッドラインは処理をキャンセルし、何を書き込むかはハンドラーが決めます。

標準ライブラリには、もう 1 つの選択肢として http.TimeoutHandler があります。ハンドラーが時間を超えると 503 を書き込み、ハンドラーのその後の書き込みを http.ErrHandlerTimeout で失敗させます。また、レスポンス全体をバッファし、ドキュメントによると「Hijacker と Flusher の interface をサポートしません」(net/http)。ハンドラーの goroutine はハンドラーが戻るまで動き続けるので、ハンドラーは引き続き context を尊重する必要があります。小さな JSON エンドポイントで、確実に 503 を返せるならバッファのコストを払ってもよい場合は TimeoutHandler を使います。ストリーミングするものには context のデッドラインを使います。

ボディサイズの制限は、小さな JSON オブジェクトを想定したエンドポイントに、クライアントが 1 GB を送りつけるのを防ぎます。

example.gogo
func limitBody(n int64) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			r.Body = http.MaxBytesReader(w, r.Body, n)
			next.ServeHTTP(w, r)
		})
	}
}

MaxBytesReader は、リクエストをその場で拒否するわけではありません。上限を超えて読もうとすると *http.MaxBytesError(Go 1.19)が返り、ハンドラーがそれを 413 に変えます。

example.gogo
func createPayment(w http.ResponseWriter, r *http.Request) {
	var req CreatePayment
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		if tooBig, ok := errors.AsType[*http.MaxBytesError](err); ok {
			msg := fmt.Sprintf("body larger than %d bytes", tooBig.Limit)
			http.Error(w, msg, http.StatusRequestEntityTooLarge)
			return
		}
		http.Error(w, "invalid JSON", http.StatusBadRequest)
		return
	}
	p, _ := PrincipalFrom(r.Context())
	slog.InfoContext(r.Context(), "payment accepted", "user_id", p.UserID, "order_id", req.OrderID)
	w.WriteHeader(http.StatusAccepted)
}

errors.AsType は Go 1.26 で追加されました。それより古いバージョンでは、var tooBig *http.MaxBytesError を宣言して errors.As(err, &tooBig) を呼びます。エラーをメッセージではなく型でマッチすべき理由は、Go でよくある間違いとその避け方で解説しています。

認証ミドルウェアはどう動くべきか?

認証ミドルウェアは呼び出し元を検証し、それが誰なのかを context に保存してから、リクエストを先に渡します。クライアントが直接送ってきた身元情報を信頼することは決してありません。

example.gogo
type Principal struct {
	UserID string
	Scopes []string
}

type TokenVerifier interface {
	Verify(ctx context.Context, token string) (Principal, error)
}

type principalKey struct{}

func PrincipalFrom(ctx context.Context) (Principal, bool) {
	p, ok := ctx.Value(principalKey{}).(Principal)
	return p, ok
}

func requireAuth(verifier TokenVerifier) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
			if !ok || token == "" {
				w.Header().Set("WWW-Authenticate", "Bearer")
				http.Error(w, "missing bearer token", http.StatusUnauthorized)
				return
			}
			p, err := verifier.Verify(r.Context(), token)
			if err != nil {
				w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
				http.Error(w, "invalid token", http.StatusUnauthorized)
				return
			}
			ctx := context.WithValue(r.Context(), principalKey{}, p)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func requireScope(scope string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			p, ok := PrincipalFrom(r.Context())
			if !ok {
				// A wiring bug, not a client error: requireScope ran without requireAuth.
				http.Error(w, "internal server error", http.StatusInternalServerError)
				return
			}
			if !slices.Contains(p.Scopes, scope) {
				http.Error(w, "missing scope "+scope, http.StatusForbidden)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

このコードは 3 つのルールに従っています。401 は「あなたが誰かわからない」という意味で、WWW-Authenticate ヘッダーを伴います。403 は「あなたが誰かはわかっているが、答えはノーだ」という意味です。そして、すべての拒否は return で終わります。エラーを書き込んだ後にそれでも next を呼んでしまうミドルウェアは、認証されていない呼び出し元のために決済ハンドラーを実行してしまうからです。

TokenVerifier は、使う場所で定義した小さな interface です。そのため、テストでは map をもとにしたフェイクを渡し、本番では JWT やセッションストアの実装を渡せます。API ゲートウェイから来る X-User-ID のようなヘッダーが安全なのは、ゲートウェイがサービスへの唯一の経路である場合だけです。サービスに直接到達できるなら、誰でもそのヘッダーを送れます。

Go のミドルウェアでレート制限をかけるには?

golang.org/x/time/rate はトークンバケットを提供します。rate.NewLimiter(10, 20) は毎秒 10 イベントを許可し、最大 20 までのバーストを認めます。Allow は、現在のイベントが枠に収まるかどうかを報告します(x/time/rate)。クライアントごとのリミッターは、キーごとに 1 つのバケットを持ち、アイドル状態のクライアントを忘れます。

example.gogo
type client struct {
	limiter  *rate.Limiter
	lastSeen time.Time
}

type RateLimiter struct {
	mu      sync.Mutex
	clients map[string]*client
	limit   rate.Limit
	burst   int
}

func NewRateLimiter(limit rate.Limit, burst int) *RateLimiter {
	return &RateLimiter{clients: make(map[string]*client), limit: limit, burst: burst}
}

func (rl *RateLimiter) allow(key string) bool {
	rl.mu.Lock()
	defer rl.mu.Unlock()
	c, ok := rl.clients[key]
	if !ok {
		c = &client{limiter: rate.NewLimiter(rl.limit, rl.burst)}
		rl.clients[key] = c
	}
	c.lastSeen = time.Now()
	return c.limiter.Allow()
}

// Cleanup drops clients idle for longer than maxIdle until ctx is done.
func (rl *RateLimiter) Cleanup(ctx context.Context, maxIdle time.Duration) {
	ticker := time.NewTicker(time.Minute)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			rl.mu.Lock()
			for key, c := range rl.clients {
				if time.Since(c.lastSeen) > maxIdle {
					delete(rl.clients, key)
				}
			}
			rl.mu.Unlock()
		}
	}
}

func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ip, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			ip = r.RemoteAddr
		}
		if !rl.allow(ip) {
			w.Header().Set("Retry-After", "1")
			http.Error(w, "too many requests", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

NewRateLimiter で map を作っておく必要があります。そうしないと、最初の allow が nil の map で panic します。Cleanup がなければ、map には接続してきたすべてのアドレスのエントリーが増えていき、決して縮みません。go limiter.Cleanup(ctx, 10*time.Minute) で一度だけ起動し、シャットダウン時に ctx をキャンセルします。

難しいのはキーの選び方です。ロードバランサーの背後では r.RemoteAddr がロードバランサーのアドレスになるので、すべてのクライアントが 1 つのバケットを共有してしまいます。すぐに思いつくのは X-Forwarded-For の最初のアドレスを使う方法ですが、その値はクライアントが自由に決められます。MDN は、サーバーにインターネットから直接到達できる場合、「X-Forwarded-For の IP リストのどの部分も、信頼できるものとも、セキュリティ関連の用途に安全なものとも見なせない」と述べています(MDN)。自分のプロキシが追加したアドレスを、前段にある信頼できるプロキシの数だけ右から数えて取得します。そのうえで、プロキシを迂回してサービスに到達できないようにします。

また、このリミッターは 1 つのプロセスの中だけで動きます。レプリカが 4 つあれば、クライアントは上限の 4 倍を使えます。グローバルに厳密な上限が必要なら、ゲートウェイか、Redis のような共有ストアで制限をかけます。

Go のハンドラーを CSRF から守るには?

Go 1.25 で http.CrossOriginProtection が追加されました。これは Sec-Fetch-Site ヘッダーを確認するか、そのヘッダーがない場合は Origin ヘッダーのホストを Host と比較して、安全でないクロスオリジンのブラウザリクエストを拒否します。GET、HEAD、OPTIONS は常に通します。トークンが不要なので、テンプレートを変更せずに導入できます。cookie で認証するルートには、すべてこれが必要です。ブラウザが bearer トークンを自動で付けることはないので、トークンだけで認証する API は CSRF の影響を受けません。

上の newServer に設定のすべてがあります。http.NewCrossOriginProtection() を作り、別の管理画面のフロントエンドのために AddTrustedOrigin を呼び、グローバルなチェーンに csrf.Handler を入れています。どちらのヘッダーもないリクエストは、同一オリジンまたはブラウザ以外のトラフィックとして扱われて許可されます。サーバー間の呼び出しでは、これが望ましい動作です。

これは CORS ではありません。CORS のヘッダーは、ほかのどのオリジンがレスポンスを読めるかを決めるもので、CrossOriginProtection はそうしたヘッダーを一切設定しません。別のドメインにあるシングルページアプリケーションから API を呼ぶなら、許可するオリジンを明示的に列挙した小さな CORS ミドルウェアが別途必要です。ブラウザはプリフライトの OPTIONS リクエストを認証情報なしで送るので、このミドルウェアはチェーンの先頭近くに置きます。同じオリジンは AddTrustedOrigin にも追加します。app.example.com のフロントエンドが api.example.com を呼ぶと Sec-Fetch-Site: same-site が送られるので、CORS で許可していても CrossOriginProtection がその書き込みを拒否します。

httptest で Go のミドルウェアをテストする

各ミドルウェアは httptest で単独でテストします。next には、実行されたかどうかを記録する小さなハンドラーを使います。staticTokens は、map をもとにした TokenVerifier のフェイクです。

example.gogo
func TestRequireAuthRejectsMissingToken(t *testing.T) {
	called := false
	next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		called = true
	})
	h := requireAuth(staticTokens{})(next)

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/payments", nil))

	res := rec.Result()
	if res.StatusCode != http.StatusUnauthorized {
		t.Fatalf("status = %d, want 401", res.StatusCode)
	}
	if got := res.Header.Get("WWW-Authenticate"); got != "Bearer" {
		t.Errorf("WWW-Authenticate = %q, want Bearer", got)
	}
	if called {
		t.Error("next handler ran for an unauthenticated request")
	}
}

アサーションは rec.Header() ではなく rec.Result() に対して行います。Header() は、ハンドラーが変更するライブの map を返します。ドキュメントも「ハンドラーの完了後に書き込まれたヘッダーをテストする」には Result を使うよう案内しています(httptest)。Result().Header は最初の書き込み時点で取ったスナップショットで、本物のクライアントが受け取るものと一致します。次のミドルウェアには、Result でしか見つからないバグがあります。

example.gogo
// serverTiming sets a header after the handler has written, which is a bug.
func serverTiming(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		w.Header().Set("Server-Timing", fmt.Sprintf("app;dur=%d", time.Since(start).Milliseconds()))
	})
}

rec.Header().Get("Server-Timing") は値を返すので、これを確認するテストは通ります。一方、rec.Result().Header.Get("Server-Timing") は空文字列を返します。ハンドラーがすでにレスポンスを書き込んでいたので、本物のクライアントがこのヘッダーを目にすることはないからです。

このスタックの修正をカバーするテストが、あと 3 つあります。Unwrap メソッドについては、http.NewResponseController(w).Flush() を呼ぶハンドラーを logRequests 経由で実行し、rec.Flushed を確認します。リカバリーの 2 つのケースについては、http.ErrAbortHandler で panic するハンドラーと、ボディの一部を書き込んでから panic するハンドラーをそれぞれラップし、テストの中で recover して、値が http.ErrAbortHandler であることを確認します。slog のラッパーについては、ロガーで .With(...) を呼んだ後もリクエスト ID が出力されることを確かめます。こうしたテストを書くときは、各テストが守っている行を削除して、テストが失敗することを確認します。それでも通るなら、そのテストは修正を検証できていません。

Go のミドルウェアでよくある間違い

間違い何が起きるか対策
ログより外側にリカバリーを置くpanic がリクエスト ID なしで 200 として記録される順序はリクエスト ID、ログ、リカバリー
Unwrap のないラッパーFlush と SetWriteDeadline が ErrNotSupported で失敗するUnwrap() http.ResponseWriter を追加する
http.ErrAbortHandler を握りつぶすプロキシの中断がスタックトレースと壊れた 500 になる再度 panic させる
ステータスの送信後に 500 を書き込む途中で切れたボディが 200 として届くhttp.ErrAbortHandler で中断する
文字列の context キーパッケージ間で衝突する(staticcheck SA1029)エクスポートしない struct のキーと型付きのアクセサー
next.ServeHTTP の後でヘッダーを設定するヘッダーが黙って捨てられるnext を呼ぶ前にヘッダーを設定する
エラーレスポンスの後に return がない保護されたハンドラーが実行されてしまうエラーを書き込んだ直後に return する
ReadHeaderTimeout がない遅いクライアントがコネクションを開いたままにする(gosec G112)すべての http.Server に設定する
左端の X-Forwarded-For をレート制限のキーにするクライアントが自分のバケットを選べてしまうプロキシが追加したアドレスを使う
クリーンアップのないレートリミッターの mapクライアントのアドレスが増えるたびにメモリが増えるticker でアイドルなエントリーを削除する

順序の背後にある原則

順序のルールは、このスタックに限らず幅広く使えます。ある層が観察できるのは自分の内側で起きることだけなので、結果を記録する層は、結果を変える層より外側に置きます。HTTP サーバーなら、アクセスログ、メトリクス、トレースのスパンをリカバリー、認証、各種の制限より外側に置きます。そうすれば、クライアントが実際に受け取ったものを記録できます。gRPC なら、ログ用のインターセプターは、panic をステータスコードに変換するインターセプターより外側に置きます。データベースのトランザクションを包むメトリクスのラッパーは、リトライのループごと包むべきです。そうしないと、データベースが 3 回受け取った呼び出しを 1 回と数えてしまいます。

LevelUpGo で学ぶ

LevelUpGo では、ブラウザ上で本物の Go コードを実行する演習を通じて、こうしたパターンを学べます。HTTP & Networking では、net/http を使ってログ、チェーン、認証のミドルウェアを書きます。Real-World Patterns では、ミドルウェアのパターン、panic からの復旧、チェーンを扱います。Logging with slog では、アクセスログのミドルウェアの背後にある本番用のログ出力をさらに掘り下げます。

よくある質問

Go のミドルウェアとは何ですか?

Go のミドルウェアは、func(http.Handler) http.Handler というシグネチャを持つ関数です。ハンドラーをラップして、その前後でログ、認証、リカバリー、制限などのコードを実行します。標準の http.Handler interface を受け取って返すので、どのパッケージのミドルウェアでも、http.ServeMux を含め http.Handler を受け付けるあらゆるルーターと組み合わせられます。

Go のミドルウェアには、フレームワークや chi のようなルーターが必要ですか?

いいえ。Go 1.22 から、http.ServeMux はメソッドのマッチングと /orders/{id} のようなパスのワイルドカードをサポートしています。そしてミドルウェアは、ハンドラーをラップする関数にすぎません。10 行ほどの Chain ヘルパーで、グローバルなスタックとルートごとのスタックの両方に対応できます。chi のようなルーターはルートグループなどの便利な機能を加えますが、そのミドルウェアも同じシグネチャを使うので、後から乗り換えてもミドルウェアを書き直す必要はありません。

Go のミドルウェアはどの順番で実行すべきですか?

最初にリクエスト ID、次にアクセスログ、その次に panic のリカバリーを実行し、続いてクロスオリジンのチェック、レート制限、ボディサイズの制限といった低コストの拒否処理を実行します。認証とルートごとのデッドラインは最後に置きます。ログはリカバリーより外側に置く必要があります。そうしないと、panic がリクエスト ID なしで 200 のステータスとして記録されます。

Go でミドルウェアからハンドラーに値を渡すにはどうすればよいですか?

type principalKey struct{} のようなエクスポートしないキーの型で context.WithValue を使い、ハンドラーには PrincipalFrom(ctx) (Principal, bool) のような型付きのアクセサーを用意します。キーに文字列を使ってはいけません。2 つのパッケージが同じ文字列を選ぶと、互いの値を上書きしてしまうからです。

Go で HTTP ミドルウェアをテストするにはどうすればよいですか?

スタブのハンドラーをミドルウェアでラップし、httptest.NewRecorder() と httptest.NewRequest で ServeHTTP を呼んでから、rec.Result() に対してアサーションを行います。ステータス、クライアントが受け取るヘッダー、スタブが実行されたかどうかを確認します。リカバリーミドルウェアでは、テストの中で recover して、その値を http.ErrAbortHandler と比較します。

出典

シニアエンジニアのように Go を書く

ブラウザで学べるインタラクティブなレッスン。最初のレッスンは無料です。

無料レッスンを試すまたは無料アカウントを作成