网关先做减法:只保留四件事
「回声」的 API 网关最初只是一层 Nginx 反向代理,后来陆续加上了登录校验、灰度分流、限流和请求改写。半年后配置有 900 多行,没人能完整读懂。大促前,运营要临时封禁一批刷单账号。开发在网关加了一条规则:路径包含 /order 且请求头 X-Risk-Level 为 high 就返回 403。上线后正常用户的订单列表也出现 403,因为客户端重试时把这个头透传了回来。团队又加了一条白名单例外,配置继续膨胀。
事故暴露的不是某条规则写错,而是职责没有边界。网关每多承担一条业务判断,就多一份和业务服务不同步的配置。稳定的网关只做四件事:终结连接、认证、路由、限流。
| 职责 | 网关应该做 | 网关不应该做 |
|---|---|---|
| 连接终结 | TLS、HTTP/2、超时、请求体大小上限 | 解析业务 JSON 并改写字段 |
| 认证 | 校验会话或服务签名,尽早拒绝 | 查询用户等级、判断是否 VIP |
| 路由 | 按 Host 与前缀选择上游 | 根据订单金额决定走哪条链路 |
| 限流 | 按路由、用户、IP 设置配额,返回 429 | 根据库存调整限流阈值 |
认证方式也应该是有限枚举,而不是任意脚本:
| Auth | 需要的头 | 适用场景 |
|---|---|---|
| none | 无 | 公开静态资源、健康检查 |
| session | X-Session-ID | 面向登录用户的接口 |
| service | X-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 规则会和业务服务不同步。
- 管理接口只校验会话。普通登录用户可能触发管理操作,形成权限绕过。
- 认证头只按精确大小写查找,或头存在但值为空仍放行。合法请求会被误拒,空身份会被误收。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。