Skip to content

Commit b3d05ca

Browse files
committed
feat(interceptor): add direct-pod routing for cold-start requests
- Adds KEDA_HTTP_DIRECT_POD_ROUTING (disabled|cold-start-only); when set, rewrites upstream URL to a ready pod's ip:port after scale-from-zero - ReadyEndpointsCache now tracks (ip, port) pairs keyed by named port via EndpointSlice updates; WaitForReady returns a podHost alongside isColdStart - Empty podHost (no portName match) leaves upstream URL unchanged, falling back to ClusterIP as before - TransportPool keyed on (responseHeaderTimeout, serverName) with per-transport TLSClientConfig.ServerName for correct SNI per pod - Routing middleware stores intended TLS hostname in context before any URL rewrite so SNI always points to the original service Signed-off-by: Atharva Pakade <pakade310@gmail.com>
1 parent b9b764f commit b3d05ca

15 files changed

Lines changed: 1326 additions & 119 deletions

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@ This changelog keeps track of work items that have been completed and are ready
3232

3333
### New
3434

35-
- **General**: TODO ([#TODO](https://github.com/kedacore/http-add-on/issues/TODO))
35+
- **Interceptor**: Add `KEDA_HTTP_DIRECT_POD_ROUTING` environment variable (`disabled` | `cold-start-only`). When set to `cold-start-only`, the interceptor routes cold-start requests directly to a ready pod IP instead of through the service ClusterIP, reducing latency when kube-proxy rules are slow to propagate. ([#1473](https://github.com/kedacore/http-add-on/issues/1473))
3636

3737
### Improvements
3838

39-
- **General**: TODO ([#TODO](https://github.com/kedacore/http-add-on/issues/TODO))
39+
- **Interceptor**: `ReadyEndpointsCache` now tracks full `(ip, port)` pairs per named port from EndpointSlices, enabling direct-pod routing (replaces the previous bool-only ready state). ([#1473](https://github.com/kedacore/http-add-on/issues/1473))
40+
- **Interceptor**: `TransportPool` now keys on `(responseHeaderTimeout, serverName)` and applies TLS `ServerName` per transport, enabling correct SNI when the upstream URL is rewritten to a pod IP. ([#1473](https://github.com/kedacore/http-add-on/issues/1473))
41+
- **Interceptor**: TLS server name is captured in context by the routing middleware before any URL rewrites, so downstream transports always use the original service hostname for SNI. ([#1473](https://github.com/kedacore/http-add-on/issues/1473))
4042

4143
### Fixes
4244

interceptor/config/serving.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,24 @@
11
package config
22

33
import (
4+
"fmt"
45
"time"
56

67
"github.com/caarlos0/env/v11"
78
)
89

10+
// DirectPodRoutingMode controls whether and when the interceptor routes requests
11+
// directly to a ready pod IP instead of through the ClusterIP service.
12+
type DirectPodRoutingMode string
13+
14+
const (
15+
// DirectPodRoutingDisabled never bypasses the ClusterIP service (default).
16+
DirectPodRoutingDisabled DirectPodRoutingMode = "disabled"
17+
// DirectPodRoutingColdStartOnly bypasses the ClusterIP service only on cold
18+
// starts, reducing latency when kube-proxy rules are slow to propagate.
19+
DirectPodRoutingColdStartOnly DirectPodRoutingMode = "cold-start-only"
20+
)
21+
922
// Serving is configuration for how the interceptor serves the proxy
1023
// and admin server
1124
type Serving struct {
@@ -53,10 +66,21 @@ type Serving struct {
5366
EnableColdStartHeader bool `env:"KEDA_HTTP_ENABLE_COLD_START_HEADER" envDefault:"true"`
5467
// LogRequests enables/disables logging of incoming requests
5568
LogRequests bool `env:"KEDA_HTTP_LOG_REQUESTS" envDefault:"false"`
69+
// DirectPodRouting controls when the interceptor routes directly to a pod IP
70+
// instead of the ClusterIP service. Valid values: "disabled", "cold-start-only".
71+
DirectPodRouting DirectPodRoutingMode `env:"KEDA_HTTP_DIRECT_POD_ROUTING" envDefault:"disabled"`
5672
}
5773

5874
// MustParseServing parses standard configs and returns the
5975
// newly created config. It panics if parsing fails.
6076
func MustParseServing() Serving {
61-
return env.Must(env.ParseAs[Serving]())
77+
s := env.Must(env.ParseAs[Serving]())
78+
switch s.DirectPodRouting {
79+
case DirectPodRoutingDisabled, DirectPodRoutingColdStartOnly:
80+
// valid
81+
default:
82+
panic(fmt.Sprintf("invalid KEDA_HTTP_DIRECT_POD_ROUTING value %q: must be %q or %q",
83+
s.DirectPodRouting, DirectPodRoutingDisabled, DirectPodRoutingColdStartOnly))
84+
}
85+
return s
6286
}

interceptor/handler/upstream.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func (uh *Upstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6868
}
6969
}
7070

71-
transport := uh.transportPool.Get(responseHeaderTimeout)
71+
transport := uh.transportPool.Get(responseHeaderTimeout, util.UpstreamServerNameFromContext(ctx))
7272

7373
var rt http.RoundTripper = transport
7474
if uh.tracingCfg.Enabled {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package middleware
2+
3+
const (
4+
schemeHTTP = "http"
5+
schemeHTTPS = "https"
6+
)

interceptor/middleware/endpoint_resolver.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const defaultFallbackReadinessTimeout = 30 * time.Second
1818
type EndpointResolverConfig struct {
1919
ReadinessTimeout time.Duration
2020
EnableColdStartHeader bool
21+
DirectPodOnColdStart bool // route to pod IP directly during cold start
2122
}
2223

2324
type EndpointResolver struct {
@@ -64,7 +65,7 @@ func (er *EndpointResolver) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6465
}
6566

6667
serviceKey := ir.Namespace + "/" + ir.Spec.Target.Service
67-
isColdStart, err := er.readyCache.WaitForReady(waitCtx, serviceKey)
68+
isColdStart, podHost, err := er.readyCache.WaitForReady(waitCtx, serviceKey, ir.Spec.Target.PortName)
6869
if err != nil {
6970
// No fallback, return an error
7071
if !hasFallback {
@@ -90,12 +91,30 @@ func (er *EndpointResolver) ServeHTTP(w http.ResponseWriter, r *http.Request) {
9091
// Fall back to alternate upstream.
9192
fallbackURL := util.FallbackURLFromContext(ctx)
9293
ctx = util.ContextWithUpstreamURL(ctx, fallbackURL)
94+
// Update SNI to the fallback service hostname for TLS upstreams so the
95+
// transport uses the correct server name instead of the primary service's.
96+
// For non-TLS fallbacks the context may still hold the primary service's
97+
// server name, but the transport ignores it for plain HTTP — no update needed.
98+
if fallbackURL.Scheme == schemeHTTPS {
99+
ctx = util.ContextWithUpstreamServerName(ctx, fallbackURL.Hostname())
100+
}
93101
r = r.WithContext(ctx)
94-
}
102+
} else {
103+
// isColdStart is only meaningful when the backend resolved without errors
104+
if er.cfg.EnableColdStartHeader {
105+
w.Header().Set(kedahttp.HeaderColdStart, strconv.FormatBool(isColdStart))
106+
}
95107

96-
// isColdStart is only meaningful when the backend resolved without errors
97-
if err == nil && er.cfg.EnableColdStartHeader {
98-
w.Header().Set(kedahttp.HeaderColdStart, strconv.FormatBool(isColdStart))
108+
// Cold-start direct-to-pod routing: rewrites upstream to a pod IP, reducing latency when kube-proxy rules are slow to propagate.
109+
// TLS SNI uses the original service hostname captured in context. Empty podHost leaves the upstream URL unchanged.
110+
if isColdStart && er.cfg.DirectPodOnColdStart && podHost != "" {
111+
if upstreamURL := util.UpstreamURLFromContext(ctx); upstreamURL != nil {
112+
podURL := *upstreamURL
113+
podURL.Host = podHost
114+
ctx = util.ContextWithUpstreamURL(ctx, &podURL)
115+
r = r.WithContext(ctx)
116+
}
117+
}
99118
}
100119

101120
er.next.ServeHTTP(w, r)

0 commit comments

Comments
 (0)