Nginx 一次/两次代理
1. 背景
之前观测到,在域名解析/出口路由不变的情况下,自己的电脑 clone github 比直接在出口节点慢很多,且带宽 远小于本地到出口的带宽。怀疑是本地到出口之间 RTT 大,且有丢包,导致 cubic 拥塞算法带宽拉不上来。服务器端 的拥塞算法我也无法控制,因此尝试按下面方法拆分 TCP 连接,将本地到出口之间的链路用 Nginx 代理下,使得每段 连接的 RTT 比较小,且出口之前的链路可以用 BBR 容忍丢包:
本机 接入 出口 服务器
原本: < ---------------------------- >
一次代理: < ---------------> | < ------- >
两次代理: < --- > | <-----> | < ------- >
UDP 流量没有这类通用的考虑,因为主要的协议要么不需要流控(DNS),要么已经有 BBR(QUIC)。
有一个细节是,如果使用 Nginx 做代理,需要一些 Hack 让它能响应目标地址为非本机地址的请求;如果是两次 代理,还需要让它能还原原始目标地址。
2. 一次代理
由于出口的代理直接面对上游服务器,它不能再改变对端,也就不能对出流量做协议允许之外的修改;同样因为它直接面对客户端, 入流量也必须遵守原始协议规则。
虽然有保持协议不变的限制,但是实际操作起来却不复杂。原因是因为当今互联网的细腰架构,最主要的流量都是 HTTP 和 HTTPS 协议,现在 HTTP 协议都少见了;而两个协议都留有各自有方便用于代理去恢复实际目标地址的机制:
- HTTP 协议的 HOST 字段通常都是真正目标域名
- HTTPS 协议的使用 TLS1.2 或以上(应该是)基本都会带上 SNI 信息,很方便各类 4-7 层设备做转发策略
我们还可以做一个假设,就是由于各种 NAT 的需求,使用 HTTP/HTTPS 协议的应用一般不会传输对 NAT 不友好的 信息(比如 7 层协议里带上客户端地址,和 3 层包做对比),因此代理对源地址的转换也通常不会引入问题。
由此还有一个副产物是,很容易在 Nginx 的日志上看到最近的出口请求是到哪些域名的。虽然有点侵犯接入者的隐私, 但是我保证不会告诉第二个人的:P。这种透明性对于没什么功夫的维护者来说,极大减少了调试问题的难度。
另外,因为一次代理保持协议的特性,可以级联起来做两次或者多次代理,进一步减少每段 TCP 连接的 RTT,让带宽 更容易拉起来。多次代理之间,也可以不用标准的 80/443 端口。
示例的 Nginx 规则如下。
http { server { listen 80; listen [::]:80; location / { proxy_pass http://$http_host; proxy_buffering off; proxy_set_header Host $http_host; } } } stream { # 如果 upstream 是一个域名,nginx 需要解析它。 # 我忘记为什么 nginx 不用 /etc/resolv.conf 里的配置了。 resolver 1.1.1.1; server { listen 443; listen [::]:443; proxy_pass $ssl_preread_server_name:443; ssl_preread on; proxy_connect_timeout 10s; # Timeout for establishing a connection to the upstream # 下面这个 timeout 我觉得不合理(1w),但是万一有长连接真的如此空闲... proxy_timeout 168h; # Timeout for inactivity between NGINX and the client/upstream # 这两个是 AI 填的,我不清楚意义 proxy_upload_rate 0; # Unlimited upload rate (0 means no limit) proxy_download_rate 0; # Unlimited download rate (0 means no limit) } }
上面的监听者需要用到 AnyIP 的功能,见下面讲解。
3. 两次代理
因为在接入和出口间设置了一对代理,它们两个通信时,可以在任意一条 TCP 流上额外携带信息,比如来记录原始的目标地址。 Nginx 很贴心地支持了 Proxy Protocol,很方便实现这个功能。这样最大的好处是能够在不改变下游到上游的流内容的情况下, 让出口能和原始上游建立连接,因此它可以代理任意的、对 NAT 友好的 TCP 协议。
示例的入口 Nginx 配置:
stream { server { listen 8080; listen [::]:8080; proxy_pass example.com:8080; # 这里是核心配置 proxy_protocol on; proxy_connect_timeout 10s; # Timeout for establishing a connection to the upstream # 下面这个 timeout 我觉得不合理(1w),但是万一有长连接真的如此空闲... proxy_timeout 168h; # Timeout for inactivity between NGINX and the client/upstream # 这两个是 AI 填的,我不清楚意义 proxy_upload_rate 0; # Unlimited upload rate (0 means no limit) proxy_download_rate 0; # Unlimited download rate (0 means no limit) } }
示例的出口 Nginx 配置:
stream { # 给 IPv6 地址套上方括号,因为下面还要加 :$port map $proxy_protocol_server_addr $proxy_pass_proxy_protocol_ip { default "$proxy_protocol_server_addr"; ~.*:.* "[$proxy_protocol_server_addr]"; } server { # 这里是核心配置 listen 8080 proxy_protocol; listen [::]:8080 proxy_protocol; proxy_pass $proxy_pass_proxy_protocol_ip:$proxy_protocol_server_port; proxy_connect_timeout 10s; # Timeout for establishing a connection to the upstream # 下面这个 timeout 我觉得不合理(1w),但是万一有长连接真的如此空闲... proxy_timeout 168h; # Timeout for inactivity between NGINX and the client/upstream # 这两个是 AI 填的,我不清楚意义 proxy_upload_rate 0; # Unlimited upload rate (0 means no limit) proxy_download_rate 0; # Unlimited download rate (0 means no limit) } }
入口的监听者需要用到 AnyIP 的功能,见下面讲解。
4. AnyIP 和 TPROXY
AnyIP https://blog.widodh.nl/2016/04/anyip-bind-a-whole-subnet-to-your-linux-machine/ AnyIP + TPROXY https://blog.cloudflare.com/how-we-built-spectrum/
AnyIP 是通过特殊的 local 路由,让本机网络栈把目标地址非本机的数据包按输入而不是转发来处理。 有三个相关的组件:local 路由、IPTRANSPARENT sockopt、TPROXY。
4.1. local 路由
这是 AnyIP 的核心
# 往默认的 local 路由表里增加一段地址 # 如果本机有监听到对应端口的 socket,哪怕它没有配置 IP_TRANSPARENT sockopt,也不用 TPROXY 转发, # 这个 socket 也能收到发往这个网段的数据包,而且 getsockname 能正确返回原始的目标地址。 sudo ip -4 r add local 100.64.0.0/10 dev lo sudo ip -6 r add local fddd:/112 dev lo # 往非默认的路由表里增加一段地址 # 非默认路由表里的 IPv6 local 路由依旧能实现上面注释里的功能。 # 但是,非默认路由表里的 IPv4 local 路由,虽然能让数据包按输入来处理,但是不带 IP_TRANSPARENT sockopt # 的 socket 收不到这些数据包,哪怕加了 TPROXY 规则也一样。 sudo ip -4 r add local default dev lo table 99 sudo ip -6 r add local default dev lo table 99
4.2. IPTRANSPARENT
这个选项有两个作用:
- 让 socket 可以接收目标地址非本机地址、甚至(配合 TPROXY)目标端口也非本 socket 监听端口的连接, 同时能够获得连接的原始源/目标地址
- 让 socket 可以以非本机地址作为源地址发包
对于这里的代理功能,第一个作用比较相关,因为:
- 一次转发的情况下,如果使用非默认路由表,IPv4 流量要求 IPTRANSPARENT
- 两次转发的情况下,入口代理需要处理发往各种目标端口的请求,它不可能真的听到所有的端口上
4.2.1. 让 Nginx 支持 IPTRANSPARENT
Nginx 的 proxy pass 虽然支持 transparent 参数,但这是配置它对上游连接的 socket;listen 用的 socket 是不支持这个配置的。但是有个基于 systemd socket 的 hack,可以让 systemd socket 配上 IPTRANSPARENT, 然后利用 Nginx 在 reload 时尽量不断开 listen socket 的机制,使得它从 systemd socket 继承这些 socket。
示例的 systemd socket
# nginx.socket # See # https://systemd.io/DAEMON_SOCKET_ACTIVATION/ # and # https://github.com/nginx/nginx/blob/stable-1.28/src/core/nginx.c#L466 # for how this IP_TRANSPARENT trick works. # The order of the sockets are important, they must match with the order # they appear inside Nginx's config. [Socket] ListenStream=0.0.0.0:80 ListenStream=80 ListenStream=0.0.0.0:8080 ListenStream=8080 ListenStream=0.0.0.0:443 ListenStream=443 BindIPv6Only=ipv6-only Transparent=true [Install] WantedBy=sockets.target
Nginx 服务也必须要做修改,一是继承 socket,二是因为 nginx 在继承 socket 的情况下不会再 fork,因此它的 systemd service 类型也必须从 forking 改为 simple,否则 systemd 会认为这个服务一直不 fork 而判断启动失败。 为了对应这个改动,nginx 侧也最好把 daemon 功能关掉,不要 fork 来保持行为一致。
[Service] # Nginx will not fork when inheriting sockets, # see # https://github.com/nginx/nginx/blob/stable-1.28/src/core/nginx.c#L466. # So we need to disable daemonize and change the service type to simple. Type=simple # 这里的 socket 数量要和上面 systemd socket 数量对应上,fd 编号从 3 开始(跳过 stdin/stdout/stderr) Environment=NGINX=3:4:5:6:7:8; # 额外配置下让 systemd 生成的 socket 带上 non-blocking 配置,不过好像不带也没事 NonBlocking=true
4.3. TPROXY
让本机的 socket 能收到目标地址非本机、目标端口非本 socket 监听端口的连接。
有一个不确定的细节,我记得 IPTABLES REDIRECT 功能选择的 socket 是监听在输入包对应 netif 设备上的 socket, 假如有个 socket 只听到 lo 上,那外界来的包经过 REDIRECT 后是不会到这个 socket 上的。我不确定 TPROXY 是否 也有类似的要求。
5. Backlinks
- 多出口的家庭网络 (20251020T213424-多出口的家庭网络.org)