Skip to content

网关与代理 ​

Go 服务前面放一个代理,通常是为了统一入口、证书、路由和流量保护。但多一跳也会改变客户端地址、超时、缓冲和连接生命周期。服务在本地正常,接到代理后流式输出变成“一口气到达”,或者限流把所有人当成同一个 IP,常常就是这些边界没有对齐。

本篇先沿着请求走一遍,再比较 Nginx、Envoy、Caddy 的机制和选择条件。Go 1.27.2、Linux/amd64 的代理头清理、上游超时、并发令牌桶实验已实际运行;三种产品配置提供完整文件,但没有运行对应服务或配置校验器。2026-10-13 查阅官方发布页时,Nginx stable 为 1.30.5、mainline 为 1.31.6,Envoy release 为 1.39.4,Caddy release 为 2.11.7。下文不会把“latest 文档”当成已安装版本。

一次代理转发有两段连接 ​

正向代理代表客户端去访问外部服务器,例如企业出站代理;反向代理代表服务端接收外部请求,再选择内部服务。客户端通常只知道反向代理的公开地址。API gateway 往往在这个基础上再做鉴权、配额、路由或策略,但“叫网关”不自动代表所有功能都已启用。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    C[客户端] -->|连接 A:TLS / HTTP| E[边缘代理]
    E -->|连接 B:HTTP 或新 TLS| U[Go 上游服务]
    E --> R[路由与限流策略]
    E --> O[连接、状态码与延迟指标]
    U --> D[数据库或其他依赖]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    C[客户端] -->|连接 A:TLS / HTTP| E[边缘代理]
    E -->|连接 B:HTTP 或新 TLS| U[Go 上游服务]
    E --> R[路由与限流策略]
    E --> O[连接、状态码与延迟指标]
    U --> D[数据库或其他依赖]

A 和 B 是两段连接。客户端的 TCP 地址到达边缘时可见,上游直接看到的对端通常是代理;TLS 在代理终结后,B 是否加密要另外决定。代理可以在 A 用 HTTP/2,在 B 用 HTTP/1.1,也可以复用少量 B 连接承载更多请求。连接数、请求数和用户数因此不能互相替代。

要做的事通常在哪一层配置为什么不能只改 Go handler
TLS 证书与续期外部入口及需要加密的上游连接handler 可能根本没收到原始 TLS
路径、域名、服务路由代理路由表请求可能已经被改写或分流
读取真实客户端地址已建立信任边界的代理头处理RemoteAddr 多半是上一跳地址
流式响应应用 Flush、每一跳的缓冲和超时任意一跳缓冲都可能改变可见时间
全局用户配额共享限流服务或业务配额系统单个代理副本只看到部分请求

三种工具各把复杂性放在哪里 ​

工具主要机制与常用入口适合的场景需要承担的代价
Nginxworker 事件循环、server/location、upstream、proxy 模块常规入口、静态内容、成熟运维配置指令继承、URI 改写、模块与版本差异要理解
Envoylistener → filter chain → HTTP connection manager → route → cluster;可通过 xDS 更新服务间代理、动态发现、细粒度 L7 策略配置面较大,常需配套控制面与观测
Caddy配置适配、反向代理、自动 HTTPS 生命周期希望较少配置完成站点与 HTTPS自动化仍依赖 DNS、端口、存储;插件需额外维护

Nginx 的常见工作从 server 选站点、location 匹配路径开始,用 proxy_pass 指向 upstream。Envoy 把监听、连接处理、路由、后端集群明确拆开,适合控制面动态下发;它本身是数据面,不等于 Kubernetes 的配置控制器。Caddy 可以用很短的 Caddyfile 表达入口,并负责符合条件的证书申请与续期。Nginx proxy、Envoy HTTP 管理、Caddy reverse_proxy

这里没有“哪个最快”的通用结论。压缩、TLS、日志、过滤器、上游行为和并发模型都会改变结果;本文没有三者同负载压测。选型先看现有控制面、证书责任、协议与调试成本,再用自己的流量验证性能。

TLS 终结只保护到终结点 ​

客户端与代理完成 TLS 握手,代理解密 HTTP 并处理路由,这叫 TLS termination。它集中管理证书和连接,但也让代理成为能够看到明文内容的可信组件。代理到上游可以是受控环境中的明文 HTTP,也可以重新建立 TLS;跨不可信网络或需要服务身份认证时,应评估上游 TLS/mTLS。

TLS 路径能看到什么主要取舍
TLS passthrough中间层通常看不到 HTTP 路径和正文应用负责证书,可用 L7 策略受限
入口终结,内网 HTTP入口可路由、鉴权、观测内部一段明文,必须明确网络信任边界
入口终结,再 TLS 到上游两段独立加密,可校验上游身份需维护两段证书、SNI 与信任根
mTLS 到上游双向校验端点证书身份证书轮换与授权映射更复杂

Nginx 的 proxy_pass https://... 不足以独立证明上游身份校验正确:需要核对 proxy_ssl_verify、可信 CA、SNI 和名称。不能用跳过证书验证来解决名称不匹配。外侧证书也要有完整链、正确域名、更新和到期监测。Nginx HTTPS、proxy SSL 参数

Caddy 的自动 HTTPS 会为符合条件的名称自动取得和维护证书,并配置相关跳转。要成功仍需要域名控制权、相应 challenge 可达性或 DNS 集成、可写且持久的证书存储。内部名称可能使用本地 CA,对客户端的信任部署是另一项工作,不应自动安装到用户设备。本例为了保持离线、无证书申请副作用,明确使用 HTTP 回环监听并关闭 auto_https。Caddy 自动 HTTPS

X-Forwarded-For 是一条声明,先问是谁写的 ​

客户端可以自己发送 X-Forwarded-For: 1.2.3.4。如果应用无条件取最左边地址做授权、审计或限流,攻击者就能改变你认为的“客户端”。正确处理依赖拓扑:直接边缘应使用实际连接地址并重写相关头;处于可信负载均衡器之后时,只对允许的来源接受其头,并按已知跳数或可信链规则解析。Nginx realip、Envoy 头部与可信跳数

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A[任意客户端:可伪造头] --> L[已知 LB:建立第一层信任]
    L --> P[代理:只信已配置 LB 来源]
    P --> G[Go:只接受可信代理提供的规范头]
    A -. 必须限制绕过入口 .-> G
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A[任意客户端:可伪造头] --> L[已知 LB:建立第一层信任]
    L --> P[代理:只信已配置 LB 来源]
    P --> G[Go:只接受可信代理提供的规范头]
    A -. 必须限制绕过入口 .-> G

只在代码里设置“信任代理头”还不够。若攻击者能绕过入口直接连接 Go 端口,他仍可能给出看似可信的头。因此部署网络应限制上游服务的可达来源,应用也应建立明确的代理信任配置。

Go 的 httputil.ReverseProxy 提供 Rewrite。使用这个入口时,库会先移除入站的 Forwarded、X-Forwarded-For、X-Forwarded-Host、X-Forwarded-Proto 四类头,再让回调生成新值。我们调用:

go
Rewrite: func(r *httputil.ProxyRequest) {
    r.SetURL(target)
    r.SetXForwarded()
},

测试请求把 RemoteAddr 设为文档保留地址 192.0.2.1:1234,同时伪造 X-Forwarded-For: 198.51.100.99 和 Forwarded: for=198.51.100.99;proto=https。请求经这个 Go 代理转给真实回环 HTTP 测试服务器,上游收到:

text
xff=192.0.2.1 proto=http forwarded=""

这证明本文的直接边缘用法不会保留伪造链。SetXForwarded 若发现输出头已有 XFF,会追加,因此不要在回调里不加判断地把原始入站 XFF 复制回去。这不是对所有以 X-Forwarded- 开头的头做通配清理,例如任意扩展名 X-Forwarded-Port 仍需按自己的策略处理。这个例子没有实现“可信 LB 后多跳解析”,那需要额外的来源验证。Go 1.27.2 ReverseProxy

Caddy 默认也对 X-Forwarded-* 的生成与信任有防伪造考虑;代理前还有 CDN/LB 时,应按文档设置 trusted_proxies,并审查来源范围。把所有私网地址或 0.0.0.0/0 都信任,通常比真实拓扑需要的权限宽。Nginx 示例则直接用 $remote_addr 覆盖 XFF,因为它假设自身就是边缘。Caddy 头处理

超时要按一跳一跳的预算设计 ​

一个请求可能经历客户端、入口代理、Go 服务、数据库,每层都设“30 秒”并不意味着总共只等 30 秒。重试会增加尝试次数,排队和建连也会消耗预算。应先定端到端预算,再为每个环节留出余量,并传播取消。

超时主要约束容易误认
connect / dial timeout建立上游连接不包含整个上游响应时间
response header timeout发送请求后等响应头不限制后续持续流式正文总长
read / idle timeout两次读或流活动之间的间隔不一定是整个请求的总时限
route / request timeout对应产品定义的请求处理预算起止点、流式例外必须查版本文档
下游调用 context deadlineGo 内部及数据库/RPC 操作代理关闭连接后也须配合取消

Nginx proxy_read_timeout 计算两次读取之间的间隔,不是整个响应的总耗时。一个持续缓慢输出的连接可以一直活着;这对 SSE 有用,也可能成为资源占用。Envoy 区分路由超时与 stream idle timeout;流式路由需要单独规划,不应为了长连接全局关闭所有保护。Nginx read timeout、Envoy connection management

本文 Go 测试把 http.Transport.ResponseHeaderTimeout 设为 20 毫秒,上游收到请求后不发响应头,只等请求 context 取消。测试确认代理通过 ErrorHandler 返回 502,而没有无限等待。这是本地 Go proxy 的错误映射,不代表所有产品都会返回同一个状态码。

重试尤其需要谨慎:响应丢失不代表上游没执行。扣款、创建订单等非幂等动作,未经幂等键或业务去重支持不应因为 502 就自动重做。设置最大尝试次数、总预算和只允许重试的错误类型,比简单“失败重试三次”更可靠。示例 Nginx 关闭了 proxy_next_upstream,避免把教学配置变成隐式重试策略。

缓冲会改变资源消耗,也会改变用户看到的时间 ​

开启响应缓冲时,代理尽快读取上游,把数据放在内存或临时文件,再按客户端速度发送。好处是可以让上游更早结束;代价是增加代理资源占用,某些流式内容可能不能及时到达。关闭响应缓冲则更直接地传递,但慢客户端会更久地占用链路资源。Nginx buffering

请求缓冲是另一回事:它决定大请求体是否先由代理接收,再交给上游。不要以为 proxy_buffering off 同时关闭了上传缓冲。流式上传、SSE、WebSocket、gRPC 都有各自的协议与超时要求。

业务要核对的点
JSON APIbody 大小、响应头大小、总预算、连接复用
SSE应用 Flush、代理刷新/缓冲、心跳、idle timeout
WebSocketUpgrade / tunnel 支持、长连接退出、空闲超时
gRPCHTTP/2 到上游、trailers、deadline、消息大小
文件上传请求缓冲、临时磁盘、body 限制和客户端中断

本例 Nginx 设置 proxy_buffering off,Caddy 设置 flush_interval -1;这些是为学习低延迟转发给出的配置选择,不是所有接口都应默认关闭缓冲的建议。Envoy 例子没有开启全量 body buffer filter,但仍有协议缓冲和流控,不能称作“零缓冲”。本文没有三种代理的流式时间测量。

限流先定身份和范围,再选算法 ​

“每秒五个”缺少两个条件:按谁算,在哪里累计。按 IP 易于实现,但 NAT 后许多用户共享一个地址;按 API key、用户或租户更贴近业务,不过必须先完成可信鉴权,不能直接信任任意请求头。

Nginx 的 limit_req 使用漏桶方式控制速率,burst 提供突发余量,nodelay 改变突发请求等待方式;超额响应码可以设置为 429。共享内存 zone 可在同一 Nginx 实例的 worker 间共享状态,但不自动成为多台代理之间的全局配额。Nginx limit_req

Envoy local rate limit 使用本地 token bucket。默认范围与配置相关,常见 HTTP 本地桶在单个 Envoy 进程内共享;也有按连接等选项。本例没有按用户/IP 描述符分桶,所有命中路由的请求消耗同一个本地桶。需要跨副本的配额,应使用外部全局限流服务或应用配额系统,并决定服务故障时 fail-open 还是 fail-closed。Envoy local rate limit、global rate limiting

本地 Go 令牌桶实验从 2 个 token 开始,每秒补 1 个,同一时刻连试三次,再前进一秒:

text
bucket: true true false true

前两次扣掉初始 token,第三次没有余量;一秒后补回一个。测试还让 20 个 goroutine 同时请求,确认在这个固定时刻只允许两个,并通过 race。这里是一个桶,没实现用户键的生命周期、分布式一致性、持久化或时钟跨节点协调。

若部署 4 个各自 5r/s 的代理副本,理论合计额度就可能达到 20r/s,不能宣称全局仍为 5r/s。全局限流增加共享服务开销、延迟和故障依赖;本地限流更简单,适合保护单实例容量。二者可以叠加,但作用不同。

Caddy 标准反向代理不应被假定自带任意教程中出现的 rate_limit 指令。官方模块目录明确区分标准与非标准模块,某些限流实现需要定制构建和社区插件。采用前应固定模块版本并评估维护、安全和全局范围;也可以把限流留给上游或独立网关。Caddy 非标准模块说明

三份配置怎样读,怎样验证 ​

文末三份配置都只监听回环高端口,将流量转向 127.0.0.1:9000。Caddy 额外写了 bind 127.0.0.1:站点地址里的主机部分参与站点与 Host 匹配,不应被当成 socket 绑定范围;只有明确设置监听绑定,才能避免默认监听所有接口。Caddy bind它们是待本地验证的学习配置,没有公网监听、真实证书或外部身份;9000 端口的应用由读者自行提供。Go 实验使用自己的临时测试服务器,不依赖这个端口。

配置体现的选择未覆盖的生产责任
nginx.conf边缘覆写转发头、每 IP 本地限流、显式超时、关闭响应缓冲TLS、可信前置 LB、多节点配额、精确 Host 白名单
envoy.yamllistener/HCM/route/cluster、直接对端来源、本地共享桶xDS 控制面、TLS、全局限流、认证
Caddyfile简短反代、dial/header timeout、及时刷新自动 HTTPS 实际签发、可信前置代理、额外限流模块

Envoy 的 latest 文档此次显示 1.40 开发文档,而 release 页是 1.39.4。本文使用 v3 API 与已有核心字段,仍需要用部署版本的 envoy --mode validate 验证;不能把开发文档新字段原样复制进旧二进制。Nginx 可使用 nginx -t,Caddy 可使用 caddy validate --config Caddyfile --adapter caddyfile。这些验证也不等于完整请求链测试,且可能读取证书或准备运行时资源,应在受控配置目录执行。

本次只对 Envoy YAML 做 PyYAML 语法解析,另外两份配置依官方指令审查但未运行产品 parser。完整验证还应覆盖正确/错误 Host、伪造代理头、上游断连、慢请求、流式响应、TLS 名称、限流边界和多副本行为。

依据 ​

完整复现文件 ​

保存以下文件,在 Go 1.27.2、Python 3、PyYAML 6.0.3 环境执行 sh verify.sh。这只启动短暂回环测试服务器,不启用三种代理产品。nginx.conf 若在自己的测试环境启动,还需在配置前缀下建立 logs 目录;所有产品配置均应先用匹配版本校验。

go.mod ​

go
module example.com/proxydemo

go 1.27.0

main.go ​

代码块 · 70 行 · 点击收起 / 展开
go
package main

import (
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
	"sync"
	"time"
)

func proxy(target *url.URL, timeout time.Duration) *httputil.ReverseProxy {
	tr := &http.Transport{DialContext: (&net.Dialer{Timeout: time.Second}).DialContext, ResponseHeaderTimeout: timeout, MaxIdleConnsPerHost: 8, IdleConnTimeout: 30 * time.Second}
	return &httputil.ReverseProxy{
		Rewrite:   func(r *httputil.ProxyRequest) { r.SetURL(target); r.SetXForwarded() },
		Transport: tr,
		ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
			http.Error(w, "bad gateway", http.StatusBadGateway)
		},
	}
}

type bucket struct {
	mu                     sync.Mutex
	tokens, capacity, rate float64
	last                   time.Time
}

func (b *bucket) allow(now time.Time) bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	if now.After(b.last) {
		b.tokens = min(b.capacity, b.tokens+now.Sub(b.last).Seconds()*b.rate)
		b.last = now
	}
	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}
func main() {
	backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "xff=%s proto=%s forwarded=%q\n", r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Forwarded-Proto"), r.Header.Get("Forwarded"))
	}))
	defer backend.Close()
	target, err := url.Parse(backend.URL)
	if err != nil {
		panic(err)
	}
	p := proxy(target, time.Second)
	defer p.Transport.(*http.Transport).CloseIdleConnections()
	req := httptest.NewRequest(http.MethodGet, "http://api.example.test/", nil)
	req.RemoteAddr = "192.0.2.1:1234"
	req.Header.Set("X-Forwarded-For", "198.51.100.99")
	req.Header.Set("Forwarded", "for=198.51.100.99;proto=https")
	w := httptest.NewRecorder()
	p.ServeHTTP(w, req)
	body, err := io.ReadAll(w.Result().Body)
	if err != nil {
		panic(err)
	}
	fmt.Print(string(body))
	start := time.Unix(0, 0)
	b := bucket{tokens: 2, capacity: 2, rate: 1, last: start}
	fmt.Println("bucket:", b.allow(start), b.allow(start), b.allow(start), b.allow(start.Add(time.Second)))
}

main_test.go ​

代码块 · 67 行 · 点击收起 / 展开
go
package main

import (
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

func TestSpoofedForwardedRemoved(t *testing.T) {
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("X-Forwarded-For") != "192.0.2.1" || r.Header.Get("Forwarded") != "" || r.Header.Get("X-Forwarded-Proto") != "http" {
			t.Errorf("unexpected headers: %v", r.Header)
		}
		w.WriteHeader(204)
	}))
	defer upstream.Close()
	u, _ := url.Parse(upstream.URL)
	p := proxy(u, time.Second)
	defer p.Transport.(*http.Transport).CloseIdleConnections()
	r := httptest.NewRequest("GET", "http://example.test/", nil)
	r.RemoteAddr = "192.0.2.1:42"
	r.Header.Set("X-Forwarded-For", "198.51.100.99")
	r.Header.Set("Forwarded", "for=198.51.100.99")
	r.Header.Set("X-Forwarded-Proto", "https")
	w := httptest.NewRecorder()
	p.ServeHTTP(w, r)
	if w.Code != 204 {
		t.Fatal(w.Code)
	}
}
func TestHeaderTimeout(t *testing.T) {
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
	defer upstream.Close()
	u, _ := url.Parse(upstream.URL)
	p := proxy(u, 20*time.Millisecond)
	defer p.Transport.(*http.Transport).CloseIdleConnections()
	w := httptest.NewRecorder()
	p.ServeHTTP(w, httptest.NewRequest("GET", "http://example.test/", nil))
	if w.Code != 502 || !strings.Contains(w.Body.String(), "bad gateway") {
		t.Fatalf("%d %s", w.Code, w.Body.String())
	}
}
func TestBucketConcurrent(t *testing.T) {
	now := time.Unix(0, 0)
	b := bucket{tokens: 2, capacity: 2, rate: 1, last: now}
	var accepted atomic.Int32
	var wg sync.WaitGroup
	for range 20 {
		wg.Go(func() {
			if b.allow(now) {
				accepted.Add(1)
			}
		})
	}
	wg.Wait()
	if accepted.Load() != 2 {
		t.Fatal(accepted.Load())
	}
	if !b.allow(now.Add(time.Second)) {
		t.Fatal("expected refill")
	}
}

nginx.conf ​

nginx
worker_processes 1;
pid logs/nginx.pid;
error_log logs/error.log;
events { worker_connections 256; }
http {
    access_log logs/access.log;
    limit_req_zone $binary_remote_addr zone=per_ip:1m rate=5r/s;
    upstream api {
        server 127.0.0.1:9000;
        keepalive 8;
    }
    server {
        listen 127.0.0.1:8081;
        server_name localhost;
        client_max_body_size 1m;
        location / {
            limit_req zone=per_ip burst=10 nodelay;
            limit_req_status 429;
            proxy_pass http://api;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-For $remote_addr;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header Forwarded "";
            proxy_connect_timeout 1s;
            proxy_send_timeout 5s;
            proxy_read_timeout 5s;
            proxy_buffering off;
            proxy_next_upstream off;
        }
    }
}

envoy.yaml ​

代码块 · 49 行 · 点击收起 / 展开
yaml
static_resources:
  listeners:
    - name: local_http
      address:
        socket_address: {address: 127.0.0.1, port_value: 8082}
      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
                use_remote_address: true
                xff_num_trusted_hops: 0
                stream_idle_timeout: 15s
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: api
                      domains: ['*']
                      routes:
                        - match: {prefix: /}
                          route: {cluster: api, timeout: 5s}
                http_filters:
                  - name: envoy.filters.http.local_ratelimit
                    typed_config:
                      '@type': type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
                      stat_prefix: local_rate_limit
                      token_bucket:
                        max_tokens: 10
                        tokens_per_fill: 5
                        fill_interval: 1s
                      filter_enabled:
                        default_value: {numerator: 100, denominator: HUNDRED}
                      filter_enforced:
                        default_value: {numerator: 100, denominator: HUNDRED}
                  - name: envoy.filters.http.router
                    typed_config:
                      '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  clusters:
    - name: api
      connect_timeout: 1s
      type: STATIC
      load_assignment:
        cluster_name: api
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address: {address: 127.0.0.1, port_value: 9000}

Caddyfile ​

caddyfile
{
    admin off
    auto_https off
}
http://127.0.0.1:8083 {
    bind 127.0.0.1
    reverse_proxy 127.0.0.1:9000 {
        transport http {
            dial_timeout 1s
            response_header_timeout 5s
        }
        flush_interval -1
    }
}

requirements.txt ​

text
PyYAML==6.0.3

verify.sh ​

sh
#!/bin/sh
set -eu
cd "$(dirname "$0")"
go version
go test ./...
go test -race ./...
go vet ./...
go run .
python3 - <<'PY'
import yaml
from pathlib import Path
v=yaml.safe_load(Path('envoy.yaml').read_text())
assert len(v['static_resources']['listeners']) == 1
print('Envoy YAML parsed locally; not Envoy schema validation')
PY

保留各书来源与许可 · 示例运行条件以正文为准