p2c: inflight counters reset on configuration reload, degrading to random selection for long-lived connections
Welcome!
- Yes, I've searched similar issues on GitHub and didn't find any.
- Yes, I've searched similar issues on the Traefik community forum and didn't find any.
What did you do?
Used strategy: p2c for a service whose backends hold long-lived connections
(WebSockets, multi-day lifetimes), then triggered a dynamic configuration
reload while those connections were still open.
Minimal reproduction with the file provider (no Kubernetes needed):
- Start three backends that accept a request and never return from the handler, simulating a long-lived connection.
- Run Traefik with the static and dynamic configuration shown below
(
watch: trueon the file provider). - Open a number of connections and leave them open. They are distributed by
p2c, and
inflightgrows per backend as expected. - Append an unrelated router to
dynamic.ymlto trigger a reload. Do not touch theappservice or its servers, and do not close any connection. - Open a second batch of connections.
Expected: the second batch is placed according to the connections still open from the first batch, i.e. concentrated on the least-loaded backends.
What did you see instead?
After the reload, every backend's inflight counter is back to 0 even though
all connections from step 3 are still established. The second batch is
distributed uniformly at random across all backends, ignoring the existing
skew entirely.
The cause is in pkg/server/service/loadbalancer/p2c/p2c.go. The counter
lives on the handler, which lives on the Balancer:
type namedHandler struct {
http.Handler
name string
inflight atomic.Int64
}
func (h *namedHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
h.inflight.Add(1)
defer h.inflight.Add(-1)
h.Handler.ServeHTTP(rw, req)
}
A dynamic configuration update builds a new Balancer via p2c.New() with
fresh namedHandler instances, all starting at inflight == 0. Connections
opened before the reload are still blocked inside the previous handler's
ServeHTTP, so they keep incrementing the old, now-unreachable counters. The
new balancer has no knowledge of them.
Selection then ties on zero for every pair, and the strict comparison in
nextServer() falls through to h1:
// Ensure h1 has fewer inflight requests than h2.
if h2.inflight.Load() < h1.inflight.Load() {
return h2, nil
}
return h1, nil
Since h1 = healthy[b.rand.Intn(len(healthy))], the result is a uniform
random pick. p2c silently degrades to random selection until enough new
connections accumulate to break the ties — which, for connections measured in
days, effectively means never.
Why this matters in practice
With the Kubernetes providers, any endpoint change reloads the configuration. A pod restart or a deploy therefore does two things at once: it creates connection skew (the evicted pod's clients reconnect) and it erases the state p2c needs to correct that skew. The reconnect wave is scattered uniformly instead of being steered onto the empty pods.
What version of Traefik are you using?
v3.7.13, and reproduced on current master (built locally).
What is your environment & configuration?
Reproduction (file provider, no Kubernetes):
Static:
log:
level: DEBUG
entryPoints:
web:
address: ":8000"
providers:
file:
filename: dynamic.yml
watch: true
Dynamic:
http:
routers:
app:
rule: PathPrefix(`/`)
service: app
services:
app:
loadBalancer:
strategy: p2c
servers:
- url: "http://127.0.0.1:9001"
- url: "http://127.0.0.1:9002"
- url: "http://127.0.0.1:9003"
Originally observed with the kubernetesCRD provider: IngressRoute with
strategy: p2c, nativeLB: false, 3 backend pods, Traefik scaled to 1
replica to rule out per-instance counter splitting.
If applicable, please paste the log output in DEBUG level
No response
Source: traefik/traefik