APISIX 插件体系

背景知识

APISIX 基于 Openresty 和 Nginx 开发,了解 APISIX 的插件体系也有必要先了解下 Nginx 和 Openresty 的基础知识。

Nginx 请求处理阶段

Nginx 将一个 HTTP 请求的处理过程精心划分为一系列有序的阶段(Phases),就像一个工厂的流水线。每个阶段都有特定的任务,不同的模块可以将自己的处理程序(handler)注册到感兴趣的阶段。这种设计使得 Nginx 的功能高度模块化、可扩展,并且处理流程非常清晰高效。

一个请求在 Nginx 中主要会经过以下 11 个处理阶段

1. NGX_HTTP_POST_READ_PHASE (请求读取后阶段)

  • 任务: 接收到完整的请求头(Request Header)之后,第一个被执行的阶段。
  • 常用模块ngx_http_realip_module
  • 作用举例: 在这个阶段,realip 模块会根据 X-Forwarded-For 或 X-Real-IP 等请求头,将客户端的真实 IP 地址替换掉代理服务器的 IP 地址。这对后续的访问控制和日志记录至关重要。

2. NGX_HTTP_SERVER_REWRITE_PHASE (Server 级别地址重写阶段)

  • 任务: 在 server 配置块中执行 URL 重写。
  • 常用模块/指令rewrite 指令(当它定义在 server 块中时)。
  • 作用举例: 在请求进入具体的 location 匹配之前,对 URL 进行全局的、初步的改写。例如,将所有 http 请求强制重定向到 https
server {
    listen 80;
    server_name example.com;
    # 这个 rewrite 就工作在 SERVER_REWRITE 阶段
    rewrite ^/(.*)$ https://example.com/$1 permanent; 
}

3. NGX_HTTP_FIND_CONFIG_PHASE (配置查找阶段)

  • 任务: 根据上个阶段处理完的 URI,查找并匹配对应的 location 配置块。
  • 核心功能: 这是 Nginx 路由的核心。Nginx 会用请求的 URI 与 location 指令定义的规则进行匹配,找到最合适的 location。这个阶段没有模块可以注册 handler,是 Nginx 核心自己完成的。

4. NGX_HTTP_REWRITE_PHASE (Location 级别地址重写阶段)

  • 任务: 在上一步匹配到的 location 块内部,执行 URL 重写。
  • 常用模块/指令rewrite 指令(当它定义在 location 块中时)、set 指令。
  • 作用举例: 对特定 location 的请求进行更精细的 URL 改写。这个阶段的 rewrite 可能会导致 Nginx 重新回到 FIND_CONFIG 阶段去匹配新的 location,可能会有循环,最多执行 10 次以防止死循环。
location /app/ {
    # 这个 rewrite 工作在 REWRITE 阶段
    rewrite ^/app/(.*)$ /v2/app/$1 break; 
}

5. NGX_HTTP_POST_REWRITE_PHASE (地址重写后阶段)

  • 任务: 防止 rewrite 阶段的重写指令导致死循环。如果 URI 在上一个阶段被重写,这个阶段会把重写后的 URI 交给 Nginx,让其重新开始 FIND_CONFIG 阶段,查找新的 location。这是一个内部阶段,用户通常不直接感知。

6. NGX_HTTP_PREACCESS_PHASE (访问权限控制前置阶段)

  • 任务: 在正式的访问权限检查之前,做一些准备工作。
  • 常用模块ngx_http_limit_conn_module (连接数限制), ngx_http_limit_req_module (请求速率限制)。
  • 作用举例: 在检查用户名密码之前,先检查客户端的请求速率是否过快,如果过快,直接拒绝,不必再执行后面的阶段。

7. NGX_HTTP_ACCESS_PHASE (访问权限控制阶段)

  • 任务: 对请求进行权限验证。
  • 常用模块/指令ngx_http_access_module (allowdeny), ngx_http_auth_basic_module (HTTP 基本认证)。
  • 作用举例: 检查客户端 IP 是否在允许列表中,或者验证用户提供的用户名和密码是否正确。如果这个阶段有任何模块拒绝了请求,处理就会立即中断并返回错误(如 403 Forbidden)。

8. NGX_HTTP_POST_ACCESS_PHASE (访问权限控制后置阶段)

  • 任务: 主要用于配合 ACCESS 阶段的 satisfy 指令。
  • 作用举例: 当 satisfy any; 被使用时,如果 ACCESS 阶段有模块允许了访问(例如 IP 匹配成功),POST_ACCESS 阶段的处理就可以被跳过。如果 ACCESS 阶段没有明确允许,这个阶段的处理(例如 HTTP 基本认证)就会被执行。

9. NGX_HTTP_TRY_FILES_PHASE (Try_files 阶段)

  • 任务try_files 指令专属的特殊阶段。
  • 常用模块/指令try_files
  • 作用举例: 按顺序检查文件或目录是否存在,如果找到,则内部重定向到该文件,如果都找不到,则执行最后一个参数(通常是内部重定向到一个 location 或返回一个状态码)。
location / {
    # 这个指令工作在 TRY_FILES 阶段
    try_files $uri $uri/ /index.html;
}

10. NGX_HTTP_CONTENT_PHASE (内容生成阶段)

  • 任务核心阶段,负责生成最终的响应内容并发送给客户端。
  • 重要特性: 每个 location 只有一个模块能成为“内容处理模块”(Content Handler)。
  • 常用模块:
    • ngx_http_static_module: 处理静态文件。
    • ngx_http_proxy_module: 将请求反向代理到后端服务器 (proxy_pass)。
    • ngx_http_fastcgi_module: 将请求转发给 FastCGI 应用(如 PHP-FPM)。
    • ngx_http_index_module: 处理目录索引文件。
    • return: 直接返回指定的状态码或内容。
  • 执行逻辑: Nginx 会调用在 location 中找到的内容处理模块。例如,如果配置了 proxy_pass,那么 proxy_module 就会接管请求。如果没有特定的内容处理模块,默认会由 static_module 尝试提供静态文件服务。

11. NGX_HTTP_LOG_PHASE (日志记录阶段)

  • 任务: 请求处理完成后,记录访问日志。
  • 常用模块/指令ngx_http_log_module (access_log)。
  • 作用举-例: 无论请求成功还是失败,这个阶段都会被执行(除非有严重错误),用于将请求的相关信息(如客户端IP、请求时间、状态码等)写入到日志文件中。

过滤器模块 (Filter Modules)

CONTENT 阶段生成了响应内容后,在发送给客户端之前,响应数据还会经过一系列的 过滤器(Filter) 链处理。过滤器负责对响应头和响应体进行修改或加工。

WebSocket Origin Header 校验失败

最近做 APISIX 线上服务时遇到一个场景:业务使用 websocket 转发时,在浏览器会出现 WebSocket close with status code 1006 的错误。打开调试工具查看发现在 websocket 握手时服务端返回了 403。

76f75e967736245ec923202987a22482_MD5

4efbf4b7a2093ed4f1d6e522139dc92e_MD5

非常奇怪的是,如果业务不经过 APISIX 直接访问后端 code-server 是没有问题的(中间也得经过一层 Ingress 转发)。

简单的流量模型如下:

                                                                                                                      
                                                                                                                      
                               +--------------+                              +--------------+          +-------------+
  https://domain.com:9443/xxx  |              |  http://domain.com:23480/xxx |              |          |             |
--------------------------------   APISIX     -------------------------------+   Ingress    -----------+ code-server |
                               |              |                              |              |          |             |
                               +--------------+                              +--------------+          +-------------+

经过对比发现,两者的请求头里面 Host 和 Origin 是存在差异的。尝试在 APISIX 中强制修改 Host 头部,问题没有解决。然后利用 proxy-rewrite 强制修改 Origin 头部,请求恢复正常。

    "plugins": {
      "proxy-rewrite": {
        "uri": "/anything",
        "headers": {
          "set": {
	        "Origin": "http://domain.com"
          }
        }
      }
    },

那么问题来了,为什么改完 Origin Header 就行了?code-server 是如何处理 Origin Header 的?为什么 Ingress 可以,APISIX 不行?

Introducing Kubernetes Gateway API

Kubernetes Gateway API 是一个由 SIG-Network 孵化和维护的 开放、标准、可扩展的 API,旨在为 Kubernetes 集群内部和外部的统一流量管理提供更强大、更灵活、更具表现力的方式。

它被视为 Ingress API 的继任者和演进,解决了 Ingress 在灵活性、可扩展性、角色分离和 L4-L7 支持方面的诸多局限性。Gateway API 不仅仅关注 HTTP 流量,还支持 TCP、UDP 以及 TLS 路由,覆盖了更广泛的网络场景。

为什么需要 Gateway API

在 Gateway API 出现之前,Kubernetes 主要使用 Ingress 来管理集群的 L7 HTTP/HTTPS 流量。然而,Ingress 存在一些固有的局限性,促使了 Gateway API 的诞生:

  1. 功能有限: Ingress 本身功能非常基础,只支持主机和路径路由。任何高级功能(如流量拆分、重写、限速、认证等)都必须通过特定于 Ingress 控制器(如 NGINX Ingress, Traefik, AWS ALB Ingress 等)的 注解(Annotations) 来实现。
  2. 可移植性差: 注解是特定于实现的,这意味着用 NGINX Ingress 注解编写的规则无法直接移植到 Traefik 或其他 Ingress 控制器上,造成供应商锁定。
  3. 缺乏角色分离: Ingress 资源将网络基础设施的配置(例如暴露的端口、TLS 证书)与应用程序的路由规则混在一起,这使得集群管理员和应用开发者之间的职责难以清晰划分。
  4. L4 流量支持不足: Ingress 仅专注于 HTTP/HTTPS (L7) 流量。对于 TCP、UDP 或 TLS 直通(Passthrough)等 L4 流量,通常需要使用 LoadBalancer 类型的 Service 或其他的解决方案。
  5. 表达能力有限: 难以表达复杂的路由策略,如基于请求头、查询参数的匹配,细粒度的流量权重分配等。

Gateway API 旨在解决这些问题,提供一个更加健壮和通用的流量管理框架。

APISIX balancer 支持 domain

最近在做网关开发时遇到了一个场景:业务在做多实例部署时需要通过 Ingress 将服务注册给 APISIX,由于每个实例属于一个单独的 namespace,导致在 APISIX 和 Ingress 上出现流量覆盖的问题:

  1. Ingress 通过 Host 和 URI 匹配入向流量,同一个服务的 Host 和 URI 是完全一致的(Host 用 ip:port 表示),因此注册多个 Ingress 流量只会引流到一个实例上。
  2. APISIX 侧在 balance 阶段对于相同 ip:port 的 upstream node 会做去重,导致跨集群多实例时流量分配并不符合预期。

举个例子,有 A,B 两个集群,A 集群部署了 2 个实例,B 集群部署了一个实例。预期这 3 个实例应该平均分配流量(假设采用 rr),A 集群和 B 集群流量比例应该是 2:1,但实际上 A 集群和 B 集群在 APISIX 分配时流量是 1:1,而在 A 集群内一个实例占据了全部流量。

从业务上来说这个可以通过重新规划 namespace 和实例的关系来解决。同一个集群同一个服务的所有实例分布在同一个 namespace 中。这样注册的 Ingress 和 APISIX upstream node 就只会有一个。

抛开这个点,我们来看看从网关的角度如何解决这两个问题。

第一个问题比较好解决,每个注册的实例采用独立的 Domain,Ingress 通过 Host Header 来区分流量。在 APISIX 采用域名注册,域名解析到 Ingress 地址。如果域名没法解析(我们遇到的场景),也可以将 Domain 信息放到 node 的 metadata 内,写一个插件将其注入到 node.domain 内来完成。

Go 汇编分析

Go的汇编不是像 C/C++ 一样,对机器码的直接描述,而是兼容跨平台需求实现的半抽象化的指令集。

https://go.dev/doc/asm

汇编分析(Go 1.17)

我们用一个简单的例子来开始汇编分析:

package main

func main() {
	add(1, 3)
}

func add(i, j int) int {
	return i + j
}

汇编结果,删去了一些无关的输出:

# go tool compile -S -l -N main.go
"".main STEXT size=54 args=0x0 locals=0x18 funcid=0x0
        0x0000 00000 (main.go:3)        TEXT    "".main(SB), ABIInternal, $24-0
        0x0000 00000 (main.go:3)        CMPQ    SP, 16(R14)
        0x0004 00004 (main.go:3)        PCDATA  $0, $-2
        0x0004 00004 (main.go:3)        JLS     47
        0x0006 00006 (main.go:3)        PCDATA  $0, $-1
        0x0006 00006 (main.go:3)        SUBQ    $24, SP
        0x000a 00010 (main.go:3)        MOVQ    BP, 16(SP)
        0x000f 00015 (main.go:3)        LEAQ    16(SP), BP
        0x0014 00020 (main.go:3)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0014 00020 (main.go:3)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0014 00020 (main.go:4)        MOVL    $1, AX
        0x0019 00025 (main.go:4)        MOVL    $3, BX
        0x001e 00030 (main.go:4)        PCDATA  $1, $0
        0x001e 00030 (main.go:4)        NOP
        0x0020 00032 (main.go:4)        CALL    "".add(SB)
        0x0025 00037 (main.go:5)        MOVQ    16(SP), BP
        0x002a 00042 (main.go:5)        ADDQ    $24, SP
        0x002e 00046 (main.go:5)        RET
        0x002f 00047 (main.go:5)        NOP
        0x002f 00047 (main.go:3)        PCDATA  $1, $-1
        0x002f 00047 (main.go:3)        PCDATA  $0, $-2
        0x002f 00047 (main.go:3)        CALL    runtime.morestack_noctxt(SB)
        0x0034 00052 (main.go:3)        PCDATA  $0, $-1
        0x0034 00052 (main.go:3)        JMP     0

"".add STEXT nosplit size=56 args=0x10 locals=0x10 funcid=0x0
        0x0000 00000 (main.go:7)        TEXT    "".add(SB), NOSPLIT|ABIInternal, $16-16
        0x0000 00000 (main.go:7)        SUBQ    $16, SP
        0x0004 00004 (main.go:7)        MOVQ    BP, 8(SP)
        0x0009 00009 (main.go:7)        LEAQ    8(SP), BP
        0x000e 00014 (main.go:7)        FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (main.go:7)        FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (main.go:7)        FUNCDATA        $5, "".add.arginfo1(SB)
        0x000e 00014 (main.go:7)        MOVQ    AX, "".i+24(SP)
        0x0013 00019 (main.go:7)        MOVQ    BX, "".j+32(SP)
        0x0018 00024 (main.go:7)        MOVQ    $0, "".~r2(SP)
        0x0020 00032 (main.go:8)        MOVQ    "".i+24(SP), AX
        0x0025 00037 (main.go:8)        ADDQ    "".j+32(SP), AX
        0x002a 00042 (main.go:8)        MOVQ    AX, "".~r2(SP)
        0x002e 00046 (main.go:8)        MOVQ    8(SP), BP
        0x0033 00051 (main.go:8)        ADDQ    $16, SP
        0x0037 00055 (main.go:8)        RET

FUNCDATAPCDATA是由编译器引入的,主要包含垃圾回收时使用的信息,这里略过。