《互联网产品的服务端架构·产品、流量与基础能力》7.2 网关的认证、路由与限流

从「回声」网关用字符串前缀匹配,把 /api/user-admin 导流到用户服务、普通会话触发管理导出的事故写起,说明网关只该做连接终结、认证、路由和限流四件事,并给出 Host 优先级、路径段边界匹配与认证检查的 Go 实现和表驱动测试。

网关先做减法:只保留四件事

「回声」的 API 网关最初只是一层 Nginx 反向代理,后来陆续加上了登录校验、灰度分流、限流和请求改写。半年后配置有 900 多行,没人能完整读懂。大促前,运营要临时封禁一批刷单账号。开发在网关加了一条规则:路径包含 /order 且请求头 X-Risk-Level 为 high 就返回 403。上线后正常用户的订单列表也出现 403,因为客户端重试时把这个头透传了回来。团队又加了一条白名单例外,配置继续膨胀。

事故暴露的不是某条规则写错,而是职责没有边界。网关每多承担一条业务判断,就多一份和业务服务不同步的配置。稳定的网关只做四件事:终结连接、认证、路由、限流。

职责网关应该做网关不应该做
连接终结TLS、HTTP/2、超时、请求体大小上限解析业务 JSON 并改写字段
认证校验会话或服务签名,尽早拒绝查询用户等级、判断是否 VIP
路由按 Host 与前缀选择上游根据订单金额决定走哪条链路
限流按路由、用户、IP 设置配额,返回 429根据库存调整限流阈值

认证方式也应该是有限枚举,而不是任意脚本:

Auth需要的头适用场景
none无公开静态资源、健康检查
sessionX-Session-ID面向登录用户的接口
serviceX-Internal-Signature服务间调用、管理接口

路由规则同样要少而明确:同一 Host 取最长前缀,Host 精确匹配优先于通配,前缀必须在路径段边界结束,配置冲突直接报错。把这四条写进代码,比在文档里写“注意匹配顺序”可靠得多。

失败场景:/api/user-admin 被 /api/user 吃掉

网关有一条 Prefix: /api/user 指向用户服务。管理后台的导出接口是 /api/user-admin/export,团队以为它会命中管理路由,但管理路由的前缀写成了 /api/admin,两者并不匹配。

第一层后果是 404:用户服务的路由也用字符串前缀匹配,/api/user-admin/export 被它的 /api/user 规则接住,但服务里没有这个 handler。第二层更严重:用户服务保留着一个旧版管理导出 handler,路径恰好是 /api/user-admin/export,只校验了会话,没有校验管理员角色;网关的 session 认证通过,普通登录用户于是可以触发全量用户导出。一次前缀误匹配,变成了权限绕过。第三层是排障被带偏:值班同学先查管理服务,发现没有流量;再查用户服务,看到 404 和少量 200 混在一起;日志里没有记录命中的是哪条路由,只能靠猜。

修复分三步:路由匹配改成路径段边界匹配,管理接口全部要求 service 签名,网关日志记录命中的路由 ID 和上游。前两步是代码问题,第三步是可观测性问题,缺一个都会让同类事故再次发生。

用 Host 与边界前缀选路由

Match 只负责选择上游:先按 Host 分组,再在同一组里取最长前缀;Check 只负责认证。两者分开,是因为路由是静态配置,认证是每请求动作,混在一起会让配置和运行时状态互相污染。

// file: internal/edge/gateway.go
// tier: L1
// verify: go test ./internal/edge/

package edge

import (
	"errors"
	"fmt"
	"strings"
)

// AuthMode 是网关对一条路由执行的认证方式。
type AuthMode string

const (
	// AuthNone 只用于公开资源,网关不检查身份。
	AuthNone AuthMode = "none"
	// AuthSession 要求用户会话头,适用于面向登录用户的接口。
	AuthSession AuthMode = "session"
	// AuthService 要求内部签名头,适用于服务间调用。
	AuthService AuthMode = "service"
)

const (
	// HeaderSession 是网关注入或透传的用户会话标识。
	HeaderSession = "X-Session-ID"
	// HeaderInternalSig 是服务间调用的内部签名。
	HeaderInternalSig = "X-Internal-Signature"
)

var (
	// ErrNoRoute 表示没有任何路由能匹配请求。
	ErrNoRoute = errors.New("edge: no route matches request")
	// ErrAmbiguousRoute 表示同一优先级下有多条同长度路由。
	ErrAmbiguousRoute = errors.New("edge: ambiguous route")
	// ErrInvalidRoute 表示路由配置本身不合法。
	ErrInvalidRoute = errors.New("edge: invalid route")
	// ErrMissingSession 表示路由要求会话头但请求没有携带。
	ErrMissingSession = errors.New("edge: route requires session header")
	// ErrMissingSignature 表示路由要求内部签名但请求没有携带。
	ErrMissingSignature = errors.New("edge: route requires internal signature")
)

// Route 是网关路由表的一行。Host 为空表示通配,Priority 上低于任何
// Host 精确匹配的路由。
type Route struct {
	Host     string
	Prefix   string
	Auth     AuthMode
	Upstream string
}

// Match 按 Host 优先级和最长前缀选择路由。Host 精确匹配优先于通配,
// 同一优先级下取最长前缀;前缀必须在路径边界上结束,避免
// /api/user 吃掉 /api/user-admin。
func Match(routes []Route, host, p string) (Route, error) {
	if p == "" || !strings.HasPrefix(p, "/") {
		return Route{}, fmt.Errorf("%w: path %q", ErrInvalidRoute, p)
	}

	var exact, wildcard Route
	exactOK, wildcardOK := false, false
	exactAmbiguous, wildcardAmbiguous := false, false

	for _, rt := range routes {
		if !prefixMatches(rt.Prefix, p) {
			continue
		}
		if err := validateRoute(rt); err != nil {
			return Route{}, err
		}
		if rt.Host != "" {
			if !strings.EqualFold(rt.Host, host) {
				continue
			}
			switch {
			case !exactOK || len(rt.Prefix) > len(exact.Prefix):
				exact, exactOK, exactAmbiguous = rt, true, false
			case len(rt.Prefix) == len(exact.Prefix):
				exactAmbiguous = true
			}
			continue
		}
		switch {
		case !wildcardOK || len(rt.Prefix) > len(wildcard.Prefix):
			wildcard, wildcardOK, wildcardAmbiguous = rt, true, false
		case len(rt.Prefix) == len(wildcard.Prefix):
			wildcardAmbiguous = true
		}
	}

	if exactOK {
		if exactAmbiguous {
			return Route{}, fmt.Errorf("%w: host %q prefix %q", ErrAmbiguousRoute, host, exact.Prefix)
		}
		return exact, nil
	}
	if wildcardOK {
		if wildcardAmbiguous {
			return Route{}, fmt.Errorf("%w: wildcard prefix %q", ErrAmbiguousRoute, wildcard.Prefix)
		}
		return wildcard, nil
	}
	return Route{}, fmt.Errorf("%w: host=%q path=%q", ErrNoRoute, host, p)
}

// prefixMatches 要求前缀在路径段边界结束:/api/user 匹配 /api/user
// 和 /api/user/1,但不匹配 /api/user-admin。
func prefixMatches(prefix, p string) bool {
	if prefix == "" || !strings.HasPrefix(prefix, "/") {
		return false
	}
	if prefix == "/" {
		return true
	}
	if p == prefix {
		return true
	}
	if !strings.HasPrefix(p, prefix) {
		return false
	}
	return prefix[len(prefix)-1] == '/' || p[len(prefix)] == '/'
}

func validateRoute(rt Route) error {
	if rt.Prefix == "" || !strings.HasPrefix(rt.Prefix, "/") {
		return fmt.Errorf("%w: prefix %q", ErrInvalidRoute, rt.Prefix)
	}
	if rt.Upstream == "" {
		return fmt.Errorf("%w: empty upstream for prefix %q", ErrInvalidRoute, rt.Prefix)
	}
	switch rt.Auth {
	case AuthNone, AuthSession, AuthService:
		return nil
	default:
		return fmt.Errorf("%w: unknown auth mode %q", ErrInvalidRoute, rt.Auth)
	}
}

// Check 执行路由声明的认证方式。header 名按大小写不敏感查找,
// 因为不同客户端和代理对头名的规范化并不一致。
func Check(route Route, headers map[string]string) error {
	switch route.Auth {
	case AuthNone:
		return nil
	case AuthSession:
		if strings.TrimSpace(headerValue(headers, HeaderSession)) == "" {
			return ErrMissingSession
		}
		return nil
	case AuthService:
		if strings.TrimSpace(headerValue(headers, HeaderInternalSig)) == "" {
			return ErrMissingSignature
		}
		return nil
	default:
		return fmt.Errorf("%w: unknown auth mode %q", ErrInvalidRoute, route.Auth)
	}
}

func headerValue(headers map[string]string, name string) string {
	for k, v := range headers {
		if strings.EqualFold(k, name) {
			return v
		}
	}
	return ""
}

prefixMatches 是这段代码的核心。它要求前缀在路径段边界结束:/api/user 匹配 /api/user 和 /api/user/1,但不匹配 /api/user-admin。只写 strings.HasPrefix 会省几行代码,却会把管理路径交给用户服务,这正是上一节事故的直接原因。

Host 优先级单独处理,而不是简单比较前缀长度。精确 Host 的路由即使前缀更短,也优先于通配路由;只有没有精确匹配时才使用 Host == "" 的通配项。同一优先级出现两条同长度前缀时,Match 返回 ErrAmbiguousRoute,而不是按切片顺序取第一条:配置错误应该在第一次请求时暴露,而不是在流量上来后表现成随机路由。Check 对头名做大小写不敏感查找,对会话头和内部签名都做 TrimSpace,空白值按缺失处理;认证失败返回可识别的错误,网关可以据此返回 401 或 403。

测试:前缀边界与认证模式

路由测试的重点不是“能匹配”,而是“不该匹配的没有匹配”。认证测试的重点则是每种模式在缺少对应头时的行为。

// file: internal/edge/gateway_test.go
// tier: L1
// verify: go test ./internal/edge/

package edge

import (
	"errors"
	"testing"
)

func testRoutes() []Route {
	return []Route{
		{Host: "api.example.com", Prefix: "/api/user", Auth: AuthSession, Upstream: "user-svc"},
		{Host: "api.example.com", Prefix: "/api/user-admin", Auth: AuthService, Upstream: "admin-svc"},
		{Host: "", Prefix: "/api", Auth: AuthSession, Upstream: "public-svc"},
		{Host: "", Prefix: "/assets", Auth: AuthNone, Upstream: "static-svc"},
	}
}

func TestMatchUsesLongestPrefixOnSameHost(t *testing.T) {
	got, err := Match(testRoutes(), "api.example.com", "/api/user/42")
	if err != nil {
		t.Fatal(err)
	}
	if got.Upstream != "user-svc" {
		t.Fatalf("upstream = %q, want user-svc", got.Upstream)
	}

	got, err = Match(testRoutes(), "api.example.com", "/api/user-admin/export")
	if err != nil {
		t.Fatal(err)
	}
	if got.Upstream != "admin-svc" || got.Auth != AuthService {
		t.Fatalf("route = %+v, want admin-svc with service auth", got)
	}
}

func TestMatchRespectsPrefixBoundary(t *testing.T) {
	routes := []Route{
		{Host: "", Prefix: "/api/user", Auth: AuthNone, Upstream: "user-svc"},
	}
	if _, err := Match(routes, "", "/api/user-admin"); !errors.Is(err, ErrNoRoute) {
		t.Fatalf("error = %v, want ErrNoRoute", err)
	}
	for _, p := range []string{"/api/user", "/api/user/1"} {
		got, err := Match(routes, "", p)
		if err != nil {
			t.Fatalf("Match(%q): %v", p, err)
		}
		if got.Upstream != "user-svc" {
			t.Fatalf("Match(%q) upstream = %q", p, got.Upstream)
		}
	}
}

func TestMatchPrefersExactHostOverLongerWildcard(t *testing.T) {
	routes := []Route{
		{Host: "api.example.com", Prefix: "/api", Auth: AuthNone, Upstream: "host-svc"},
		{Host: "", Prefix: "/api/user/42", Auth: AuthNone, Upstream: "wildcard-svc"},
	}
	got, err := Match(routes, "API.EXAMPLE.COM", "/api/user/42")
	if err != nil {
		t.Fatal(err)
	}
	if got.Upstream != "host-svc" {
		t.Fatalf("exact host lost: %+v", got)
	}

	got, err = Match(routes, "other.example.com", "/api/user/42")
	if err != nil {
		t.Fatal(err)
	}
	if got.Upstream != "wildcard-svc" {
		t.Fatalf("wildcard route = %+v", got)
	}
}

func TestMatchReturnsNoRoute(t *testing.T) {
	if _, err := Match(testRoutes(), "api.example.com", "/health"); !errors.Is(err, ErrNoRoute) {
		t.Fatalf("error = %v, want ErrNoRoute", err)
	}
}

func TestMatchRejectsAmbiguousRoutes(t *testing.T) {
	routes := []Route{
		{Host: "api.example.com", Prefix: "/api/user", Auth: AuthNone, Upstream: "a"},
		{Host: "api.example.com", Prefix: "/api/user", Auth: AuthNone, Upstream: "b"},
	}
	if _, err := Match(routes, "api.example.com", "/api/user/1"); !errors.Is(err, ErrAmbiguousRoute) {
		t.Fatalf("error = %v, want ErrAmbiguousRoute", err)
	}
}

func TestMatchRejectsInvalidMatchedRoute(t *testing.T) {
	routes := []Route{{Host: "", Prefix: "/api", Auth: AuthNone}}
	if _, err := Match(routes, "", "/api/x"); !errors.Is(err, ErrInvalidRoute) {
		t.Fatalf("error = %v, want ErrInvalidRoute", err)
	}
}

func TestCheck(t *testing.T) {
	cases := []struct {
		name    string
		route   Route
		headers map[string]string
		want    error
	}{
		{"none without headers", Route{Auth: AuthNone}, nil, nil},
		{"session present", Route{Auth: AuthSession}, map[string]string{HeaderSession: "s-1"}, nil},
		{"session present lowercase", Route{Auth: AuthSession}, map[string]string{"x-session-id": "s-1"}, nil},
		{"session missing", Route{Auth: AuthSession}, map[string]string{"X-Other": "1"}, ErrMissingSession},
		{"session blank", Route{Auth: AuthSession}, map[string]string{HeaderSession: "  "}, ErrMissingSession},
		{"service present", Route{Auth: AuthService}, map[string]string{HeaderInternalSig: "sig"}, nil},
		{"service missing", Route{Auth: AuthService}, map[string]string{HeaderSession: "s-1"}, ErrMissingSignature},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := Check(tc.route, tc.headers)
			if tc.want == nil && err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if tc.want != nil && !errors.Is(err, tc.want) {
				t.Fatalf("error = %v, want %v", err, tc.want)
			}
		})
	}
}

func TestCheckUnknownAuthMode(t *testing.T) {
	err := Check(Route{Auth: AuthMode("magic")}, nil)
	if !errors.Is(err, ErrInvalidRoute) {
		t.Fatalf("error = %v, want ErrInvalidRoute", err)
	}
}

TestMatchRespectsPrefixBoundary 直接复现了事故路径:只有 /api/user 一条路由时,/api/user-admin 必须返回 ErrNoRoute,而 /api/user 和 /api/user/1 仍然命中。TestMatchPrefersExactHostOverLongerWildcard 验证 Host 优先级高于前缀长度:精确 Host 的 /api 赢过通配的 /api/user/42;换成其他 Host 时,通配路由才生效。

TestCheck 用表驱动覆盖七种组合,包括头名小写和会话值为空白。空白值单独列出,是因为“头存在但值为空”在真实代理链路里很常见,不能当成已认证。测试没有覆盖限流,限流需要共享计数器,属于有状态的运行时组件,不适合放进这个纯函数包。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/edge/ -count=1 -v -run '^(TestMatchRespectsPrefixBoundary|TestMatchPrefersExactHostOverLongerWildcard|TestCheck)$/^(session_missing|service_present)$'

一次本机运行的真实输出如下,耗时随机器变化(0.326s 会随机器和负载变化):

=== RUN   TestMatchRespectsPrefixBoundary
--- PASS: TestMatchRespectsPrefixBoundary (0.00s)
=== RUN   TestMatchPrefersExactHostOverLongerWildcard
--- PASS: TestMatchPrefersExactHostOverLongerWildcard (0.00s)
=== RUN   TestCheck
=== RUN   TestCheck/session_missing
=== RUN   TestCheck/service_present
--- PASS: TestCheck (0.00s)
    --- PASS: TestCheck/session_missing (0.00s)
    --- PASS: TestCheck/service_present (0.00s)
PASS
ok  	webapp/internal/edge	0.326s

小结

网关的价值在于把横切关注点集中到入口,而不是把所有判断都搬到入口。连接终结、认证、路由、限流这四件事足够稳定;业务规则一旦进入网关,就会变成一份无法测试、无法回滚、和业务服务不同步的配置。路由层面,Host 精确匹配优先于通配,同一 Host 取最长前缀,前缀必须在路径段边界结束,冲突配置直接报错。下一节会把视角推到边缘缓存,看热点过期时如何保护源站。

阅读导航:上一节:7.1 静态与动态的分界 · 下一节:7.3 边缘缓存与回源保护 。

常见错误与边界

  • 用 strings.HasPrefix 匹配路由。/api/user 会吃掉 /api/user-admin,管理接口走错服务。
  • 只按前缀长度排序,忽略 Host 优先级。专属规则会被更长的通配规则覆盖。
  • 前缀冲突时取第一条。配置错误表现成随机路由,排障时无法复现。
  • 把业务判断塞进网关。风险等级、库存、VIP 规则会和业务服务不同步。
  • 管理接口只校验会话。普通登录用户可能触发管理操作,形成权限绕过。
  • 认证头只按精确大小写查找,或头存在但值为空仍放行。合法请求会被误拒,空身份会被误收。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查