touka/docs/reverse-proxy.md
wjqserver e4d3eed379 feat: redesign server startup around Run options
Replace the old RunShutdown and RunTLS style entry points with a single Run(opts...) API for v1. Add focused startup semantics tests, keep TLS and graceful shutdown independent, ensure sibling servers are cleaned up on startup failure, and update docs to match the new option-based startup model.
2026-04-07 17:44:55 +08:00

15 KiB
Raw Permalink Blame History

反向代理

Touka 内置了反向代理能力,可以直接把某一组请求转发到后端服务,同时保留 Touka 的路由、中间件与统一错误处理风格。

touka.ReverseProxy 返回一个 HandlerFunc,因此它可以像普通路由处理器一样直接挂到 GETANY、路由组等位置。

最简单的用法

package main

import (
    "log"
    "net/url"

    "github.com/infinite-iroha/touka"
)

func main() {
    r := touka.Default()

    target, err := url.Parse("http://127.0.0.1:9000")
    if err != nil {
        log.Fatal(err)
    }

    r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
        Target: target,
    }))

    _ = r.Run(touka.WithAddr(":8080"))
}

当客户端访问 http://127.0.0.1:8080/api/users 时,请求会被转发到 http://127.0.0.1:9000/api/users

带基础路径的代理

如果目标服务部署在一个子路径下,可以直接把目标地址写成带路径的 URL

target, _ := url.Parse("http://127.0.0.1:9000/backend")

r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Target: target,
}))

此时:

  • /api/users 会转发到 /backend/api/users
  • /api/orders?id=10 会转发到 /backend/api/orders?id=10

目标 URL 自身携带的查询参数也会被保留并与原请求查询参数合并。 合并后的出站查询串会再经过一次规范化处理,因此某些非标准分隔符(例如 ;)或非法参数片段可能被重编码、折叠或直接丢弃。 这是为了尽量让代理链各跳对查询参数的解析结果保持一致,并减少参数走私这类解析歧义风险。

配置项说明

type ReverseProxyConfig struct {
    Target  *url.URL
    Targets []string

    LoadBalancing ReverseProxyLoadBalancingConfig
    PassiveHealth ReverseProxyPassiveHealthConfig

    Transport     http.RoundTripper
    FlushInterval time.Duration
    BufferPool    BufferPool
    AllowH2CUpstream bool

    ModifyRequest  func(*http.Request)
    ModifyResponse func(*http.Response) error
    ErrorHandler   func(http.ResponseWriter, *http.Request, error)

    ForwardedHeaders ForwardedHeadersPolicy
    ForwardedBy      string
    Via              string
    PreserveHost     bool
}

Target

Targets 二选一。表示单个后端目标地址,至少需要提供 schemehost

target, _ := url.Parse("http://backend:9000")

Targets

可选。用于配置多个后端目标地址。

  • TargetTargets 互斥,只能使用其中一种
  • Targets 的每一项都必须是完整 URL
  • 每个 target 仍然可以自带 base path 和 query
r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Targets: []string{
        "http://127.0.0.1:9001/base?from=a",
        "http://127.0.0.1:9002/base?from=b",
    },
}))

这意味着不同 upstream 仍然可以保留各自的路径前缀和固定查询参数。

LoadBalancing

用于配置 upstream 选择策略和重试行为。

type ReverseProxyLoadBalancingConfig struct {
    Policy      ReverseProxyLBPolicy
    Retries     int
    TryDuration time.Duration
    TryInterval time.Duration
}

当前内置策略:

  • touka.LBRandom()
  • touka.LBRoundRobin()
  • touka.LBFirst()
  • touka.LBLeastConn()
  • touka.LBIPHash()
  • touka.LBClientIPHash()
  • touka.LBURIHash()
  • touka.LBHeader("X-Upstream", fallback)
  • touka.LBQuery("tenant", fallback)

其中:

  • LBFirst() 适合主备/故障转移顺序
  • LBHeader / LBQuery 只有在对应 header/query 缺失时才会走 fallback
  • 如果 LBHeader / LBQuery 没有显式 fallback则默认回退到 LBRandom()
r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Targets: []string{
        "http://127.0.0.1:9001",
        "http://127.0.0.1:9002",
    },
    LoadBalancing: touka.ReverseProxyLoadBalancingConfig{
        Policy: touka.LBHeader("X-Upstream", touka.LBFirst()),
        Retries: 1,
    },
}))

重试说明:

  • 只对未开始收到上游响应的失败进行重试
  • 默认仅对 RFC 定义的安全方法(GET / HEAD / OPTIONS / TRACE)重试
  • Retries 表示额外重试次数
  • TryDuration 表示总尝试时间预算;如果配置了它,会优先于重试次数控制停止时机
  • TryInterval 表示两次重试之间的等待间隔

PassiveHealth

用于配置被动健康检查。它不会后台探测 upstream而是根据真实代理请求的失败结果临时把某个 upstream 视为不健康。

type ReverseProxyPassiveHealthConfig struct {
    FailDuration    time.Duration
    MaxFails        int
    UnhealthyStatus []int
}
  • FailDuration > 0 时启用被动健康跟踪
  • MaxFails <= 0 时默认按 1 处理
  • UnhealthyStatus 中的状态码会被记为一次失败,但当前请求仍会先收到该响应;后续请求才会绕过这个 upstream
r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Targets: []string{
        "http://127.0.0.1:9001",
        "http://127.0.0.1:9002",
    },
    LoadBalancing: touka.ReverseProxyLoadBalancingConfig{
        Policy: touka.LBFirst(),
    },
    PassiveHealth: touka.ReverseProxyPassiveHealthConfig{
        FailDuration:    time.Minute,
        UnhealthyStatus: []int{http.StatusServiceUnavailable},
    },
}))

AllowH2CUpstream

允许代理使用未加密 HTTP/2h2chttp:// upstream 通信。

  • 默认关闭
  • 这是一个显式配置项
  • 启用后Touka 会为该 upstream 使用 h2c prior-knowledge 方式连接上游
  • 这意味着上游本身也必须显式支持 h2c它不是“先试 h2c失败再自动回退到 h1”的协商模式
r.GET("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Target:           target,
    AllowH2CUpstream: true,
}))

对于下游 HTTP/2 extended CONNECT websocket 场景Touka 会只在该特殊桥接路径上强制与上游使用 HTTP/1.1 websocket upgrade以匹配 Caddy 风格的桥接语义;普通 HTTP 请求不会因为这个特性而被强制降级为 HTTP/1.1。

Transport

可选。用于自定义底层转发所使用的 http.RoundTripper

如果留空,则默认使用 http.DefaultTransport

proxyTransport := &http.Transport{
    MaxIdleConns:        100,
    MaxIdleConnsPerHost: 20,
}

r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Target:    target,
    Transport: proxyTransport,
}))

FlushInterval

控制代理在复制响应体时的主动刷新间隔:

  • 0:不额外定时刷新
  • > 0:按指定间隔刷新
  • < 0:每次写入后立即刷新

对于 SSE 和无 Content-Length 的流式响应Touka 会自动立即刷新,不依赖该配置。

BufferPool

可选。用于为响应体复制过程提供可复用的字节缓冲区,以减少大响应或高并发代理场景下的临时内存分配。

如果留空Touka 会在复制响应体时按需分配默认缓冲区。

type bytePool struct {
    pool sync.Pool
}

func (p *bytePool) Get() []byte {
    if buf, ok := p.pool.Get().([]byte); ok {
        return buf
    }
    return make([]byte, 32*1024)
}

func (p *bytePool) Put(buf []byte) {
    if cap(buf) >= 32*1024 {
        p.pool.Put(buf[:32*1024])
    }
}

proxyPool := &bytePool{}

r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Target:     target,
    BufferPool: proxyPool,
}))

通常只有在您已经观察到明显的分配压力,或代理的响应体较大、吞吐较高时,才需要专门配置它。

ModifyRequest

在请求真正发往后端前,对出站请求做最后修改。

如果启用了多 upstream 重试,ModifyRequest 可能会在同一个客户端请求里被调用多次:每一次实际发往 upstream 的尝试都会重新构造一份请求并再次执行它。因此,这个回调最好保持幂等,不要依赖“只会执行一次”的副作用。

常见用途:

  • 覆盖 Host
  • 增加鉴权头
  • 重写路径
  • 注入内部追踪头
r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Target: target,
    ModifyRequest: func(req *http.Request) {
        req.Header.Set("X-Internal-Token", "gateway-token")
    },
}))

ModifyResponse

在后端返回响应后、写回客户端前,对响应做额外处理。

注意:ModifyResponse 也会作用于 101 Switching Protocols 响应。 如果该代理路由需要转发 WebSocket 或其他 Upgrade 流量,请不要在这里消费、完全缓冲,或替换 resp.Body 为只读对象;后续升级流程仍然要求它保留 io.ReadWriteCloser 能力。 更稳妥的做法是对 101 响应直接跳过这类处理。

r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Target: target,
    ModifyResponse: func(resp *http.Response) error {
        if resp.StatusCode == http.StatusSwitchingProtocols {
            return nil
        }
        resp.Header.Set("X-Proxy", "touka")
        return nil
    },
}))

如果该函数返回错误,会转入 ErrorHandler 或默认的 502 Bad Gateway 处理流程。

ErrorHandler

用于处理连接后端失败、协议升级失败、ModifyResponse 返回错误等情况。

r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Target: target,
    ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
        w.WriteHeader(http.StatusBadGateway)
        _, _ = w.Write([]byte("upstream unavailable"))
    },
}))

PreserveHost

默认情况下,代理请求的 Host 会跟随后端目标地址。

如果设置为 true,则会保留客户端原始 Host

r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Target:       target,
    PreserveHost: true,
}))

这在某些依赖原始域名进行路由或租户识别的后端服务中会比较有用。

转发头策略

Touka 支持两类常见的代理转发头:

  • 兼容性更好的 X-Forwarded-*
  • 标准化的 ForwardedRFC 7239

可选值:

const (
    ForwardedBoth ForwardedHeadersPolicy = iota
    ForwardedNone
    ForwardedXForwardedOnly
    ForwardedRFC7239Only
)

推荐默认使用 ForwardedBoth

r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Target:           target,
    ForwardedHeaders: touka.ForwardedBoth,
    ForwardedBy:      "_gateway-1",
    Via:              "edge-1",
}))

如果您配置了 ForwardedBy,它必须是一个符合 RFC 7239 的 node identifier。

  • IPv4203.0.113.43
  • IPv6 / 带端口:[2001:db8::17]:443
  • 匿名标识:_gateway-1
  • 未知:unknown

gateway-1 这类普通 token 不再被视为合法的 by= 值。

Via 不是“留空即禁用”的开关。当前实现中:

  • 如果 Via 非空,则使用该值追加 Via
  • 如果 Via 为空,则会回退到固定值 touka-engine

因此,把 Via 留空时,发送出去的请求仍会包含 Via 头,只是使用默认标识 touka-engine

如果您希望上游清楚区分不同入口、环境或网关实例,仍然建议显式设置一个稳定且可公开暴露的代理标识,例如:

r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Target: target,
    Via:    "edge-gateway",
}))

当前版本没有提供“完全禁用追加 Via”的单独配置项因此不要把空字符串当作关闭手段。

Touka 会如何处理这些头?

Touka 会尽量遵循代理链语义:

  • 已有的 X-Forwarded-For 会保留,并在末尾追加当前 hop 的客户端 IP
  • 已有的 Forwarded 会保留,并在末尾追加当前 hop 的条目
  • 已有的 X-Forwarded-HostX-Forwarded-Proto 会优先保留;如果缺失,则由当前请求补齐
  • Via 会追加当前代理标识

这意味着在 Touka 前面还有一层可信代理(如 Nginx、Traefik、Cloudflare、网关上游服务仍然可以看到完整的代理链。

如果您不信任客户端传入的这些头,请在进入 ReverseProxy 之前自行清理,或在 ModifyRequest 中显式重写。

协议升级与流式响应

Touka 的反向代理实现支持以下能力:

  • CONNECT 隧道转发HTTP/1.x
  • HTTP/2 extended CONNECT
  • Connection: Upgrade / Upgrade 协议升级转发
  • WebSocket 等 101 Switching Protocols 场景
  • SSEServer-Sent Events立即刷新
  • Trailer 透传
  • 1xx 响应透传
  • TRACE / OPTIONS 上的 Max-Forwards 递减与本地终止处理

例如,代理 WebSocket 服务:

target, _ := url.Parse("http://127.0.0.1:9001")

r.ANY("/ws/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
    Target: target,
}))

Hop-by-hop 头处理

根据 HTTP 代理语义Touka 在转发时会移除连接级别的 hop-by-hop 头,避免把只应作用于单跳连接的头继续传给下游。

典型包括:

  • Connection
  • Proxy-Connection
  • Keep-Alive
  • Proxy-Authenticate
  • Proxy-Authorization
  • TE
  • Trailer
  • Transfer-Encoding
  • Upgrade

同时若请求本身是合法的协议升级请求Touka 会在剥离后重新补回必要的 Connection: UpgradeUpgrade 头。

一个更完整的例子

package main

import (
    "log"
    "net/http"
    "net/url"
    "time"

    "github.com/infinite-iroha/touka"
)

func main() {
    r := touka.Default()

    target, err := url.Parse("http://127.0.0.1:9000")
    if err != nil {
        log.Fatal(err)
    }

    r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{
        Target:           target,
        ForwardedHeaders: touka.ForwardedBoth,
        ForwardedBy:      "_gateway-1",
        Via:              "gateway-1",
        FlushInterval:    100 * time.Millisecond,
        ModifyRequest: func(req *http.Request) {
            req.Header.Set("X-Gateway", "touka")
        },
        ModifyResponse: func(resp *http.Response) error {
            resp.Header.Set("X-Proxy", "touka")
            return nil
        },
        ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
            w.WriteHeader(http.StatusBadGateway)
            _, _ = w.Write([]byte("bad gateway"))
        },
    }))

    if err := r.Run(touka.WithAddr(":8080"), touka.WithGracefulShutdown(10*time.Second)); err != nil {
        log.Fatal(err)
    }
}

SetForwardByClientIP 的关系

ReverseProxy 负责把请求转发给后端,并维护代理链头。

SetForwardByClientIP / SetRemoteIPHeaders 是 Touka 在接收请求时,用于解析当前请求客户端 IP 的逻辑。

两者通常会一起出现,但解决的是两个不同方向的问题:

  • ReverseProxy:出站转发
  • SetForwardByClientIP:入站解析

如果您的 Touka 本身就部署在其他代理之后,建议同时正确配置这两部分。