47.2 API 网关
一句话
API 网关是「把 API 治理从业务里剥离出来」的那一层——认证、配额、路由、版本、协议转换、开发者门户;它与反向代理的分界线不是技术,而是是否承担一套与业务无关、但每个 API 都必须有的治理职责。
与反向代理的分界
| 能力 | 反向代理(47.1 反向代理) | API 网关 | Service Mesh(东西向) |
|---|---|---|---|
| TLS 终结 / 压缩 / 缓冲 | ✅ 主场 | ✅ | 通常不终结(mTLS 为主) |
| 按 host/path 转发、负载均衡 | ✅ 主场 | ✅ | ✅ |
| 认证 / 鉴权 | 一般只到 mTLS / IP 白名单 | ✅ 主场(API key、OAuth2、JWT、scope) | ✅(服务身份) |
| 配额与计费 | ❌ | ✅ 主场(按 key/租户/路由) | ❌ |
| API 版本与生命周期 | ❌ | ✅(v1/v2 共存、废弃公告) | ❌ |
| 协议转换 | 有限(HTTP↔HTTP) | ✅(REST↔gRPC、WebSocket、SOAP) | 有限 |
| 开发者门户 / 文档 / 沙箱 | ❌ | ✅ | ❌ |
| 东西向服务间通信 | 不适合 | 不适合 | ✅ 主场 |
判断标准只有一条:这一层要做的治理,是不是"每个 API 都要、但都不该写在业务里"? 是 → 网关;只是"转发与 TLS" → 反向代理就够了;只是"服务之间互调" → Service Mesh。
入口的四层/七层选型见 8.9 四层与七层负载均衡;入口与内部流量的边界见 47.5 南北向与东西向流量。
五个核心能力
| 能力 | 解决什么 | 没有会怎样 | 关键设计 |
|---|---|---|---|
| 认证(你是谁) | 统一校验 API key / OAuth2 / JWT / mTLS | 每个服务各写一套,密钥散落各处 | 认证结果向下传递(header/上下文),别让每个服务再解一次 token |
| 授权(你能做什么) | scope/角色/租户隔离 | 认证了但越权,或每个服务各写权限 | 粗粒度在网关(scope、路由级),细粒度在服务(资源级) |
| 配额与限流 | 按 key/租户/路由的用量控制 | 单个客户打爆全站;无法做商业套餐 | 计数键要带业务含义(见 47.4 鉴权与限流应放在哪一层) |
| 路由与版本 | v1/v2 共存、灰度、路径重写 | 版本切换要停服;客户端绑死旧地址 | 路由规则与灰度主场在 47.3 路由与灰度 |
| 可观测与门户 | 按 key 的调用量/错误率、文档、沙箱 | 客户问"为什么 429"答不上来 | 日志/指标必须带 consumer、route、upstream 三个维度 |
🔑 网关的能力清单很容易越加越长,唯一该问的是"这条规则改一次要动几个服务":能在一个地方改、且对所有 API 生效的,才配放在网关。
控制面与数据面
控制面(配置):路由、插件、消费者、证书 ──下发──▶ 数据面(网关实例,多副本)
▲ │
└──────── 运营/CI 声明式提交(GitOps、CRD、Admin API) ◀─┘
| 关注点 | 做法 | 反例 |
|---|---|---|
| 配置变更 | 声明式 + 版本化 + 可回滚;先灰度到 1 个实例 | 手改线上配置,改错全量生效 |
| 数据面扩容 | 无状态,按 QPS 水平扩;配置走共享存储 | 网关把配置存在本地磁盘,扩容后配置不一致 |
| 证书轮换 | 集中在控制面,热更新 | 每台机器手工换证书,漏一台就 502 |
| 消费者数据 | 独立存储,别塞进路由配置 | 10 万 API key 塞进一份 YAML,改一次全量重载 |
插件顺序:错了不会报错,只会错
请求 → ①认证 → ②配额/限流 → ③路由 → ④协议转换/改写 → ⑤日志/指标 → 上游
└─ 响应 ← ⑤′ 日志 ← ④′ 转换 ← ③′ 路由 ← ②′ 计数 ← ①′
| 顺序错误 | 后果 |
|---|---|
| 先路由后认证 | 未认证请求也能探测内部路由(路径枚举);日志里出现"未知消费者"的海量垃圾 |
| 先限流后认证 | 只能按 IP 限(NAT 后连坐),无法按 key/租户限;恶意请求也能消耗配额 |
| 认证后不传递身份 | 下游服务拿不到 consumer,只能再解一次 token 或信任一个可伪造的 header |
| 日志在限流前 | 被拒绝的请求不打日志 → 客户投诉"为什么 429"时无据可查 |
| 转换在限流前 | 不同协议/版本共享配额时口径不一致 |
容量与延迟:多一跳的真实成本
| 指标 | 量级(同机房、成熟实现) | 说明 |
|---|---|---|
| 单实例吞吐 | 1 万–5 万 QPS | 取决于插件数与 payload;带 JWT 验签会明显下降 |
| 额外延迟 | +0.5–3 ms P99 | 每多一个远程插件(如远程鉴权)再加 1 RTT |
| 连接数 | 网关 → 上游要连接池复用 | 不复用则每个请求一次 TCP/TLS 握手 |
算一笔账:入口 10 万 QPS,单实例 2 万 QPS → 至少 5 个实例;按 N+1 冗余配 6 个。若每次请求都做远程鉴权,鉴权服务要扛 10 万 QPS(成本见 47.4 鉴权与限流应放在哪一层);改成 JWT 本地验签后,鉴权服务的压力降到接近 0,只保留密钥轮换。
⚠️ 网关的容量不能只看 QPS:长连接(WebSocket/SSE)会把"并发连接数"变成第一约束。10 万条长连接在 6 个网关上,单实例 1.7 万连接,内存与 fd 才是瓶颈。
失效模式
| 失效 | 触发 | 现象 | 防线 |
|---|---|---|---|
| 网关变单点 | 副本数不足 / 同一可用区 | 网关挂 = 全站 502 | 多副本 + 多 AZ + 健康检查摘除 |
| 配置错误全量生效 | 一次提交影响所有路由 | 全站 404/401 | 配置灰度(先 1 实例)+ 自动回滚 + 语法校验 |
| 插件 bug | 新版本插件、正则写错 | 全站 5xx 或 CPU 打满 | 插件版本与网关版本解耦;危险插件先灰度 |
| 超时级联 | 网关超时 > 上游超时 | 上游还在处理,网关已断开并重试 → 放大 | 网关超时 > 上游超时,重试要有预算(见 35.5 超时、重试与退避) |
| 连接池耗尽 | 上游变慢,连接被占满 | 网关对新请求直接 503 | 池上限 + 快速失败 + 熔断(见 35.6 熔断与降级) |
| 配额存储故障 | 限流用 Redis,Redis 挂了 | 全局限流失效(或被误判为全拒) | 降级策略要显式:宁可放行(保可用)还是全拒(保安全) |
什么时候不该上网关
| 你的情况 | 选择 |
|---|---|
| 只有 1–3 个服务、调用方是自己人 | 反向代理 + 服务内鉴权(47.1 反向代理) |
| 纯内部服务互调,没有外部 API | Service Mesh(mTLS + 流量治理) |
| 需要"给不同客户看不同 API 组合" | BFF(按端聚合)而不是网关 |
| 只是要统一加 header / 改路径 | 反向代理或 sidecar,不值得多一跳 |
| 网关里开始写业务逻辑(聚合、计算) | 停手:这是 ESB 复活,复杂度会指数上升 |
APISIX 对照:一条路由的治理链
# APISIX 对照配置(未在本机验证;字段以 APISIX 3.x 为准)
routes:
- name: order-api
uri: /api/order/*
plugins:
jwt-auth: {} # ① 认证:校验签名与过期,注入 consumer
limit-count: # ② 配额:按消费者计,60 秒 1000 次
count: 1000
time_window: 60
key_type: var
key: consumer_name
rejected_code: 429
proxy-rewrite: # ③ 路由/改写:去掉 /api 前缀
regex_uri: ["^/api/(.*)", "/$1"]
upstream:
type: roundrobin
nodes:
"order:8080": 1
这份 APISIX 配置未在本机验证,只用于对照插件字段;本节可验证的落地是下面的 Envoy 配置。
三条能直接对照上文的细节:jwt-auth 在 limit-count 之前(先认人再计数);key: consumer_name 用的是认证注入的变量(不是 IP);rejected_code: 429 而不是 500(见 35.7 限流 (漏桶 / 令牌桶 / 滑动窗口 / 自适应) 里"拒绝也要有礼貌")。
具体用什么:五个中间件,一份 Envoy 配置
“API 网关”不是自己写一个:生产上就是下面几类中间件,差别在控制面形态、插件生态、部署成本。
| 中间件 | 形态 | 认证 / 配额插件 | 配置入口 | 适合 |
|---|---|---|---|---|
| Apache APISIX | etcd 或 standalone(apisix.yaml) | jwt-auth、key-auth、limit-count、limit-req、traffic-split | Admin API / 声明式文件 / Ingress | 国内生态、插件多、需要动态路由 |
| Kong | DB-less(kong.yml)或 Postgres | key-auth、jwt、rate-limiting、request-transformer、prometheus | deck / Admin API / Kong Ingress | 插件生态成熟、团队熟悉 Lua |
| Envoy Gateway | K8s Gateway API + Envoy 数据面 | SecurityPolicy、RateLimitPolicy、BackendTrafficPolicy | CRD(GitOps) | 已在 K8s、想要声明式与多租户 |
| Higress | Istio + Envoy | Wasm 插件、认证/限流策略 | Ingress / Gateway API / 控制台 | 阿里生态、需要 Wasm 扩展 |
| 云 API 网关 | 托管 | 内置 Key/配额/WAF/日志 | 控制台 / Terraform | 不想自建控制面与高可用 |
Envoy 是这些网关共同的数据面。下面是它最小可用的一份“认证 + 单实例限流 + 路由”配置,envoy --mode validate 校验通过、实跑无 token 返回 401:
static_resources:
listeners:
- name: ingress
address:
socket_address: { address: 0.0.0.0, port_value: 10000 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
route_config:
name: api_routes
virtual_hosts:
- name: api
domains: ["api.example.com"]
routes:
- match: { prefix: "/api/order" }
route: { cluster: order_api, timeout: 2s }
http_filters:
- name: envoy.filters.http.jwt_authn
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
providers:
idp:
issuer: "https://issuer.example.com"
audiences: ["order-api"]
remote_jwks:
http_uri:
uri: "https://issuer.example.com/.well-known/jwks.json"
cluster: jwks
timeout: 1s
cache_duration: { seconds: 300 }
rules:
- match: { prefix: "/api/order" }
requires: { provider_name: idp }
- name: envoy.filters.http.local_ratelimit
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
stat_prefix: http_local_rate_limiter
token_bucket: { max_tokens: 100, tokens_per_fill: 100, fill_interval: 1s }
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: order_api
type: STRICT_DNS
load_assignment:
cluster_name: order_api
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: 127.0.0.1, port_value: 8080 }
- name: jwks
type: STRICT_DNS
load_assignment:
cluster_name: jwks
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: 127.0.0.1, port_value: 8081 }
三个映射关系:jwt_authn = 认证(验签、aud、过期),local_ratelimit = 单实例限流(跨实例的全局配额要接 RLS/Redis,见 35.7 限流),route = 路由与超时。
envoy --mode validate -c envoy.yaml
# configuration '/etc/envoy/envoy.yaml' OK(Envoy 1.40.1 实测)
curl -i -H 'Host: api.example.com' http://127.0.0.1:10000/api/order
# HTTP/1.1 401 Unauthorized
# www-authenticate: Bearer realm="http://api.example.com/api/order"
curl -s localhost:9901/stats | grep -E 'http.ingress_http.(downstream_rq_401|downstream_rq_429)'
一句话选型:要开箱的 API 治理(key/配额/门户)选 APISIX 或 Kong;已经在 K8s 且想 GitOps 选 Envoy Gateway;只想在 Envoy 上做认证与限流,就用上面这份配置,控制面自己管。
上线:配置怎么发、密钥怎么换、配额挂了怎么办
| 动作 | 做法 | 判据 / 坑 |
|---|---|---|
| 配置发布 | 声明式提交 → schema 校验 → 先上 1 个实例 → 观察 10 分钟 → 全量 | 401/404/429 比例不跳变;“全量生效”是网关事故第一名 |
| 配置回滚 | 保留上一版,回滚 = 提交上一版 + 同样灰度 | 5 分钟内恢复;前提是配置在 Git 里 |
| 密钥轮换 | 新 key 先发、旧 key 保留 ≥ 1 个 token TTL,双 key 重叠 | 轮换期 401 不上升;直接替换 = 存量客户端全部掉线 |
| 消费者吊销 | 删 consumer + 等缓存过期(或主动清缓存) | 只删配置不清缓存,吊销延迟 = 缓存 TTL |
| 配额存储故障 | 显式选 fail-open(保可用)或 fail-closed(保安全),按路由分级 | 不选,就是让实现替你选;不同插件行为并不一致 |
| 上游变更 | 走服务发现 / EDS,不手改 nodes | 扩容不应该需要改网关配置 |
# 四种结果各打一次:401 / 403 / 200 / 429
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/api/order/1
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $LOW_SCOPE" https://api.example.com/api/order/1
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" https://api.example.com/api/order/1
for i in $(seq 1 1001); do curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" https://api.example.com/api/order/1; done | sort | uniq -c
# 429 必须带 Retry-After,否则客户端立刻重试,限流反而成了加速器
curl -si -H "Authorization: Bearer $TOKEN" https://api.example.com/api/order/1 | grep -i '^retry-after'
# 故障演练:把配额用的 Redis 停掉,看是放行还是全拒——这个行为必须是你选过的
🔑 网关配置是生产资产,不是部署产物:没有灰度、没有回滚、没有故障演练的网关,就是一个能一次改挂全站的单点。
反模式
| 反模式 | 后果 |
|---|---|
| 把网关做成 ESB:编排、聚合、转换全塞进去 | 每次改动都要动网关,发布耦合所有团队 |
| 只在网关鉴权,内网信任一切 | 内网横向移动即可绕过;零信任下不可接受(见 38.8 零信任) |
| 网关里存业务状态(购物车、会话) | 网关变成有状态服务,扩容/故障切换全乱 |
| 用网关做服务发现(硬编码上游列表) | 上游扩缩容要改网关配置 |
| 认证结果用可伪造的 header 传给下游 | 下游信任 header → 任意伪造身份 |
| 所有 API 共用一套超时/重试 | 快接口被慢接口拖死,重试放大 |
| 配额计数放在网关内存 | 多副本各自计数 → 实际配额 = 配置值 × 副本数 |
| 配置改动全量直接生效 | 一条错误路由 = 全站 401/404 |
| 密钥一刀切换掉 | 旧 key 立刻失效,存量客户端全量 401 |
| 配额存储故障没演练 | Redis 挂时行为未知:要么全拒,要么配额失效 |
自检
Q1 反向代理和 API 网关的分界线在哪?举一个"该用反向代理、不该用网关"的例子。
▸ 答案:分界线是"是否承担每个 API 都需要、但不该写在业务里的治理"(认证、配额、版本、协议转换、开发者门户)。若只是 TLS 终结 + 按路径转发 + 负载均衡,反向代理足够:例如一个内部管理后台,3 个服务、调用方全是公司员工,加网关只会多一跳、多一套配置要维护,收益为零。
Q2 为什么"先路由后认证"是危险的?
▸ 答案:未认证的请求也能打到路由匹配逻辑,攻击者可以枚举路径、探测哪些内部路由存在(404/401/403 的差异就是信息泄漏);同时日志里会出现大量"未知消费者"的噪声,掩盖真实攻击。正确顺序是认证 → 配额 → 路由:不认人就不进入路由判断。
Q3 入口 10 万 QPS,网关单实例 2 万 QPS,为什么要配 6 个而不是 5 个?
▸ 答案:5 个是"刚好扛住"的数学下限,任何一个实例故障或滚动发布,剩余 4 个就要扛 10 万(每个 2.5 万),超过单实例容量 → 雪崩。N+1 冗余(6 个)保证单实例故障时仍有 5 个在工作。若做多 AZ,还要保证"挂一个 AZ 后剩余容量 ≥ 峰值"。
Q4 网关的配额计数放在实例内存里,会有什么问题?怎么改?
▸ 答案:每个副本各自计数,实际放行量 = 配置值 × 副本数(6 个副本、1000/分钟 → 实际 6000/分钟),配额形同虚设;扩容时配额还会突然变大。改法:集中计数(Redis/专用配额服务)+ 本地预扣(批量领令牌,减少远程调用),并接受"预扣未用完"的少量浪费。
Q5 网关超时应该比上游超时更长还是更短?为什么?
▸ 答案:更长。上游先超时并返回(或快速失败),网关才能拿到明确结果;如果网关先超时,它已经断开连接、可能触发重试,而上游还在继续处理——同一请求被处理两次,重试还会放大下游压力。经验规则:网关超时 = 上游超时 + 余量(含网络与排队),重试只在"连接未建立/明确可重试"时做。
Q6 密钥轮换为什么要“双 key 重叠”?重叠窗口至少要多久?
▸ 答案:客户端手里的旧 key 数量不可控,替换是渐进的:新旧 key 必须同时有效,直到旧 key 的存量客户端全部换完。窗口 ≥ max(token TTL, 客户端缓存/发布周期) + 观测时间;窗口内盯 401 比例,降到 0 再下线旧 key。只保留新 key,等于在轮换瞬间把所有没升级的客户端踢下线。