Posts for: #Learning

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 旨在解决这些问题,提供一个更加健壮和通用的流量管理框架。

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是由编译器引入的,主要包含垃圾回收时使用的信息,这里略过。

Lua:Table 浅析

本文的分析基于 OpenResty 的 Lua 分支(https://github.com/openresty/luajit2)。

核心 API

table 的 API 定义在 src/lib_table.c 中,API 分为三个部分:

标准库函数:

  • table.insert() - 向 table 插入元素
  • table.remove() - 移除 table 元素
  • table.concat() - 连接 table 元素为字符串
  • table.sort() - 对 table 进行排序
  • table.maxn() - 找到 table 中最大数字键
  • table.move() - 移动 table 元素

LuaJIT 扩展函数:

  • table.new() - 预分配指定大小的 table

OpenResty 扩展函数:

  • table.clear() - 清空 table 内容
  • table.clone() - 克隆 table
  • table.nkeys() - 获取 table 键的数量
  • table.isarray() - 检查是否为数组
  • table.isempty() - 检查 table 是否为空

数据结构

typedef struct Node {
  TValue val;         // 值对象,必须是第一个字段
  TValue key;         // 键对象
  MRef next;          // 哈希链指针
#if !LJ_GC64
  MRef freetop;       // 32位架构下的空闲节点顶部指针(存储在node[0])
#endif
} Node;

typedef struct GCtab {
  GCHeader;           // GC 通用头部:nextgc, marked, gct
  uint8_t nomm;       // 元方法负缓存掩码
  int8_t colo;        // 数组共址标记 (-1表示已分离, >0表示共址大小)
  MRef array;         // 数组部分指针
  GCRef gclist;       // GC 链表指针
  GCRef metatable;    // 元表引用
  MRef node;          // 哈希部分指针
  uint32_t asize;     // 数组部分大小 [0, asize-1]
  uint32_t hmask;     // 哈希掩码 (哈希部分大小-1)
#if LJ_GC64
  MRef freetop;       // 64位架构下的空闲节点顶部指针
#endif
} GCtab;

可以看到,GCtab 中同时定义了数组部分 array 和哈希部分 node