ingress: expose the load-balancer strategy on Ingress annotations

The strategy was already honoured by dynamic config and by every other
provider; only the Ingress provider had no way to ask for it, so a plain
Ingress could never select anything but wrr.

That gap is why api.lux.network cannot pin a client to one backend. Sticky is
cookie-based, and a cross-origin JSON-RPC client — a wallet, a dapp on another
domain, curl — never sends the cookie, so cookie stickiness cannot pin the
clients that most need pinning. hrw hashes the client address instead: one
client stays on one server, and distinct clients spread over every server.

  ingress.kubernetes.io/service.strategy: hrw

Absent stays empty rather than defaulting here — the empty value is what
service.go maps to wrr, so stamping a value at this layer would silently
change every existing Ingress.

NOTE hrw.New hardcodes ip.RemoteAddrStrategy, so behind a SNATing cloud LB
RemoteAddr is the LB, not the client, and hrw would hash one value for
everyone. PROXY protocol is therefore a prerequisite, not an optimisation.
This commit is contained in:
2026-08-10 12:51:25 -07:00
parent 06e232242c
commit ce76a09285
3 changed files with 53 additions and 0 deletions
@@ -50,6 +50,17 @@ type ServiceIng struct {
Sticky *dynamic.Sticky `json:"sticky,omitempty" label:"allowEmpty"`
NativeLB *bool `json:"nativeLB,omitempty"`
NodePortLB bool `json:"nodePortLB,omitempty"`
// Strategy selects the load-balancing algorithm across the service's
// servers: wrr (default), p2c, hrw or leasttime.
//
// hrw is the reason this is exposed. Sticky is cookie-based, and a
// cross-origin JSON-RPC client — a wallet, a dapp on another domain, curl —
// does not send cookies, so cookie stickiness cannot pin the clients that
// most need pinning. hrw hashes the client address instead, which both pins
// one client to one server AND spreads distinct clients over every server.
// The strategy was already honoured by the dynamic configuration and by
// every other provider; only the Ingress provider had no way to ask for it.
Strategy dynamic.BalancerStrategy `json:"strategy,omitempty"`
}
// SetDefaults sets the default values.
@@ -283,3 +283,44 @@ func Test_convertAnnotations(t *testing.T) {
})
}
}
func TestParseServiceConfig_Strategy(t *testing.T) {
// hrw is why this annotation exists: it hashes the client address, so one
// client stays on one server while distinct clients spread over all of
// them. Sticky cannot do that for cross-origin JSON-RPC clients, which
// never send the cookie.
testCases := []struct {
desc string
annotations map[string]string
expected dynamic.BalancerStrategy
}{
{
desc: "hrw",
annotations: map[string]string{"ingress.kubernetes.io/service.strategy": "hrw"},
expected: dynamic.BalancerStrategyHRW,
},
{
desc: "p2c",
annotations: map[string]string{"ingress.kubernetes.io/service.strategy": "p2c"},
expected: dynamic.BalancerStrategyP2C,
},
{
// Absent must stay empty, NOT default to something here: the empty
// value is what service.go maps to wrr, and stamping a value at this
// layer would silently change every existing Ingress.
desc: "absent leaves the strategy unset",
annotations: map[string]string{"ingress.kubernetes.io/service.sticky.cookie": "true"},
expected: "",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
cfg, err := parseServiceConfig(test.annotations)
require.NoError(t, err)
require.NotNil(t, cfg)
require.NotNil(t, cfg.Service)
assert.Equal(t, test.expected, cfg.Service.Strategy)
})
}
}
@@ -616,6 +616,7 @@ func (p *Provider) loadService(client Client, namespace string, backend netv1.In
if svcConfig != nil && svcConfig.Service != nil {
svc.LoadBalancer.Sticky = svcConfig.Service.Sticky
svc.LoadBalancer.Strategy = svcConfig.Service.Strategy
svc.Middlewares = svcConfig.Service.Middlewares
if svcConfig.Service.PassHostHeader != nil {