feat: configure gateway transport pools

This commit is contained in:
youfak 2026-08-07 15:27:17 +08:00
parent d4539da9c5
commit bda9cc03df
9 changed files with 228 additions and 1 deletions

View File

@ -16,6 +16,11 @@ gateway:
limits:
maxConcurrentConnections: 20000
requestsPerMinutePerClient: 60000
transport:
maxIdleConns: 20000
maxIdleConnsPerHost: 32
maxConnsPerHost: 32
tunnelBufferBytes: 32768
retry:
maxAttempts: 2
retryMethods: [GET, HEAD]

View File

@ -333,6 +333,16 @@ gateway:
auth: {mode: none}
limits:
maxConcurrentConnections: 50000
transport:
dialTimeout: 10s
handshakeTimeout: 15s
responseHeaderTimeout: 30s
idleConnTimeout: 90s
maxIdleConns: 20000
maxIdleConnsPerHost: 32
maxConnsPerHost: 32
tunnelBufferBytes: 32768
tunnelIdleTimeout: 5m
retry:
maxAttempts: 2
retryMethods: [GET, HEAD]
@ -358,6 +368,31 @@ gateway:
- 保留地址、CGNAT 与云元数据端点始终拒绝,不能通过私网/链路本地开关放行。
- `maxConcurrentConnections` 是入口准入上限,不是 Proxy 容量上限。
### 5.1 Gateway 本地传输层
**gateway.transport** 只配置每个 Gateway Worker 的本地 HTTP Transport 和 CONNECT
隧道 I/O不读取或写入 Redis、PostgreSQL、Provider也不参与热路径分配。修改
这些字段需要滚动重启对应 Gateway Worker 后生效。
- **dialTimeout**、**handshakeTimeout**、**responseHeaderTimeout**、
**idleConnTimeout**:分别限制拨号、上游 TLS/CONNECT 握手、HTTP 响应头和空闲
HTTP 连接的时长。
- **maxIdleConns**:单个本地 HTTP Transport 的总空闲连接上限。Gateway 分别为代理
转发和 direct fallback 建立 Transport最坏情况下两者都可能保有空闲连接。
- **maxIdleConnsPerHost**:单个上游 Host 的空闲 HTTP 连接上限。
- **maxConnsPerHost**:单个上游 Host 的 HTTP 活跃加空闲连接上限。它不替代
**limits.maxConcurrentConnections**,也不限制已经建立的 CONNECT 隧道。
- **tunnelBufferBytes**:每个活跃 CONNECT 隧道的每个复制方向使用一个缓冲区,内存
预算至少按 活跃隧道数 * 2 * tunnelBufferBytes 计算。
- **tunnelIdleTimeout**CONNECT 隧道双向没有数据活动时的最长存活时间。
所有字段均为可选项;数值 0 表示沿用进程默认值:拨号 10s、握手 15s、
响应头 30s、空闲 HTTP 连接 90s、总空闲连接 1024、每 Host 空闲连接
64、隧道缓冲 32768 字节、隧道空闲 5m。计数和时长不得为负数
maxIdleConnsPerHost 不得大于显式配置的 maxIdleConns。示例中的 20000/32
是高并发 HTTP 转发的起点,不是每秒请求量的换算公式,应同时按可用文件描述符、
上游限额和观测到的复用率调节。
## 6. Distribution
```yaml

View File

@ -66,6 +66,7 @@ type Listener struct {
Limits Limits `yaml:"limits"`
Retry Retry `yaml:"retry"`
StickySession StickySession `yaml:"stickySession"`
Transport GatewayTransport `yaml:"transport"`
DestinationPolicy DestinationPolicy `yaml:"destinationPolicy"`
}
@ -127,6 +128,20 @@ type StickySession struct {
MaxEntries int `yaml:"maxEntries"`
}
// GatewayTransport configures the local Gateway process connection pools and
// tunnel I/O. It intentionally contains no remote state or provider settings.
type GatewayTransport struct {
DialTimeout Duration `yaml:"dialTimeout"`
HandshakeTimeout Duration `yaml:"handshakeTimeout"`
ResponseHeaderTimeout Duration `yaml:"responseHeaderTimeout"`
IdleConnTimeout Duration `yaml:"idleConnTimeout"`
MaxIdleConns int `yaml:"maxIdleConns"`
MaxIdleConnsPerHost int `yaml:"maxIdleConnsPerHost"`
MaxConnsPerHost int `yaml:"maxConnsPerHost"`
TunnelBufferBytes int `yaml:"tunnelBufferBytes"`
TunnelIdleTimeout Duration `yaml:"tunnelIdleTimeout"`
}
type DestinationPolicy struct {
DenyPrivateNetworks *bool `yaml:"denyPrivateNetworks"`
DenyLoopback *bool `yaml:"denyLoopback"`

View File

@ -469,6 +469,51 @@ func TestValidateGatewayStickySession(t *testing.T) {
}
}
func TestValidateGatewayTransport(t *testing.T) {
t.Parallel()
cfg := mustLoadValidConfig(t)
cfg.Gateway.Transport = GatewayTransport{
DialTimeout: Duration(time.Second),
HandshakeTimeout: Duration(2 * time.Second),
ResponseHeaderTimeout: Duration(3 * time.Second),
IdleConnTimeout: Duration(time.Minute),
MaxIdleConns: 500,
MaxIdleConnsPerHost: 20,
MaxConnsPerHost: 10,
TunnelBufferBytes: 32 << 10,
TunnelIdleTimeout: Duration(5 * time.Minute),
}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate(valid gateway transport) error = %v", err)
}
cfg.Gateway.Transport.DialTimeout = Duration(-time.Second)
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "transport.dialTimeout") {
t.Fatalf("Validate(negative dial timeout) error = %v", err)
}
cfg.Gateway.Transport.DialTimeout = Duration(time.Second)
cfg.Gateway.Transport.MaxConnsPerHost = -1
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "transport.maxConnsPerHost") {
t.Fatalf("Validate(negative max connections) error = %v", err)
}
cfg.Gateway.Transport.MaxConnsPerHost = 10
cfg.Gateway.Transport.MaxIdleConns = 19
cfg.Gateway.Transport.MaxIdleConnsPerHost = 20
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "maxIdleConnsPerHost") {
t.Fatalf("Validate(inconsistent idle pool) error = %v", err)
}
cfg.Gateway.Transport.MaxIdleConns = 500
cfg.Gateway.Transport.MaxIdleConnsPerHost = 20
cfg.Distribution.Transport = GatewayTransport{MaxConnsPerHost: 10}
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "distribution transport") {
t.Fatalf("Validate(distribution transport) error = %v", err)
}
}
func TestValidateMetricsListener(t *testing.T) {
t.Parallel()
cfg := mustLoadValidConfig(t)

View File

@ -113,6 +113,9 @@ func Validate(cfg *Config) error {
if err := validateGatewayStickySession(cfg.Gateway); err != nil {
return err
}
if err := validateGatewayTransport(cfg.Gateway); err != nil {
return err
}
}
if cfg.Distribution.Enabled {
if err := validateDistributionClientPolicies(cfg.Distribution.Auth, cfg.Upstreams); err != nil {
@ -121,6 +124,9 @@ func Validate(cfg *Config) error {
if cfg.Distribution.StickySession.Enabled {
return fmt.Errorf("validate distribution stickySession: only supported on gateway")
}
if gatewayTransportConfigured(cfg.Distribution.Transport) {
return fmt.Errorf("validate distribution transport: only supported on gateway")
}
clientIdentificationMode := cfg.Distribution.ClientIdentification.Mode
if clientIdentificationMode == "" {
clientIdentificationMode = "sourceIP"
@ -154,6 +160,9 @@ func Validate(cfg *Config) error {
if cfg.Admin.Enabled && cfg.Admin.StickySession.Enabled {
return fmt.Errorf("validate admin stickySession: only supported on gateway")
}
if cfg.Admin.Enabled && gatewayTransportConfigured(cfg.Admin.Transport) {
return fmt.Errorf("validate admin transport: only supported on gateway")
}
return nil
}
@ -177,6 +186,53 @@ func validateGatewayStickySession(listener Listener) error {
return nil
}
func validateGatewayTransport(listener Listener) error {
item := listener.Transport
for _, timeout := range []struct {
name string
value Duration
}{
{name: "dialTimeout", value: item.DialTimeout},
{name: "handshakeTimeout", value: item.HandshakeTimeout},
{name: "responseHeaderTimeout", value: item.ResponseHeaderTimeout},
{name: "idleConnTimeout", value: item.IdleConnTimeout},
{name: "tunnelIdleTimeout", value: item.TunnelIdleTimeout},
} {
if timeout.value.Value() < 0 {
return fmt.Errorf("validate gateway transport.%s: must be non-negative", timeout.name)
}
}
for _, limit := range []struct {
name string
value int
}{
{name: "maxIdleConns", value: item.MaxIdleConns},
{name: "maxIdleConnsPerHost", value: item.MaxIdleConnsPerHost},
{name: "maxConnsPerHost", value: item.MaxConnsPerHost},
{name: "tunnelBufferBytes", value: item.TunnelBufferBytes},
} {
if limit.value < 0 || limit.value > MaximumPoolSize {
return fmt.Errorf("validate gateway transport.%s: must be in [0, %d]", limit.name, MaximumPoolSize)
}
}
if item.MaxIdleConns > 0 && item.MaxIdleConnsPerHost > item.MaxIdleConns {
return fmt.Errorf("validate gateway transport.maxIdleConnsPerHost: must not exceed maxIdleConns")
}
return nil
}
func gatewayTransportConfigured(item GatewayTransport) bool {
return item.DialTimeout != 0 ||
item.HandshakeTimeout != 0 ||
item.ResponseHeaderTimeout != 0 ||
item.IdleConnTimeout != 0 ||
item.MaxIdleConns != 0 ||
item.MaxIdleConnsPerHost != 0 ||
item.MaxConnsPerHost != 0 ||
item.TunnelBufferBytes != 0 ||
item.TunnelIdleTimeout != 0
}
func validCanonicalHeaderName(value string) bool {
if value == "" || http.CanonicalHeaderKey(value) != value {
return false

View File

@ -156,7 +156,7 @@ func newRuntime(ctx context.Context, configuration *config.Config, options Optio
storeOptions.CapacityInvariantObserver = collector
}
store := snapshot.NewStoreWithOptions(options.ClusterID, options.WorkerID, storeOptions)
proxyTransport := transport.New(transport.Config{}, snapshotCredentialResolver{store: store})
proxyTransport := transport.New(gatewayTransportConfig(configuration.Gateway), snapshotCredentialResolver{store: store})
outcomes, err := gatewayOutcome.NewQueue(gatewayOutcome.QueueOptions{
Capacity: defaultOutcomeQueueCapacity, MaxBatch: min(defaultOutcomeBatchSize, configuration.ControlPlane.MaxRuntimeCounters),
Metrics: outcomeMetrics,
@ -243,6 +243,21 @@ func newRuntime(ctx context.Context, configuration *config.Config, options Optio
return &runtime{connection: connection, group: group}, nil
}
func gatewayTransportConfig(listener config.Listener) transport.Config {
item := listener.Transport
return transport.Config{
DialTimeout: item.DialTimeout.Value(),
HandshakeTimeout: item.HandshakeTimeout.Value(),
ResponseHeaderTimeout: item.ResponseHeaderTimeout.Value(),
IdleConnTimeout: item.IdleConnTimeout.Value(),
MaxIdleConns: item.MaxIdleConns,
MaxIdleConnsPerHost: item.MaxIdleConnsPerHost,
MaxConnsPerHost: item.MaxConnsPerHost,
TunnelBufferBytes: item.TunnelBufferBytes,
TunnelIdleTimeout: item.TunnelIdleTimeout.Value(),
}
}
type generatedRuntimeClient struct {
client controlplanev1.WorkerControlPlaneClient
}

View File

@ -145,6 +145,34 @@ func TestControlPlaneTransportRequiresDedicatedGatewayTLS(t *testing.T) {
}
}
func TestGatewayTransportConfigMapsListenerSettings(t *testing.T) {
t.Parallel()
got := gatewayTransportConfig(config.Listener{Transport: config.GatewayTransport{
DialTimeout: config.Duration(time.Second),
HandshakeTimeout: config.Duration(2 * time.Second),
ResponseHeaderTimeout: config.Duration(3 * time.Second),
IdleConnTimeout: config.Duration(time.Minute),
MaxIdleConns: 500,
MaxIdleConnsPerHost: 20,
MaxConnsPerHost: 10,
TunnelBufferBytes: 32 << 10,
TunnelIdleTimeout: config.Duration(5 * time.Minute),
}})
if got.DialTimeout != time.Second ||
got.HandshakeTimeout != 2*time.Second ||
got.ResponseHeaderTimeout != 3*time.Second ||
got.IdleConnTimeout != time.Minute ||
got.MaxIdleConns != 500 ||
got.MaxIdleConnsPerHost != 20 ||
got.MaxConnsPerHost != 10 ||
got.TunnelBufferBytes != 32<<10 ||
got.TunnelIdleTimeout != 5*time.Minute {
t.Fatalf("gatewayTransportConfig() = %+v", got)
}
}
func TestSnapshotReadinessRequiresCurrentSnapshot(t *testing.T) {
t.Parallel()

View File

@ -40,6 +40,7 @@ type Config struct {
IdleConnTimeout time.Duration
MaxIdleConns int
MaxIdleConnsPerHost int
MaxConnsPerHost int
MaxErrorResponseBytes int64
MaxResponseHeaderBytes int64
TunnelBufferBytes int
@ -120,6 +121,7 @@ func newHTTPTransport(config Config, proxy func(*http.Request) (*url.URL, error)
ForceAttemptHTTP2: true,
MaxIdleConns: config.MaxIdleConns,
MaxIdleConnsPerHost: config.MaxIdleConnsPerHost,
MaxConnsPerHost: config.MaxConnsPerHost,
IdleConnTimeout: config.IdleConnTimeout,
TLSHandshakeTimeout: config.HandshakeTimeout,
ResponseHeaderTimeout: config.ResponseHeaderTimeout,

View File

@ -88,6 +88,32 @@ func TestRoundTripDirectForwardsWithoutProxyAuthorization(t *testing.T) {
}
}
func TestNewAppliesConfiguredConnectionPoolLimits(t *testing.T) {
t.Parallel()
client := New(Config{
MaxIdleConns: 256,
MaxIdleConnsPerHost: 24,
MaxConnsPerHost: 12,
}, nil)
t.Cleanup(client.CloseIdleConnections)
for name, item := range map[string]*http.Transport{
"proxy": client.client,
"direct": client.direct,
} {
if item.MaxIdleConns != 256 {
t.Fatalf("%s MaxIdleConns = %d, want 256", name, item.MaxIdleConns)
}
if item.MaxIdleConnsPerHost != 24 {
t.Fatalf("%s MaxIdleConnsPerHost = %d, want 24", name, item.MaxIdleConnsPerHost)
}
if item.MaxConnsPerHost != 12 {
t.Fatalf("%s MaxConnsPerHost = %d, want 12", name, item.MaxConnsPerHost)
}
}
}
func TestOpenDirectTunnelDialsTarget(t *testing.T) {
t.Parallel()