使用 GO 實作簡單的 API Gateway

Posted by Elizabeth Huang on Wed, Apr 8, 2026

簡單實作 API Gateway,依照 config.yaml 將 Request 反向代理給其他服務,並有 Rate limit、JWT 驗證、Log、metrics 等功能。

架構

API Gateway 接收到 Client 發送的 Request 後,經過多個 middleware,然後到達 proxy handler,依照 config.yaml 反向代理至指定的 upstream services。

專案結構:

 1api-gateway/
 2  main.go # 使用責任鏈模式組裝 handler + middlewares,啟動 HTTP server 與 metrics/health endpoints
 3  internal/
 4    handler/
 5	  proxy.go # 反向代理
 6	  health.go
 7	middleware/ # 專案的額外功能都由 middleware 實現,在 Request 進來時及 Response 出去時,實現限流、Log、斷路器和 JWT 驗證等功能
 8	auth/
 9	circuitbreaker/
10	logger/ # 依照 log level 分成 stdout、stderr 輸出
11	metric/ # 自定義四個 metric
12	ratelimit/
13	response/
14	testutil/ # 共用的測試 function
15    upstream/

main

讀取根目錄下的 config.yaml 設定 upstream。例如 order 不需要 authenticate、user 需要等不同的配置。

以及設定 Health check API /healthz 和 Prometheus API /metrics。

最後參考這篇文章實作 Graceful shutdown。
收到 SIGINT 或 SIGTERM 後, /healthz 開始回傳 503,並等待 ALB/K8S 不再傳流量進入。
再來等待 30 秒讓剩下的 request 處理完成。
留最後 3 秒強制結束。

ReverseProxy

1// upstream url
2url := "http://order.example.com"
3proxy := httputil.NewSingleHostReverseProxy(url)
4proxy.ServeHTTP(w, r)

在 config.yaml 中定義 request path 轉發給哪個 upstream,例如:

  • /order/* -> http://order.example.com
  • /user/* -> http://user.example.com

反向代理後,紀錄 upstream request 持續時間,以及 request counter 兩個 metric。

Metric

除 prometheus package 自帶的 Go runtime metric collector 和 process metric collector 外,額外定義四個 metric collector:

  • gateway_requests_total{upstream,operation,uri}:記錄 rate limit 接受(accept)和拒絕(reject)了多少 request。counter
  • http_requests_total{upstream,method,code,uri}:總共收到多少 request。counter
  • http_request_duration_seconds{upstream,uri}:request 持續多少時間,以秒計算。histogram,下邊界 0.1,factor 5,共 6 個 bucket
  • circuit_breaker_open{upstream}:斷路器斷開次數

Middleware

Recovery

為最開始的 middleware,防止其他 middleware 或 proxy panic 導致整個服務掛掉。
recovery 時,logger 會記錄 error,然後 JSON response 500 Internal Server Error。

 1return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 2    defer func() {
 3        if err := recover(); err != nil {
 4            logger := logger.NewLogger()
 5            logger.Error(
 6                "Panic recovered.",
 7                zap.String("err", fmt.Sprintf("%v", err)),
 8            )
 9            helper.JSONResponse(w, http.StatusInternalServerError, map[string]string{}, map[string]string{
10                "msg": "Internal Server Error",
11            })
12        }
13    }()
14
15    m.next.ServeHTTP(w, r)
16})

Upstream

從 request url 解析出對應的 upstream,然後將對應的 config 塞進 request context 中,找不到對應的 upstream 則回傳 404。

 1# upstream/upstream.go
 2type upstreamContextKey struct{}
 3var upstreamCtxKey upstreamContextKey
 4func WithUpstream(ctx context.Context, upstream *Upstream) context.Context {
 5	return context.WithValue(ctx, upstreamCtxKey, upstream)
 6}
 7
 8# internal/middleware/upstream.go
 9type Upstream struct {
10	upstreams map[string]upstream.Upstream
11	baseMiddleware
12}
13
14func upstreamKeyFromRequestPath(requestPath string) string {
15	clean := strings.TrimLeft(path.Clean(requestPath), "./")
16	if clean == "" {
17		return ""
18	}
19	return "/" + strings.SplitN(clean, "/", 2)[0]
20}
21
22func (m *Upstream) Handle() http.Handler {
23	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24		upstreamKey := upstreamKeyFromRequestPath(r.URL.Path)
25		u, ok := m.upstreams[upstreamKey]
26		if ok {
27			r = r.WithContext(upstream.WithUpstream(r.Context(), &u))
28			m.next.ServeHTTP(w, r)
29			return
30		}
31		http.NotFound(w, r)
32	})
33}

CORS

設置 CORS 相關 response header。如果為 preflight request,則回傳 204 No Content。

Rate Limit

因為 API Gateway 可能有突發高峰的情況,所以使用實作 Token bucket 的 time/rate,而非 Leaky bucket。
這邊簡單選擇每 1 個 request 佔用 1 個 token 的方式,沒有依照 bytes 去佔用 token。
根據 config.yaml 設定 token bucket 每秒放入多少 token 以及上限。
rate limit 接受和拒絕都會記錄 metric。
拒絕時回傳 429,header 包含 retry after,表示要等待多久才會有 token 能用。

 1func (m *RateLimit) Handle() http.Handler {
 2	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 3		u := upstream.GetUpstream(r.Context())
 4		reservation := m.getLimiter(u).Reserve()
 5		if !reservation.OK() {
 6			reservation.Cancel()
 7			// 記錄 reject metric
 8			metric.GatewayRequestTotal.WithLabelValues(u.Name, "reject", r.URL.Path).Inc()
 9			// 回傳 429,表示請求數量超過 bucket 上限
10			response.JSONResponse(
11				w,
12				http.StatusTooManyRequests,
13				map[string]string{},
14				map[string]string{
15					"msg": "Request tokens exceed the Limiter's burst size",
16				},
17			)
18			return
19		} else if reservation.Delay() > 0 {
20			reservation.Cancel()
21			// 記錄 reject metric
22			metric.GatewayRequestTotal.WithLabelValues(u.Name, "reject", r.URL.Path).Inc()
23			// 回傳 429,header 包含 retry after,表示要等待多久才會有 token 能用
24			response.JSONResponse(
25				w,
26				http.StatusTooManyRequests,
27				map[string]string{
28					"Retry-After": strconv.Itoa(int(math.Ceil(reservation.Delay().Seconds()))),
29				},
30				map[string]string{
31					"msg": "Too Many Requests",
32				},
33			)
34			return
35		}
36		// 記錄 accept metric
37		metric.GatewayRequestTotal.WithLabelValues(u.Name, "accept", r.URL.Path).Inc()
38		m.next.ServeHTTP(w, r)
39	})
40}

Circuit Breaker

斷路器實作參考 Martin Fowler 的這篇文章
這邊簡單設定 upstream response status code >= 500 超過 5 次時,斷路器開啟 30 秒。30 秒後斷路器半開,新的 request 進來時,進行試探性呼叫 upstream,成功的話斷路器閉合,恢復反向代理。
因為有多個 request,所以失敗次數、最後失敗時間,以及試探性呼叫都利用 atomic 實現 lock。

 1# internal/circuitbreaker/circuit_breaker.go
 2type CircuitBreaker struct {
 3	failureCount     atomic.Uint32
 4	failureThreshold uint32
 5	resetTimeout     time.Duration
 6	lastFailureTime  atomic.Pointer[time.Time]
 7	halfOpenInFlight atomic.Bool
 8}
 9
10func (c *CircuitBreaker) GetState() CircuitBreakerState {
11	if c.failureCount.Load() < c.failureThreshold {
12		return Closed
13	}
14
15	lastFailureTime := c.lastFailureTime.Load()
16	if lastFailureTime != nil && time.Since(*lastFailureTime) > c.resetTimeout {
17		return HalfOpen
18	}
19
20	return Open
21}
22
23func (c *CircuitBreaker) Reset() {
24	c.failureCount.Store(0)
25	c.lastFailureTime.Store(nil)
26	c.halfOpenInFlight.Store(false)
27}
28
29func (c *CircuitBreaker) RecordFailure() {
30	c.failureCount.Add(1)
31	now := time.Now()
32	c.lastFailureTime.Store(&now)
33}
34
35func (c *CircuitBreaker) TrialCallStart() bool {
36	return c.halfOpenInFlight.CompareAndSwap(false, true)
37}
38
39func (c *CircuitBreaker) TrialCallOver() {
40	c.halfOpenInFlight.Store(false)
41}

回到 middleware。
斷路器開啟,或者半開且未取得試探性呼叫的 lock 時,記錄 metric,然後回傳伺服器忙碌的 Response。
斷路器閉合,或半開且成功取得試探性呼叫的 lock 時,允許呼叫 upstream。如果 upstream 回傳 stauts code >=500,記錄失敗次數及時間,否則重置斷路器。
斷路器半開時,會設定 defer function 釋放 lock。

 1# middleware
 2func (m *CircuitBreaker) Handle() http.Handler {
 3	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 4		u := upstream.GetUpstream(r.Context()).Name
 5		circuitBreaker := m.CircuitBreakers[u]
 6		if circuitBreaker == nil {
 7			m.next.ServeHTTP(w, r)
 8			return
 9		}
10
11		state := circuitBreaker.GetState()
12		if state == circuitbreaker.Open {
13			// 記錄斷路器開啟次數
14			metric.CircuitBreakerOpen.WithLabelValues(u).Inc()
15			// 回傳伺服器忙碌的 Response
16			response.JSONResponse(
17				w,
18				http.StatusServiceUnavailable,
19				map[string]string{},
20				map[string]string{"msg": "Server is overloaded. Please try later."},
21			)
22			return
23		}
24
25		// 斷路器半開且未取得試探性呼叫的 lock
26		if state == circuitbreaker.HalfOpen && !circuitBreaker.TrialCallStart() {
27			metric.CircuitBreakerOpen.WithLabelValues(u).Inc()
28			// 回傳伺服器忙碌的 Response
29			response.JSONResponse(
30				w,
31				http.StatusServiceUnavailable,
32				map[string]string{},
33				map[string]string{"msg": "Server is overloaded. Please try later."},
34			)
35			return
36		}
37
38		if state == circuitbreaker.HalfOpen {
39			defer circuitBreaker.TrialCallOver()
40		}
41
42		// 斷路器閉合,或半開且成功取得試探性呼叫的 lock 時,允許呼叫 upstream
43		rw := response.NewResponseWriter(w)
44		m.next.ServeHTTP(rw, r)
45
46		if rw.StatusCode >= http.StatusInternalServerError {
47			// upstream 回傳 status code >= 500 時,斷路器記錄失敗次數及時間
48			circuitBreaker.RecordFailure()
49			return
50		}
51
52		// 重置斷路器
53		circuitBreaker.Reset()
54	})
55}

Log

記錄 request 和 response log,一個 request 對應的 response 會有同一個 trace_id 方便追蹤。

因為沒辦法直接從 http.ResponseWriter 取得 http status code 或 content,所以參考這篇 SO 回答,建立一個 struct embedding http.ResponseWriter,然後把 status code 和 content 設為 export field。

 1import "net/http"
 2
 3type ResponseWriter struct {
 4    http.ResponseWriter
 5    StatusCode int
 6	Body []byte
 7}
 8
 9func (r *ResponseWriter) WriteHeader(code int) {
10    r.StatusCode = code
11    r.ResponseWriter.WriteHeader(code)
12}
13
14func (r *ResponseWriter) Write(b []byte) (int, error) {
15	r.Body = b
16    return r.ResponseWriter.Write(b)
17}
18
19func NewResponseWriter(w http.ResponseWriter) *ResponseWriter {
20	return &ResponseWriter{ResponseWriter: w}
21}

Authenticate

取得 request 的 JWT,然後使用 app key(即 JWT secret)驗證簽名。

根據取得的 claim 決定處理動作:

  1. 解析 JWT 失敗,JWT 缺失、驗證不通過或沒有過期時間,回傳 401
  2. JWT 過期,回傳 403
  3. JWT scope 與 upstream 服務不合,回傳 403
  4. JWT 驗證成功,在 request header 設定 UserId、Email、Scope 然後通過此 middleware,讓 upstream 不用重複解析 JWT
 1func (m *Authenticate) Handle() http.Handler {
 2	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 3		u := upstream.GetUpstream(r.Context())
 4		if u != nil && u.Auth {
 5			userClaim, err := m.getUserClaim(r)
 6			if errors.Is(err, jwt.ErrTokenExpired) {
 7				response.JSONResponse(w, http.StatusForbidden, map[string]string{}, map[string]string{
 8					"msg": "Token expired",
 9				})
10				return
11			} else if err != nil {
12				response.JSONResponse(w, http.StatusUnauthorized, map[string]string{}, map[string]string{
13					"msg": "Unauthorized",
14				})
15				return
16			}
17			if !slices.Contains(strings.Split(userClaim.Scope, ","), u.Name) {
18				response.JSONResponse(w, http.StatusForbidden, map[string]string{}, map[string]string{
19					"msg": "Forbidden",
20				})
21				return
22			}
23			m.setClaimToHeader(userClaim, r)
24		}
25		m.next.ServeHTTP(w, r)
26	})
27}

後記

這算是儘量用標準庫或標準擴展庫寫出來的簡單專案,其中還有不少可以優化的地方,例如 reload config、Rate Limit 用 byte 佔用 token、支援其他網路協定等。
但也學到了以前沒聽過的一些概念,例如限速和斷路器。

最後,完整的 code 可以參考我的 Github repository