Add least time load balancing strategy

This commit is contained in:
Simon Delicata 2025-10-23 16:16:05 +02:00 committed by GitHub
parent 067c7e7152
commit a754236ce5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1830 additions and 134 deletions

View File

@ -515,3 +515,13 @@ For the experimental channel:
```shell
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/experimental-install.yaml
```
### Kubernetes CRD Provider
To use the new `leastime` load-balancer algorithm with the Kubernetes CRD provider, you need to update your CRDs.
**Apply Updated CRDs:**
```shell
kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.6/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml
```

View File

@ -351,12 +351,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -1313,12 +1314,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -3031,12 +3033,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -3359,12 +3362,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -3506,12 +3510,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -3740,12 +3745,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:

View File

@ -351,12 +351,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:

View File

@ -484,12 +484,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:

View File

@ -262,12 +262,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -590,12 +591,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -737,12 +739,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -971,12 +974,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:

View File

@ -485,6 +485,48 @@ Power of two choices algorithm is a load balancing strategy that selects two ser
url = "http://private-ip-server-3/"
```
## Least-Time
The Least-Time load balancing algorithm selects the server with the lowest average response time (Time To First Byte - TTFB),
combined with the fewest active connections, weighted by server capacity.
This strategy is ideal for heterogeneous backend environments where servers have varying performance characteristics,
different hardware capabilities, or varying network latency.
The algorithm continuously measures each backend's response time and tracks active connection counts.
When routing a request,
it calculates a score for each healthy server using the formula: `(avg_response_time × (1 + active_connections)) / weight`.
The server with the lowest score receives the request.
When multiple servers have identical scores,
Weighted Round Robin (WRR) with Earliest Deadline First (EDF) scheduling is used as a tie-breaker to ensure fair distribution.
??? example "Basic Least-Time Load Balancing -- Using the [File Provider](../../../install-configuration/providers/others/file.md)"
```yaml tab="YAML"
## Dynamic configuration
http:
services:
my-service:
loadBalancer:
strategy: "leasttime"
servers:
- url: "http://private-ip-server-1/"
- url: "http://private-ip-server-2/"
- url: "http://private-ip-server-3/"
```
```toml tab="TOML"
## Dynamic configuration
[http.services]
[http.services.my-service.loadBalancer]
strategy = "leasttime"
[[http.services.my-service.loadBalancer.servers]]
url = "http://private-ip-server-1/"
[[http.services.my-service.loadBalancer.servers]]
url = "http://private-ip-server-2/"
[[http.services.my-service.loadBalancer.servers]]
url = "http://private-ip-server-3/"
```
## Mirroring
The mirroring is able to mirror requests sent to a service to other services. Please note that by default the whole request is buffered in memory while it is being mirrored. See the `maxBodySize` option in the example below for how to modify this behaviour. You can also omit the request body by setting the `mirrorBody` option to false.

View File

@ -57,7 +57,7 @@ spec:
httpOnly: true
name: cookie
secure: true
strategy: RoundRobin
strategy: wrr
weight: 10
tls:
# Generate a TLS certificate using a certificate resolver

View File

@ -47,7 +47,7 @@ spec:
httpOnly: true
name: cookie
secure: true
strategy: RoundRobin
strategy: wrr
```
```yaml tab="TraefikService"
@ -75,13 +75,13 @@ spec:
httpOnly: true
name: cookie
secure: true
strategy: RoundRobin
strategy: wrr
```
## Configuration Options
| Field | Description | Default | Required |
|:---------------------------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------------------------------|:---------|
|:---------------------------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------------------------------|:---------|
| <a id="opt-kind" href="#opt-kind" title="#opt-kind">`kind`</a> | Kind of the service targeted.<br />Two values allowed:<br />- **Service**: Kubernetes Service<br /> **TraefikService**: Traefik Service.<br />More information [here](#externalname-service). | "Service" | No |
| <a id="opt-name" href="#opt-name" title="#opt-name">`name`</a> | Service name.<br />The character `@` is not authorized. <br />More information [here](#middleware). | | Yes |
| <a id="opt-namespace" href="#opt-namespace" title="#opt-namespace">`namespace`</a> | Service namespace.<br />Can be empty if the service belongs to the same namespace as the IngressRoute. <br />More information [here](#externalname-service). | | No |
@ -107,7 +107,7 @@ spec:
| <a id="opt-sticky-cookie-secure" href="#opt-sticky-cookie-secure" title="#opt-sticky-cookie-secure">`sticky.`<br />`cookie.secure`</a> | Allow the cookie can only be transmitted over an encrypted connection (i.e. HTTPS).<br />Evaluated only if the kind is **Service**. | false | No |
| <a id="opt-sticky-cookie-sameSite" href="#opt-sticky-cookie-sameSite" title="#opt-sticky-cookie-sameSite">`sticky.`<br />`cookie.sameSite`</a> | [SameSite](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite) policy<br />Allowed values:<br />-`none`<br />-`lax`<br />`strict`<br />Evaluated only if the kind is **Service**. | "" | No |
| <a id="opt-sticky-cookie-maxAge" href="#opt-sticky-cookie-maxAge" title="#opt-sticky-cookie-maxAge">`sticky.`<br />`cookie.maxAge`</a> | Number of seconds until the cookie expires.<br />Negative number, the cookie expires immediately.<br />0, the cookie never expires.<br />Evaluated only if the kind is **Service**. | 0 | No |
| <a id="opt-strategy" href="#opt-strategy" title="#opt-strategy">`strategy`</a> | Load balancing strategy between the servers.<br />RoundRobin is the only supported value yet.<br />Evaluated only if the kind is **Service**. | "RoundRobin" | No |
| <a id="opt-strategy" href="#opt-strategy" title="#opt-strategy">`strategy`</a> | Strategy defines the load balancing strategy between the servers.<br />Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).<br />Evaluated only if the kind is **Service**. | "RoundRobin" | No |
| <a id="opt-nativeLB" href="#opt-nativeLB" title="#opt-nativeLB">`nativeLB`</a> | Allow using the Kubernetes Service load balancing between the pods instead of the one provided by Traefik.<br /> Evaluated only if the kind is **Service**. | false | No |
| <a id="opt-nodePortLB" href="#opt-nodePortLB" title="#opt-nodePortLB">`nodePortLB`</a> | Use the nodePort IP address when the service type is NodePort.<br />It allows services to be reachable when Traefik runs externally from the Kubernetes cluster but within the same network of the nodes.<br />Evaluated only if the kind is **Service**. | false | No |

View File

@ -4,7 +4,6 @@ import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
@ -56,56 +55,6 @@ func (s *DockerSuite) TestSimpleConfiguration() {
require.NoError(s.T(), err)
}
func (s *DockerSuite) TestWRRServer() {
tempObjects := struct {
DockerHost string
DefaultRule string
}{
DockerHost: s.getDockerHost(),
DefaultRule: "Host(`{{ normalize .Name }}.docker.localhost`)",
}
file := s.adaptFile("fixtures/docker/simple.toml", tempObjects)
s.composeUp()
s.traefikCmd(withConfigFile(file))
whoami1IP := s.getComposeServiceIP("wrr-server")
whoami2IP := s.getComposeServiceIP("wrr-server2")
// Expected a 404 as we did not configure anything
err := try.GetRequest("http://127.0.0.1:8000/", 500*time.Millisecond, try.StatusCodeIs(http.StatusNotFound))
require.NoError(s.T(), err)
err = try.GetRequest("http://127.0.0.1:8080/api/http/services", 1000*time.Millisecond, try.BodyContains("wrr-server"))
require.NoError(s.T(), err)
repartition := map[string]int{}
for range 4 {
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil)
req.Host = "my.wrr.host"
require.NoError(s.T(), err)
response, err := http.DefaultClient.Do(req)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusOK, response.StatusCode)
body, err := io.ReadAll(response.Body)
require.NoError(s.T(), err)
if strings.Contains(string(body), whoami1IP) {
repartition[whoami1IP]++
}
if strings.Contains(string(body), whoami2IP) {
repartition[whoami2IP]++
}
}
assert.Equal(s.T(), 3, repartition[whoami1IP])
assert.Equal(s.T(), 1, repartition[whoami2IP])
}
func (s *DockerSuite) TestDefaultDockerContainers() {
tempObjects := struct {
DockerHost string

View File

@ -351,12 +351,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -1313,12 +1314,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -3031,12 +3033,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -3359,12 +3362,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -3506,12 +3510,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:
@ -3740,12 +3745,13 @@ spec:
strategy:
description: |-
Strategy defines the load balancing strategy between the servers.
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
RoundRobin value is deprecated and supported for backward compatibility.
enum:
- wrr
- p2c
- hrw
- leasttime
- RoundRobin
type: string
weight:

View File

@ -0,0 +1,36 @@
[global]
checkNewVersion = false
sendAnonymousUsage = false
[api]
insecure = true
[log]
level = "DEBUG"
noColor = true
[entryPoints]
[entryPoints.web]
address = ":8000"
[providers.file]
filename = "{{ .SelfFilename }}"
## dynamic configuration ##
[http.routers]
[http.routers.router]
service = "service1"
rule = "Path(`/whoami`)"
[http.services]
[http.services.service1.loadBalancer]
strategy = "leasttime"
[[http.services.service1.loadBalancer.servers]]
url = "{{ .Server1 }}"
weight = 1
[[http.services.service1.loadBalancer.servers]]
url = "{{ .Server2 }}"
weight = 1

View File

@ -35,14 +35,3 @@ services:
labels:
traefik.http.Routers.Super.Rule: Host(`my.super.host`)
traefik.http.Services.powpow.LoadBalancer.server.Port: 2375
wrr-server:
image: traefik/whoami
labels:
traefik.http.Routers.wrr-server.Rule: Host(`my.wrr.host`)
traefik.http.Services.wrr-server.LoadBalancer.server.Weight: 4
wrr-server2:
image: traefik/whoami
labels:
traefik.http.Routers.wrr-server.Rule: Host(`my.wrr.host`)
traefik.http.Services.wrr-server.LoadBalancer.server.Weight: 1

View File

@ -885,6 +885,109 @@ func (s *SimpleSuite) TestWRRServer() {
assert.Equal(s.T(), 1, repartition[whoami2IP])
}
func (s *SimpleSuite) TestLeastTimeServer() {
s.createComposeProject("base")
s.composeUp()
defer s.composeDown()
whoami1IP := s.getComposeServiceIP("whoami1")
whoami2IP := s.getComposeServiceIP("whoami2")
file := s.adaptFile("fixtures/leasttime_server.toml", struct {
Server1 string
Server2 string
}{Server1: "http://" + whoami1IP, Server2: "http://" + whoami2IP})
s.traefikCmd(withConfigFile(file))
err := try.GetRequest("http://127.0.0.1:8080/api/http/services", 1000*time.Millisecond, try.BodyContains("service1"))
require.NoError(s.T(), err)
// Verify leasttime strategy is configured
err = try.GetRequest("http://127.0.0.1:8080/api/http/services", 1000*time.Millisecond, try.BodyContains("leasttime"))
require.NoError(s.T(), err)
// Make requests and verify both servers respond
repartition := map[string]int{}
for range 10 {
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil)
require.NoError(s.T(), err)
response, err := http.DefaultClient.Do(req)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusOK, response.StatusCode)
body, err := io.ReadAll(response.Body)
require.NoError(s.T(), err)
if strings.Contains(string(body), whoami1IP) {
repartition[whoami1IP]++
}
if strings.Contains(string(body), whoami2IP) {
repartition[whoami2IP]++
}
}
// Both servers should have received requests
assert.Positive(s.T(), repartition[whoami1IP])
assert.Positive(s.T(), repartition[whoami2IP])
}
func (s *SimpleSuite) TestLeastTimeHeterogeneousPerformance() {
// Create test servers with different response times
var fastServerCalls, slowServerCalls atomic.Int32
fastServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
fastServerCalls.Add(1)
time.Sleep(10 * time.Millisecond) // Fast server
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write([]byte("fast-server"))
}))
defer fastServer.Close()
slowServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
slowServerCalls.Add(1)
time.Sleep(100 * time.Millisecond) // Slow server
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write([]byte("slow-server"))
}))
defer slowServer.Close()
file := s.adaptFile("fixtures/leasttime_server.toml", struct {
Server1 string
Server2 string
}{Server1: fastServer.URL, Server2: slowServer.URL})
s.traefikCmd(withConfigFile(file))
err := try.GetRequest("http://127.0.0.1:8080/api/http/services", 1000*time.Millisecond, try.BodyContains("service1"))
require.NoError(s.T(), err)
// Make 20 requests to build up response time statistics
for range 20 {
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil)
require.NoError(s.T(), err)
response, err := http.DefaultClient.Do(req)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusOK, response.StatusCode)
_, _ = io.ReadAll(response.Body)
response.Body.Close()
}
// Verify that the fast server received significantly more requests (>70%)
fastCalls := fastServerCalls.Load()
slowCalls := slowServerCalls.Load()
totalCalls := fastCalls + slowCalls
assert.Equal(s.T(), int32(20), totalCalls)
// Fast server should get >70% of traffic due to lower response time
fastPercentage := float64(fastCalls) / float64(totalCalls) * 100
assert.Greater(s.T(), fastPercentage, 70.0)
}
func (s *SimpleSuite) TestWRR() {
s.createComposeProject("base")

View File

@ -190,6 +190,12 @@ type WRRService struct {
GRPCStatus *GRPCStatus `json:"-" toml:"-" yaml:"-" label:"-" file:"-"`
}
// SetDefaults Default values for a WRRService.
func (w *WRRService) SetDefaults() {
defaultWeight := 1
w.Weight = &defaultWeight
}
// +k8s:deepcopy-gen=true
// HRWService is a reference to a service load-balanced with highest random weight.
@ -204,12 +210,6 @@ func (w *HRWService) SetDefaults() {
w.Weight = &defaultWeight
}
// SetDefaults Default values for a WRRService.
func (w *WRRService) SetDefaults() {
defaultWeight := 1
w.Weight = &defaultWeight
}
// +k8s:deepcopy-gen=true
type GRPCStatus struct {
@ -267,6 +267,8 @@ const (
BalancerStrategyP2C BalancerStrategy = "p2c"
// BalancerStrategyHRW is the highest random weight strategy.
BalancerStrategyHRW BalancerStrategy = "hrw"
// BalancerStrategyLeastTime is the least-time strategy.
BalancerStrategyLeastTime BalancerStrategy = "leasttime"
)
// +k8s:deepcopy-gen=true

View File

@ -0,0 +1,16 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: test.route
namespace: default
spec:
entryPoints:
- web
routes:
- match: Host(`foo.com`) && PathPrefix(`/leasttime`)
kind: Rule
priority: 12
services:
- name: whoami2
port: 8080
strategy: leasttime

View File

@ -384,7 +384,7 @@ func (c configBuilder) buildServersLB(namespace string, svc traefikv1alpha1.Load
// TODO: remove this when the fake client apply default values.
if svc.Strategy != "" {
switch svc.Strategy {
case dynamic.BalancerStrategyWRR, dynamic.BalancerStrategyP2C, dynamic.BalancerStrategyHRW:
case dynamic.BalancerStrategyWRR, dynamic.BalancerStrategyP2C, dynamic.BalancerStrategyHRW, dynamic.BalancerStrategyLeastTime:
lb.Strategy = svc.Strategy
// Here we are just logging a warning as the default value is already applied.

View File

@ -5667,6 +5667,54 @@ func TestLoadIngressRoutes(t *testing.T) {
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "Simple Ingress Route with leasttime strategy",
paths: []string{"services.yml", "with_leasttime_strategy.yml"},
expected: &dynamic.Configuration{
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-test-route-55869f6407935ccfa805": {
EntryPoints: []string{"web"},
Service: "default-test-route-55869f6407935ccfa805",
Rule: "Host(`foo.com`) && PathPrefix(`/leasttime`)",
Priority: 12,
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"default-test-route-55869f6407935ccfa805": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Strategy: dynamic.BalancerStrategyLeastTime,
Servers: []dynamic.Server{
{
URL: "http://10.10.0.3:8080",
},
{
URL: "http://10.10.0.4:8080",
},
},
PassHostHeader: pointer(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: ptypes.Duration(100 * time.Millisecond),
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
}
for _, test := range testCases {

View File

@ -118,10 +118,10 @@ type LoadBalancerSpec struct {
// It defaults to https when Kubernetes Service port is 443, http otherwise.
Scheme string `json:"scheme,omitempty"`
// Strategy defines the load balancing strategy between the servers.
// Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), and hrw (Highest Random Weight).
// Supported values are: wrr (Weighed round-robin), p2c (Power of two choices), hrw (Highest Random Weight), and leasttime (Least-Time).
// RoundRobin value is deprecated and supported for backward compatibility.
// TODO: when the deprecated RoundRobin value will be removed, set the default value to wrr.
// +kubebuilder:validation:Enum=wrr;p2c;hrw;RoundRobin
// TODO: when the deprecated RoundRobin value will be removed, set the default kubebuilder value to wrr.
// +kubebuilder:validation:Enum=wrr;p2c;hrw;leasttime;RoundRobin
Strategy dynamic.BalancerStrategy `json:"strategy,omitempty"`
// PassHostHeader defines whether the client Host header is forwarded to the upstream Kubernetes Service.
// By default, passHostHeader is true.

View File

@ -13,6 +13,8 @@ import (
"github.com/traefik/traefik/v3/pkg/ip"
)
var errNoAvailableServer = errors.New("no available server")
type namedHandler struct {
http.Handler
name string
@ -114,15 +116,13 @@ func (b *Balancer) SetStatus(ctx context.Context, childName string, up bool) {
// Not thread safe.
func (b *Balancer) RegisterStatusUpdater(fn func(up bool)) error {
if !b.wantsHealthCheck {
return errors.New("healthCheck not enabled in config for this weighted service")
return errors.New("healthCheck not enabled in config for this HRW service")
}
b.updaters = append(b.updaters, fn)
return nil
}
var errNoAvailableServer = errors.New("no available server")
func (b *Balancer) nextServer(ip string) (*namedHandler, error) {
b.handlersMu.RLock()
var healthy []*namedHandler

View File

@ -0,0 +1,373 @@
package leasttime
import (
"context"
"errors"
"math"
"net/http"
"net/http/httptrace"
"sync"
"sync/atomic"
"time"
"github.com/rs/zerolog/log"
"github.com/traefik/traefik/v3/pkg/config/dynamic"
"github.com/traefik/traefik/v3/pkg/server/service/loadbalancer"
)
const sampleSize = 100 // Number of response time samples to track.
var errNoAvailableServer = errors.New("no available server")
// namedHandler wraps an HTTP handler with metrics and server information.
// Tracks response time (TTFB) and inflight request count for load balancing decisions.
type namedHandler struct {
http.Handler
name string
weight float64
deadlineMu sync.RWMutex
deadline float64 // WRR tie-breaking (EDF scheduling).
inflightCount atomic.Int64 // Number of inflight requests.
responseTimeMu sync.RWMutex
responseTimes [sampleSize]float64 // Fixed-size ring buffer (TTFB measurements in ms).
responseTimeIdx int // Current position in ring buffer.
responseTimeSum float64 // Sum of all values in buffer.
sampleCount int // Number of samples collected so far.
}
// updateResponseTime updates the average response time for this server using a ring buffer.
func (s *namedHandler) updateResponseTime(elapsed time.Duration) {
s.responseTimeMu.Lock()
defer s.responseTimeMu.Unlock()
ms := float64(elapsed.Milliseconds())
if s.sampleCount < sampleSize {
// Still filling the buffer.
s.responseTimes[s.responseTimeIdx] = ms
s.responseTimeSum += ms
s.sampleCount++
} else {
// Buffer is full, replace oldest value.
oldValue := s.responseTimes[s.responseTimeIdx]
s.responseTimes[s.responseTimeIdx] = ms
s.responseTimeSum = s.responseTimeSum - oldValue + ms
}
s.responseTimeIdx = (s.responseTimeIdx + 1) % sampleSize
}
// getAvgResponseTime returns the average response time in milliseconds.
// Returns 0 if no samples have been collected yet (cold start).
func (s *namedHandler) getAvgResponseTime() float64 {
s.responseTimeMu.RLock()
defer s.responseTimeMu.RUnlock()
if s.sampleCount == 0 {
return 0
}
return s.responseTimeSum / float64(s.sampleCount)
}
func (s *namedHandler) getDeadline() float64 {
s.deadlineMu.RLock()
defer s.deadlineMu.RUnlock()
return s.deadline
}
func (s *namedHandler) setDeadline(deadline float64) {
s.deadlineMu.Lock()
defer s.deadlineMu.Unlock()
s.deadline = deadline
}
// Balancer implements the least-time load balancing algorithm.
// It selects the server with the lowest average response time (TTFB) and fewest active connections.
type Balancer struct {
wantsHealthCheck bool
// handlersMu protects the handlers slice, the status and the fenced maps.
handlersMu sync.RWMutex
handlers []*namedHandler
// status is a record of which child services of the Balancer are healthy, keyed
// by name of child service. A service is initially added to the map when it is
// created via Add, and it is later removed or added to the map as needed,
// through the SetStatus method.
status map[string]struct{}
// fenced is the list of terminating yet still serving child services.
fenced map[string]struct{}
// updaters is the list of hooks that are run (to update the Balancer
// parent(s)), whenever the Balancer status changes.
// No mutex is needed, as it is modified only during the configuration build.
updaters []func(bool)
sticky *loadbalancer.Sticky
// deadlineMu protects EDF scheduling state (curDeadline and all handler deadline fields).
// Separate from handlersMu to reduce lock contention during tie-breaking.
curDeadlineMu sync.RWMutex
// curDeadline is used for WRR tie-breaking (EDF scheduling).
curDeadline float64
}
// New creates a new least-time load balancer.
func New(stickyConfig *dynamic.Sticky, wantsHealthCheck bool) *Balancer {
balancer := &Balancer{
status: make(map[string]struct{}),
fenced: make(map[string]struct{}),
wantsHealthCheck: wantsHealthCheck,
}
if stickyConfig != nil && stickyConfig.Cookie != nil {
balancer.sticky = loadbalancer.NewSticky(*stickyConfig.Cookie)
}
return balancer
}
// SetStatus sets on the balancer that its given child is now of the given
// status. childName is only needed for logging purposes.
func (b *Balancer) SetStatus(ctx context.Context, childName string, up bool) {
b.handlersMu.Lock()
defer b.handlersMu.Unlock()
upBefore := len(b.status) > 0
status := "DOWN"
if up {
status = "UP"
}
log.Ctx(ctx).Debug().Msgf("Setting status of %s to %v", childName, status)
if up {
b.status[childName] = struct{}{}
} else {
delete(b.status, childName)
}
upAfter := len(b.status) > 0
status = "DOWN"
if upAfter {
status = "UP"
}
// No Status Change.
if upBefore == upAfter {
// We're still with the same status, no need to propagate.
log.Ctx(ctx).Debug().Msgf("Still %s, no need to propagate", status)
return
}
// Status Change.
log.Ctx(ctx).Debug().Msgf("Propagating new %s status", status)
for _, fn := range b.updaters {
fn(upAfter)
}
}
// RegisterStatusUpdater adds fn to the list of hooks that are run when the
// status of the Balancer changes.
// Not thread safe.
func (b *Balancer) RegisterStatusUpdater(fn func(up bool)) error {
if !b.wantsHealthCheck {
return errors.New("healthCheck not enabled in config for this LeastTime service")
}
b.updaters = append(b.updaters, fn)
return nil
}
// getHealthyServers returns the list of healthy, non-fenced servers.
func (b *Balancer) getHealthyServers() []*namedHandler {
b.handlersMu.RLock()
defer b.handlersMu.RUnlock()
var healthy []*namedHandler
for _, h := range b.handlers {
if _, ok := b.status[h.name]; ok {
if _, fenced := b.fenced[h.name]; !fenced {
healthy = append(healthy, h)
}
}
}
return healthy
}
// selectWRR selects a server from candidates using Weighted Round Robin (EDF scheduling).
// This is used for tie-breaking when multiple servers have identical scores.
func (b *Balancer) selectWRR(candidates []*namedHandler) *namedHandler {
if len(candidates) == 0 {
return nil
}
selected := candidates[0]
minDeadline := math.MaxFloat64
// Find handler with earliest deadline.
for _, h := range candidates {
handlerDeadline := h.getDeadline()
if handlerDeadline < minDeadline {
minDeadline = handlerDeadline
selected = h
}
}
// Update deadline based on when this server was selected (minDeadline),
// not the global curDeadline. This ensures proper weighted distribution.
newDeadline := minDeadline + 1/selected.weight
selected.setDeadline(newDeadline)
// Track the maximum deadline assigned for initializing new servers.
b.curDeadlineMu.Lock()
if newDeadline > b.curDeadline {
b.curDeadline = newDeadline
}
b.curDeadlineMu.Unlock()
return selected
}
// Score = (avgResponseTime × (1 + inflightCount)) / weight.
func (b *Balancer) nextServer() (*namedHandler, error) {
healthy := b.getHealthyServers()
if len(healthy) == 0 {
return nil, errNoAvailableServer
}
if len(healthy) == 1 {
return healthy[0], nil
}
// Calculate scores and find minimum.
minScore := math.MaxFloat64
var candidates []*namedHandler
for _, h := range healthy {
avgRT := h.getAvgResponseTime()
inflight := float64(h.inflightCount.Load())
score := (avgRT * (1 + inflight)) / h.weight
if score < minScore {
minScore = score
candidates = []*namedHandler{h}
} else if score == minScore {
candidates = append(candidates, h)
}
}
if len(candidates) == 1 {
return candidates[0], nil
}
// Multiple servers with same score: use WRR (EDF) tie-breaking.
selected := b.selectWRR(candidates)
if selected == nil {
return nil, errNoAvailableServer
}
return selected, nil
}
func (b *Balancer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// Handle sticky sessions first.
if b.sticky != nil {
h, rewrite, err := b.sticky.StickyHandler(req)
if err != nil {
log.Error().Err(err).Msg("Error while getting sticky handler")
} else if h != nil {
b.handlersMu.RLock()
_, ok := b.status[h.Name]
b.handlersMu.RUnlock()
if ok {
if rewrite {
if err := b.sticky.WriteStickyCookie(rw, h.Name); err != nil {
log.Error().Err(err).Msg("Writing sticky cookie")
}
}
h.ServeHTTP(rw, req)
return
}
}
}
server, err := b.nextServer()
if err != nil {
if errors.Is(err, errNoAvailableServer) {
http.Error(rw, errNoAvailableServer.Error(), http.StatusServiceUnavailable)
} else {
http.Error(rw, err.Error(), http.StatusInternalServerError)
}
return
}
if b.sticky != nil {
if err := b.sticky.WriteStickyCookie(rw, server.name); err != nil {
log.Error().Err(err).Msg("Error while writing sticky cookie")
}
}
// Track inflight requests.
server.inflightCount.Add(1)
defer server.inflightCount.Add(-1)
startTime := time.Now()
trace := &httptrace.ClientTrace{
GotFirstResponseByte: func() {
// Update average response time (TTFB).
server.updateResponseTime(time.Since(startTime))
},
}
traceCtx := httptrace.WithClientTrace(req.Context(), trace)
server.ServeHTTP(rw, req.WithContext(traceCtx))
}
// AddServer adds a handler with a server.
func (b *Balancer) AddServer(name string, handler http.Handler, server dynamic.Server) {
b.Add(name, handler, server.Weight, server.Fenced)
}
// Add adds a handler.
// A handler with a non-positive weight is ignored.
func (b *Balancer) Add(name string, handler http.Handler, weight *int, fenced bool) {
w := 1
if weight != nil {
w = *weight
}
if w <= 0 { // non-positive weight is meaningless.
return
}
h := &namedHandler{Handler: handler, name: name, weight: float64(w)}
// Initialize deadline by adding 1/weight to current deadline.
// This staggers servers to prevent all starting at the same time.
var deadline float64
b.curDeadlineMu.RLock()
deadline = b.curDeadline + 1/h.weight
b.curDeadlineMu.RUnlock()
h.setDeadline(deadline)
// Update balancer's current deadline with the new server's deadline.
b.curDeadlineMu.Lock()
b.curDeadline = deadline
b.curDeadlineMu.Unlock()
b.handlersMu.Lock()
b.handlers = append(b.handlers, h)
b.status[name] = struct{}{}
if fenced {
b.fenced[name] = struct{}{}
}
b.handlersMu.Unlock()
if b.sticky != nil {
b.sticky.AddHandler(name, handler)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,8 @@ import (
"github.com/traefik/traefik/v3/pkg/server/service/loadbalancer"
)
var errNoAvailableServer = errors.New("no available server")
type namedHandler struct {
http.Handler
@ -81,7 +83,7 @@ func New(stickyConfig *dynamic.Sticky, wantsHealthCheck bool) *Balancer {
}
// SetStatus sets on the balancer that its given child is now of the given
// status. balancerName is only needed for logging purposes.
// status. childName is only needed for logging purposes.
func (b *Balancer) SetStatus(ctx context.Context, childName string, up bool) {
b.handlersMu.Lock()
defer b.handlersMu.Unlock()
@ -126,14 +128,12 @@ func (b *Balancer) SetStatus(ctx context.Context, childName string, up bool) {
// Not thread safe.
func (b *Balancer) RegisterStatusUpdater(fn func(up bool)) error {
if !b.wantsHealthCheck {
return errors.New("healthCheck not enabled in config for this weighted service")
return errors.New("healthCheck not enabled in config for this P2C service")
}
b.updaters = append(b.updaters, fn)
return nil
}
var errNoAvailableServer = errors.New("no available server")
func (b *Balancer) nextServer() (*namedHandler, error) {
// We kept the same representation (map) as in the WRR strategy to improve maintainability.
// However, with the P2C strategy, we only need a slice of healthy servers.

View File

@ -12,6 +12,8 @@ import (
"github.com/traefik/traefik/v3/pkg/server/service/loadbalancer"
)
var errNoAvailableServer = errors.New("no available server")
type namedHandler struct {
http.Handler
name string
@ -94,7 +96,7 @@ func (b *Balancer) Pop() interface{} {
}
// SetStatus sets on the balancer that its given child is now of the given
// status. balancerName is only needed for logging purposes.
// status. childName is only needed for logging purposes.
func (b *Balancer) SetStatus(ctx context.Context, childName string, up bool) {
b.handlersMu.Lock()
defer b.handlersMu.Unlock()
@ -139,14 +141,12 @@ func (b *Balancer) SetStatus(ctx context.Context, childName string, up bool) {
// Not thread safe.
func (b *Balancer) RegisterStatusUpdater(fn func(up bool)) error {
if !b.wantsHealthCheck {
return errors.New("healthCheck not enabled in config for this weighted service")
return errors.New("healthCheck not enabled in config for this WRR service")
}
b.updaters = append(b.updaters, fn)
return nil
}
var errNoAvailableServer = errors.New("no available server")
func (b *Balancer) nextServer() (*namedHandler, error) {
b.handlersMu.Lock()
defer b.handlersMu.Unlock()

View File

@ -29,6 +29,7 @@ import (
"github.com/traefik/traefik/v3/pkg/server/provider"
"github.com/traefik/traefik/v3/pkg/server/service/loadbalancer/failover"
"github.com/traefik/traefik/v3/pkg/server/service/loadbalancer/hrw"
"github.com/traefik/traefik/v3/pkg/server/service/loadbalancer/leasttime"
"github.com/traefik/traefik/v3/pkg/server/service/loadbalancer/mirror"
"github.com/traefik/traefik/v3/pkg/server/service/loadbalancer/p2c"
"github.com/traefik/traefik/v3/pkg/server/service/loadbalancer/wrr"
@ -402,6 +403,8 @@ func (m *Manager) getLoadBalancerServiceHandler(ctx context.Context, serviceName
lb = p2c.New(service.Sticky, service.HealthCheck != nil)
case dynamic.BalancerStrategyHRW:
lb = hrw.New(service.HealthCheck != nil)
case dynamic.BalancerStrategyLeastTime:
lb = leasttime.New(service.Sticky, service.HealthCheck != nil)
default:
return nil, fmt.Errorf("unsupported load-balancer strategy %q", service.Strategy)
}

View File

@ -82,6 +82,20 @@ func TestGetLoadBalancer(t *testing.T) {
fwd: &forwarderMock{},
expectError: false,
},
{
desc: "Fails when unsupported strategy is set",
serviceName: "test",
service: &dynamic.ServersLoadBalancer{
Strategy: "invalid",
Servers: []dynamic.Server{
{
URL: "http://localhost:8080",
},
},
},
fwd: &forwarderMock{},
expectError: true,
},
}
for _, test := range testCases {