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 是否 也有类似的要求。