Specify backend servers' weight via annotation for kubernetes
This commit is contained in:
parent
f9b1106df2
commit
e8e36bd9d5
8 changed files with 1055 additions and 2 deletions
|
@ -155,9 +155,11 @@ The following general annotations are applicable on the Ingress object:
|
|||
| `traefik.ingress.kubernetes.io/redirect-replacement: http://mydomain/$1` | Redirect to another URL for that frontend. Must be set with `traefik.ingress.kubernetes.io/redirect-regex`. |
|
||||
| `traefik.ingress.kubernetes.io/rewrite-target: /users` | Replaces each matched Ingress path with the specified one, and adds the old path to the `X-Replaced-Path` header. |
|
||||
| `traefik.ingress.kubernetes.io/rule-type: PathPrefixStrip` | Override the default frontend rule type. Default: `PathPrefix`. |
|
||||
| `traefik.ingress.kubernetes.io/whitelist-source-range: "1.2.3.0/24, fe80::/16"` | A comma-separated list of IP ranges permitted for access. all source IPs are permitted if the list is empty or a single range is ill-formatted. Please note, you may have to set `service.spec.externalTrafficPolicy` to the value `Local` to preserve the source IP of the request for filtering. Please see [this link](https://kubernetes.io/docs/tutorials/services/source-ip/) for more information.|
|
||||
| `traefik.ingress.kubernetes.io/whitelist-source-range: "1.2.3.0/24, fe80::/16"` | A comma-separated list of IP ranges permitted for access (6). |
|
||||
| `ingress.kubernetes.io/whitelist-x-forwarded-for: "true"` | Use `X-Forwarded-For` header as valid source of IP for the white list. |
|
||||
| `traefik.ingress.kubernetes.io/app-root: "/index.html"` | Redirects all requests for `/` to the defined path. (4) |
|
||||
| `traefik.ingress.kubernetes.io/service-weights: <YML>` | Set ingress backend weights specified as percentage or decimal numbers in YAML. (5) |
|
||||
|
||||
|
||||
<1> `traefik.ingress.kubernetes.io/error-pages` example:
|
||||
|
||||
|
@ -205,6 +207,39 @@ Non-root paths will not be affected by this annotation and handled normally.
|
|||
This annotation may not be combined with the `ReplacePath` rule type or any other annotation leveraging that rule type.
|
||||
Trying to do so leads to an error and the corresponding Ingress object being ignored.
|
||||
|
||||
<5> `traefik.ingress.kubernetes.io/service-weights`:
|
||||
Service weights enable to split traffic across multiple backing services in a fine-grained manner.
|
||||
A canonical use case are canary releases where a new deployment starts to receive a small percentage of traffic (e.g., 1%) and steadily increases over time as confidence in the new deployment improves.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
service_backend1: 12.50%
|
||||
service_backend2: 12.50%
|
||||
service_backend3: 75 # Same as 75%, the percentage sign is optional
|
||||
```
|
||||
|
||||
A single service backend definition may be omitted; in this case, Traefik auto-completes that service backend to 100% automatically.
|
||||
Conveniently, users need not bother to compute the percentage remainder for a main service backend.
|
||||
For instance, in the example above `service_backend3` does not need to be specified to be assigned 75%.
|
||||
|
||||
!!! note
|
||||
For each service weight given, the Ingress specification must include a backend item with the corresponding `serviceName` and (if given) matching path.
|
||||
|
||||
Currently, 3 decimal places for the weight are supported.
|
||||
An attempt to exceed the precision should be avoided as it may lead to percentage computation flaws and, in consequence, Ingress parsing errors.
|
||||
|
||||
For each path definition, this annotation will fail if:
|
||||
|
||||
- the sum of backend weights exceeds 100% or
|
||||
- the sum of backend weights is less than 100% without one or more omitted backends
|
||||
|
||||
<6> `traefik.ingress.kubernetes.io/whitelist-source-range`:
|
||||
All source IPs are permitted if the list is empty or a single range is ill-formatted.
|
||||
Please note, you may have to set `service.spec.externalTrafficPolicy` to the value `Local` to preserve the source IP of the request for filtering.
|
||||
Please see [this link](https://kubernetes.io/docs/tutorials/services/source-ip/) for more information.
|
||||
|
||||
|
||||
!!! note
|
||||
Please note that `traefik.ingress.kubernetes.io/redirect-regex` and `traefik.ingress.kubernetes.io/redirect-replacement` do not have to be set if `traefik.ingress.kubernetes.io/redirect-entry-point` is defined for the redirection (they will not be used in this case).
|
||||
|
||||
|
|
|
@ -31,6 +31,7 @@ const (
|
|||
annotationKubernetesErrorPages = "ingress.kubernetes.io/error-pages"
|
||||
annotationKubernetesBuffering = "ingress.kubernetes.io/buffering"
|
||||
annotationKubernetesAppRoot = "ingress.kubernetes.io/app-root"
|
||||
annotationKubernetesServiceWeights = "ingress.kubernetes.io/service-weights"
|
||||
|
||||
annotationKubernetesSSLForceHost = "ingress.kubernetes.io/ssl-force-host"
|
||||
annotationKubernetesSSLRedirect = "ingress.kubernetes.io/ssl-redirect"
|
||||
|
|
|
@ -184,6 +184,18 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error)
|
|||
}
|
||||
templateObjects.TLS = append(templateObjects.TLS, tlsSection...)
|
||||
|
||||
var weightAllocator weightAllocator = &defaultWeightAllocator{}
|
||||
annotationPercentageWeights := getAnnotationName(i.Annotations, annotationKubernetesServiceWeights)
|
||||
if _, ok := i.Annotations[annotationPercentageWeights]; ok {
|
||||
fractionalAllocator, err := newFractionalWeightAllocator(i, k8sClient)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create fractional weight allocator for ingress %s/%s: %v", i.Namespace, i.Name, err)
|
||||
continue
|
||||
}
|
||||
log.Debugf("Created custom weight allocator for %s/%s: %s", i.Namespace, i.Name, fractionalAllocator)
|
||||
weightAllocator = fractionalAllocator
|
||||
}
|
||||
|
||||
for _, r := range i.Spec.Rules {
|
||||
if r.HTTP == nil {
|
||||
log.Warn("Error in ingress: HTTP is nil")
|
||||
|
@ -274,6 +286,7 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error)
|
|||
templateObjects.Backends[baseName].Buffering = getBuffering(service)
|
||||
|
||||
protocol := label.DefaultProtocol
|
||||
|
||||
for _, port := range service.Spec.Ports {
|
||||
if equalPorts(port, pa.Backend.ServicePort) {
|
||||
if port.Port == 443 || strings.HasPrefix(port.Name, "https") {
|
||||
|
@ -319,9 +332,10 @@ func (p *Provider) loadIngresses(k8sClient Client) (*types.Configuration, error)
|
|||
if address.TargetRef != nil && address.TargetRef.Name != "" {
|
||||
name = address.TargetRef.Name
|
||||
}
|
||||
|
||||
templateObjects.Backends[baseName].Servers[name] = types.Server{
|
||||
URL: url,
|
||||
Weight: label.DefaultWeight,
|
||||
Weight: weightAllocator.getWeight(r.Host, pa.Path, pa.Backend.ServiceName),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2269,7 +2269,122 @@ func TestProviderUpdateIngressStatus(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPercentageWeightServiceAnnotation(t *testing.T) {
|
||||
ingresses := []*extensionsv1beta1.Ingress{
|
||||
buildIngress(
|
||||
iAnnotation(annotationKubernetesServiceWeights, `
|
||||
service1: 10%
|
||||
`),
|
||||
iNamespace("testing"),
|
||||
iRules(
|
||||
iRule(
|
||||
iHost("host1"),
|
||||
iPaths(
|
||||
onePath(iPath("/foo"), iBackend("service1", intstr.FromString("8080"))),
|
||||
onePath(iPath("/foo"), iBackend("service2", intstr.FromString("7070"))),
|
||||
onePath(iPath("/bar"), iBackend("service2", intstr.FromString("7070"))),
|
||||
)),
|
||||
),
|
||||
),
|
||||
}
|
||||
services := []*corev1.Service{
|
||||
buildService(
|
||||
sName("service1"),
|
||||
sNamespace("testing"),
|
||||
sUID("1"),
|
||||
sSpec(
|
||||
clusterIP("10.0.0.1"),
|
||||
sPorts(sPort(8080, "")),
|
||||
),
|
||||
),
|
||||
buildService(
|
||||
sName("service2"),
|
||||
sNamespace("testing"),
|
||||
sUID("1"),
|
||||
sSpec(
|
||||
clusterIP("10.0.0.1"),
|
||||
sPorts(sPort(7070, "")),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
endpoints := []*corev1.Endpoints{
|
||||
buildEndpoint(
|
||||
eNamespace("testing"),
|
||||
eName("service1"),
|
||||
eUID("1"),
|
||||
subset(
|
||||
eAddresses(
|
||||
eAddress("10.10.0.1"),
|
||||
eAddress("10.10.0.2"),
|
||||
),
|
||||
ePorts(ePort(8080, "")),
|
||||
),
|
||||
),
|
||||
buildEndpoint(
|
||||
eNamespace("testing"),
|
||||
eName("service2"),
|
||||
eUID("1"),
|
||||
subset(
|
||||
eAddresses(
|
||||
eAddress("10.10.0.3"),
|
||||
eAddress("10.10.0.4"),
|
||||
),
|
||||
ePorts(ePort(7070, "")),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
watchChan := make(chan interface{})
|
||||
client := clientMock{
|
||||
ingresses: ingresses,
|
||||
services: services,
|
||||
endpoints: endpoints,
|
||||
watchChan: watchChan,
|
||||
}
|
||||
provider := Provider{}
|
||||
|
||||
actual, err := provider.loadIngresses(client)
|
||||
require.NoError(t, err, "error loading ingresses")
|
||||
|
||||
expected := buildConfiguration(
|
||||
backends(
|
||||
backend("host1/foo",
|
||||
servers(
|
||||
server("http://10.10.0.1:8080", weight(int(newPercentageValueFromFloat64(0.05)))),
|
||||
server("http://10.10.0.2:8080", weight(int(newPercentageValueFromFloat64(0.05)))),
|
||||
server("http://10.10.0.3:7070", weight(int(newPercentageValueFromFloat64(0.45)))),
|
||||
server("http://10.10.0.4:7070", weight(int(newPercentageValueFromFloat64(0.45)))),
|
||||
),
|
||||
lbMethod("wrr"),
|
||||
),
|
||||
backend("host1/bar",
|
||||
servers(
|
||||
server("http://10.10.0.3:7070", weight(int(newPercentageValueFromFloat64(0.5)))),
|
||||
server("http://10.10.0.4:7070", weight(int(newPercentageValueFromFloat64(0.5)))),
|
||||
),
|
||||
lbMethod("wrr"),
|
||||
),
|
||||
),
|
||||
frontends(
|
||||
frontend("host1/bar",
|
||||
passHostHeader(),
|
||||
routes(
|
||||
route("/bar", "PathPrefix:/bar"),
|
||||
route("host1", "Host:host1")),
|
||||
),
|
||||
frontend("host1/foo",
|
||||
passHostHeader(),
|
||||
routes(
|
||||
route("/foo", "PathPrefix:/foo"),
|
||||
route("host1", "Host:host1")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assert.Equal(t, expected, actual, "error loading percentage weight annotation")
|
||||
}
|
||||
|
||||
func TestProviderNewK8sInClusterClient(t *testing.T) {
|
||||
|
|
47
provider/kubernetes/percentage.go
Normal file
47
provider/kubernetes/percentage.go
Normal file
|
@ -0,0 +1,47 @@
|
|||
package kubernetes
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultPercentageValuePrecision = 3
|
||||
|
||||
// percentageValue is int64 form of percentage value with 10^-3 precision.
|
||||
type percentageValue int64
|
||||
|
||||
// toFloat64 returns its decimal float64 value.
|
||||
func (v percentageValue) toFloat64() float64 {
|
||||
return float64(v) / (1000 * 100)
|
||||
}
|
||||
|
||||
func (v percentageValue) computeWeight(count int) int {
|
||||
if count == 0 {
|
||||
return 0
|
||||
}
|
||||
return int(float64(v) / float64(count))
|
||||
}
|
||||
|
||||
// String returns its string form of percentage value.
|
||||
func (v percentageValue) String() string {
|
||||
return strconv.FormatFloat(v.toFloat64()*100, 'f', defaultPercentageValuePrecision, 64) + "%"
|
||||
}
|
||||
|
||||
// newPercentageValueFromString tries to read percentage value from string, it can be either "1.1" or "1.1%", "6%".
|
||||
// It will lose the extra precision if there are more digits after decimal point.
|
||||
func newPercentageValueFromString(rawValue string) (percentageValue, error) {
|
||||
if strings.HasSuffix(rawValue, "%") {
|
||||
rawValue = rawValue[:len(rawValue)-1]
|
||||
}
|
||||
value, err := strconv.ParseFloat(rawValue, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return newPercentageValueFromFloat64(value) / 100, nil
|
||||
}
|
||||
|
||||
// newPercentageValueFromFloat64 reads percentage value from float64
|
||||
func newPercentageValueFromFloat64(f float64) percentageValue {
|
||||
return percentageValue(f * (1000 * 100))
|
||||
}
|
196
provider/kubernetes/percentage_test.go
Normal file
196
provider/kubernetes/percentage_test.go
Normal file
|
@ -0,0 +1,196 @@
|
|||
package kubernetes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewPercentageValueFromFloat64(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
value float64
|
||||
expectedString string
|
||||
expectedFloat64 float64
|
||||
}{
|
||||
{
|
||||
value: 0.01,
|
||||
expectedString: "1.000%",
|
||||
expectedFloat64: 0.01,
|
||||
},
|
||||
{
|
||||
value: 0.5,
|
||||
expectedString: "50.000%",
|
||||
expectedFloat64: 0.5,
|
||||
},
|
||||
{
|
||||
value: 0.99,
|
||||
expectedString: "99.000%",
|
||||
expectedFloat64: 0.99,
|
||||
},
|
||||
{
|
||||
value: 0.99999,
|
||||
expectedString: "99.999%",
|
||||
expectedFloat64: 0.99999,
|
||||
},
|
||||
{
|
||||
value: -0.99999,
|
||||
expectedString: "-99.999%",
|
||||
expectedFloat64: -0.99999,
|
||||
},
|
||||
{
|
||||
value: -0.9999999,
|
||||
expectedString: "-99.999%",
|
||||
expectedFloat64: -0.99999,
|
||||
},
|
||||
{
|
||||
value: 0,
|
||||
expectedString: "0.000%",
|
||||
expectedFloat64: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range testCases {
|
||||
test := test
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pvFromFloat64 := newPercentageValueFromFloat64(test.value)
|
||||
|
||||
assert.Equal(t, test.expectedString, pvFromFloat64.String(), "percentage string value mismatched")
|
||||
assert.Equal(t, test.expectedFloat64, pvFromFloat64.toFloat64(), "percentage float64 value mismatched")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPercentageValueFromString(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
value string
|
||||
expectError bool
|
||||
expectedString string
|
||||
expectedFloat64 float64
|
||||
}{
|
||||
{
|
||||
value: "1%",
|
||||
expectError: false,
|
||||
expectedString: "1.000%",
|
||||
expectedFloat64: 0.01,
|
||||
},
|
||||
{
|
||||
value: "0.5",
|
||||
expectError: false,
|
||||
expectedString: "0.500%",
|
||||
expectedFloat64: 0.005,
|
||||
},
|
||||
{
|
||||
value: "99%",
|
||||
expectError: false,
|
||||
expectedString: "99.000%",
|
||||
expectedFloat64: 0.99,
|
||||
},
|
||||
{
|
||||
value: "99.9%",
|
||||
expectError: false,
|
||||
expectedString: "99.900%",
|
||||
expectedFloat64: 0.999,
|
||||
},
|
||||
{
|
||||
value: "-99.9%",
|
||||
expectError: false,
|
||||
expectedString: "-99.900%",
|
||||
expectedFloat64: -0.999,
|
||||
},
|
||||
{
|
||||
value: "-99.99999%",
|
||||
expectError: false,
|
||||
expectedString: "-99.999%",
|
||||
expectedFloat64: -0.99999,
|
||||
},
|
||||
{
|
||||
value: "0%",
|
||||
expectError: false,
|
||||
expectedString: "0.000%",
|
||||
expectedFloat64: 0,
|
||||
},
|
||||
{
|
||||
value: "%",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
value: "foo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
value: "",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range testCases {
|
||||
test := test
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pvFromString, err := newPercentageValueFromString(test.value)
|
||||
|
||||
if test.expectError {
|
||||
require.Error(t, err, "expecting error but not happening")
|
||||
} else {
|
||||
require.NoError(t, err, "fail to parse percentage value")
|
||||
|
||||
assert.Equal(t, test.expectedString, pvFromString.String(), "percentage string value mismatched")
|
||||
assert.Equal(t, test.expectedFloat64, pvFromString.toFloat64(), "percentage float64 value mismatched")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPercentageValue(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
stringValue string
|
||||
floatValue float64
|
||||
}{
|
||||
{
|
||||
desc: "percentage",
|
||||
stringValue: "1%",
|
||||
floatValue: 0.01,
|
||||
},
|
||||
{
|
||||
desc: "decimal",
|
||||
stringValue: "0.5",
|
||||
floatValue: 0.005,
|
||||
},
|
||||
{
|
||||
desc: "negative percentage",
|
||||
stringValue: "-99.999%",
|
||||
floatValue: -0.99999,
|
||||
},
|
||||
{
|
||||
desc: "negative decimal",
|
||||
stringValue: "-0.99999",
|
||||
floatValue: -0.0099999,
|
||||
},
|
||||
{
|
||||
desc: "zero",
|
||||
stringValue: "0%",
|
||||
floatValue: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range testCases {
|
||||
test := test
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pvFromString, err := newPercentageValueFromString(test.stringValue)
|
||||
require.NoError(t, err, "fail to parse percentage value")
|
||||
|
||||
pvFromFloat64 := newPercentageValueFromFloat64(test.floatValue)
|
||||
|
||||
assert.Equal(t, pvFromString, pvFromFloat64)
|
||||
})
|
||||
}
|
||||
}
|
190
provider/kubernetes/weight_allocator.go
Normal file
190
provider/kubernetes/weight_allocator.go
Normal file
|
@ -0,0 +1,190 @@
|
|||
package kubernetes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/containous/traefik/provider/label"
|
||||
"gopkg.in/yaml.v2"
|
||||
extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
|
||||
)
|
||||
|
||||
type weightAllocator interface {
|
||||
getWeight(host, path, serviceName string) int
|
||||
}
|
||||
|
||||
var _ weightAllocator = &defaultWeightAllocator{}
|
||||
var _ weightAllocator = &fractionalWeightAllocator{}
|
||||
|
||||
type defaultWeightAllocator struct{}
|
||||
|
||||
func (d *defaultWeightAllocator) getWeight(host, path, serviceName string) int {
|
||||
return label.DefaultWeight
|
||||
}
|
||||
|
||||
type ingressService struct {
|
||||
host string
|
||||
path string
|
||||
service string
|
||||
}
|
||||
|
||||
type fractionalWeightAllocator map[ingressService]int
|
||||
|
||||
// String returns a string representation as service name / percentage tuples
|
||||
// sorted by service names.
|
||||
// Example: [foo-svc: 30.000% bar-svc: 70.000%]
|
||||
func (f *fractionalWeightAllocator) String() string {
|
||||
var sorted []ingressService
|
||||
for ingServ := range map[ingressService]int(*f) {
|
||||
sorted = append(sorted, ingServ)
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].service < sorted[j].service
|
||||
})
|
||||
|
||||
var res []string
|
||||
for _, ingServ := range sorted {
|
||||
res = append(res, fmt.Sprintf("%s: %s", ingServ.service, percentageValue(map[ingressService]int(*f)[ingServ])))
|
||||
}
|
||||
return fmt.Sprintf("[%s]", strings.Join(res, " "))
|
||||
}
|
||||
|
||||
func newFractionalWeightAllocator(ingress *extensionsv1beta1.Ingress, client Client) (*fractionalWeightAllocator, error) {
|
||||
servicePercentageWeights, err := getServicesPercentageWeights(ingress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serviceInstanceCounts, err := getServiceInstanceCounts(ingress, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serviceWeights := map[ingressService]int{}
|
||||
|
||||
for _, rule := range ingress.Spec.Rules {
|
||||
// key: rule path string
|
||||
// value: service names
|
||||
fractionalPathServices := map[string][]string{}
|
||||
|
||||
// key: rule path string
|
||||
// value: fractional percentage weight
|
||||
fractionalPathWeights := map[string]percentageValue{}
|
||||
|
||||
for _, pa := range rule.HTTP.Paths {
|
||||
if _, ok := fractionalPathWeights[pa.Path]; !ok {
|
||||
fractionalPathWeights[pa.Path] = newPercentageValueFromFloat64(1)
|
||||
}
|
||||
|
||||
if weight, ok := servicePercentageWeights[pa.Backend.ServiceName]; ok {
|
||||
ingSvc := ingressService{
|
||||
host: rule.Host,
|
||||
path: pa.Path,
|
||||
service: pa.Backend.ServiceName,
|
||||
}
|
||||
|
||||
serviceWeights[ingSvc] = weight.computeWeight(serviceInstanceCounts[ingSvc])
|
||||
|
||||
fractionalPathWeights[pa.Path] -= weight
|
||||
|
||||
if fractionalPathWeights[pa.Path].toFloat64() < 0 {
|
||||
assignedWeight := newPercentageValueFromFloat64(1) - fractionalPathWeights[pa.Path]
|
||||
return nil, fmt.Errorf("percentage value %s must not exceed 100%%", assignedWeight.String())
|
||||
}
|
||||
} else {
|
||||
fractionalPathServices[pa.Path] = append(fractionalPathServices[pa.Path], pa.Backend.ServiceName)
|
||||
}
|
||||
}
|
||||
|
||||
for pa, fractionalWeight := range fractionalPathWeights {
|
||||
fractionalServices := fractionalPathServices[pa]
|
||||
|
||||
if len(fractionalServices) == 0 {
|
||||
if fractionalWeight > 0 {
|
||||
assignedWeight := newPercentageValueFromFloat64(1) - fractionalWeight
|
||||
return nil, fmt.Errorf("the sum of weights(%s) in the path %s%s must be 100%% when no omitted fractional service left", assignedWeight.String(), rule.Host, pa)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
totalFractionalInstanceCount := 0
|
||||
for _, svc := range fractionalServices {
|
||||
totalFractionalInstanceCount += serviceInstanceCounts[ingressService{
|
||||
host: rule.Host,
|
||||
path: pa,
|
||||
service: svc,
|
||||
}]
|
||||
}
|
||||
|
||||
for _, svc := range fractionalServices {
|
||||
ingSvc := ingressService{
|
||||
host: rule.Host,
|
||||
path: pa,
|
||||
service: svc,
|
||||
}
|
||||
serviceWeights[ingSvc] = fractionalWeight.computeWeight(totalFractionalInstanceCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allocator := fractionalWeightAllocator(serviceWeights)
|
||||
return &allocator, nil
|
||||
}
|
||||
|
||||
func (f *fractionalWeightAllocator) getWeight(host, path, serviceName string) int {
|
||||
return map[ingressService]int(*f)[ingressService{
|
||||
host: host,
|
||||
path: path,
|
||||
service: serviceName,
|
||||
}]
|
||||
}
|
||||
|
||||
func getServicesPercentageWeights(ingress *extensionsv1beta1.Ingress) (map[string]percentageValue, error) {
|
||||
percentageWeight := make(map[string]string)
|
||||
|
||||
annotationPercentageWeights := getAnnotationName(ingress.Annotations, annotationKubernetesServiceWeights)
|
||||
if err := yaml.Unmarshal([]byte(ingress.Annotations[annotationPercentageWeights]), percentageWeight); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
servicesPercentageWeights := make(map[string]percentageValue)
|
||||
for serviceName, percentageStr := range percentageWeight {
|
||||
percentageValue, err := newPercentageValueFromString(percentageStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid percentage value %q", percentageStr)
|
||||
}
|
||||
|
||||
servicesPercentageWeights[serviceName] = percentageValue
|
||||
}
|
||||
return servicesPercentageWeights, nil
|
||||
}
|
||||
|
||||
func getServiceInstanceCounts(ingress *extensionsv1beta1.Ingress, client Client) (map[ingressService]int, error) {
|
||||
serviceInstanceCounts := map[ingressService]int{}
|
||||
|
||||
for _, rule := range ingress.Spec.Rules {
|
||||
for _, pa := range rule.HTTP.Paths {
|
||||
count := 0
|
||||
endpoints, exists, err := client.GetEndpoints(ingress.Namespace, pa.Backend.ServiceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get endpoints %s/%s: %v", ingress.Namespace, pa.Backend.ServiceName, err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("endpoints not found for %s/%s", ingress.Namespace, pa.Backend.ServiceName)
|
||||
}
|
||||
|
||||
for _, subset := range endpoints.Subsets {
|
||||
count += len(subset.Addresses)
|
||||
}
|
||||
|
||||
serviceInstanceCounts[ingressService{
|
||||
host: rule.Host,
|
||||
path: pa.Path,
|
||||
service: pa.Backend.ServiceName,
|
||||
}] += count
|
||||
}
|
||||
}
|
||||
|
||||
return serviceInstanceCounts, nil
|
||||
}
|
455
provider/kubernetes/weight_allocator_test.go
Normal file
455
provider/kubernetes/weight_allocator_test.go
Normal file
|
@ -0,0 +1,455 @@
|
|||
package kubernetes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
)
|
||||
|
||||
func TestString(t *testing.T) {
|
||||
pv1 := newPercentageValueFromFloat64(0.5)
|
||||
pv2 := newPercentageValueFromFloat64(0.2)
|
||||
pv3 := newPercentageValueFromFloat64(0.3)
|
||||
f := fractionalWeightAllocator(
|
||||
map[ingressService]int{
|
||||
{
|
||||
host: "host2",
|
||||
path: "path2",
|
||||
service: "service2",
|
||||
}: int(pv2),
|
||||
{
|
||||
host: "host3",
|
||||
path: "path3",
|
||||
service: "service3",
|
||||
}: int(pv3),
|
||||
{
|
||||
host: "host1",
|
||||
path: "path1",
|
||||
service: "service1",
|
||||
}: int(pv1),
|
||||
},
|
||||
)
|
||||
|
||||
expected := fmt.Sprintf("[service1: %s service2: %s service3: %s]", pv1, pv2, pv3)
|
||||
actual := f.String()
|
||||
assert.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestGetServicesPercentageWeights(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
annotationValue string
|
||||
expectError bool
|
||||
expectedWeights map[string]percentageValue
|
||||
}{
|
||||
{
|
||||
desc: "empty annotation",
|
||||
annotationValue: ``,
|
||||
expectedWeights: map[string]percentageValue{},
|
||||
},
|
||||
{
|
||||
desc: "50% fraction",
|
||||
annotationValue: `
|
||||
service1: 10%
|
||||
service2: 20%
|
||||
service3: 20%
|
||||
`,
|
||||
expectedWeights: map[string]percentageValue{
|
||||
"service1": newPercentageValueFromFloat64(0.1),
|
||||
"service2": newPercentageValueFromFloat64(0.2),
|
||||
"service3": newPercentageValueFromFloat64(0.2),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "50% fraction with empty fraction",
|
||||
annotationValue: `
|
||||
service1: 10%
|
||||
service2: 20%
|
||||
service3: 20%
|
||||
service4:
|
||||
`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
desc: "50% fraction float form",
|
||||
annotationValue: `
|
||||
service1: 0.1
|
||||
service2: 0.2
|
||||
service3: 0.2
|
||||
`,
|
||||
expectedWeights: map[string]percentageValue{
|
||||
"service1": newPercentageValueFromFloat64(0.001),
|
||||
"service2": newPercentageValueFromFloat64(0.002),
|
||||
"service3": newPercentageValueFromFloat64(0.002),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "no fraction",
|
||||
annotationValue: `
|
||||
service1: 10%
|
||||
service2: 90%
|
||||
`,
|
||||
expectedWeights: map[string]percentageValue{
|
||||
"service1": newPercentageValueFromFloat64(0.1),
|
||||
"service2": newPercentageValueFromFloat64(0.9),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "extra weight specification",
|
||||
annotationValue: `
|
||||
service1: 90%
|
||||
service5: 90%
|
||||
`,
|
||||
expectedWeights: map[string]percentageValue{
|
||||
"service1": newPercentageValueFromFloat64(0.9),
|
||||
"service5": newPercentageValueFromFloat64(0.9),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "malformed annotation",
|
||||
annotationValue: `
|
||||
service1- 90%
|
||||
service5- 90%
|
||||
`,
|
||||
expectError: true,
|
||||
expectedWeights: nil,
|
||||
},
|
||||
{
|
||||
desc: "more than one hundred percentaged service",
|
||||
annotationValue: `
|
||||
service1: 100%
|
||||
service2: 1%
|
||||
`,
|
||||
expectedWeights: map[string]percentageValue{
|
||||
"service1": newPercentageValueFromFloat64(1),
|
||||
"service2": newPercentageValueFromFloat64(0.01),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "incorrect percentage value",
|
||||
annotationValue: `
|
||||
service1: 1000%
|
||||
`,
|
||||
expectedWeights: map[string]percentageValue{
|
||||
"service1": newPercentageValueFromFloat64(10),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range testCases {
|
||||
test := test
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ingress := &extensionsv1beta1.Ingress{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Annotations: map[string]string{
|
||||
annotationKubernetesServiceWeights: test.annotationValue,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
weights, err := getServicesPercentageWeights(ingress)
|
||||
|
||||
if test.expectError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.expectedWeights, weights)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeServiceWeights(t *testing.T) {
|
||||
client := clientMock{
|
||||
endpoints: []*corev1.Endpoints{
|
||||
buildEndpoint(
|
||||
eNamespace("testing"),
|
||||
eName("service1"),
|
||||
eUID("1"),
|
||||
subset(
|
||||
eAddresses(eAddress("10.10.0.1")),
|
||||
ePorts(ePort(8080, ""))),
|
||||
subset(
|
||||
eAddresses(eAddress("10.21.0.2")),
|
||||
ePorts(ePort(8080, ""))),
|
||||
),
|
||||
buildEndpoint(
|
||||
eNamespace("testing"),
|
||||
eName("service2"),
|
||||
eUID("2"),
|
||||
subset(
|
||||
eAddresses(eAddress("10.10.0.3")),
|
||||
ePorts(ePort(8080, ""))),
|
||||
),
|
||||
buildEndpoint(
|
||||
eNamespace("testing"),
|
||||
eName("service3"),
|
||||
eUID("3"),
|
||||
subset(
|
||||
eAddresses(eAddress("10.10.0.4")),
|
||||
ePorts(ePort(8080, ""))),
|
||||
subset(
|
||||
eAddresses(eAddress("10.21.0.5")),
|
||||
ePorts(ePort(8080, ""))),
|
||||
subset(
|
||||
eAddresses(eAddress("10.21.0.6")),
|
||||
ePorts(ePort(8080, ""))),
|
||||
subset(
|
||||
eAddresses(eAddress("10.21.0.7")),
|
||||
ePorts(ePort(8080, ""))),
|
||||
),
|
||||
buildEndpoint(
|
||||
eNamespace("testing"),
|
||||
eName("service4"),
|
||||
eUID("4"),
|
||||
subset(
|
||||
eAddresses(eAddress("10.10.0.7")),
|
||||
ePorts(ePort(8080, ""))),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
desc string
|
||||
ingress *extensionsv1beta1.Ingress
|
||||
expectError bool
|
||||
expectedWeights map[ingressService]percentageValue
|
||||
}{
|
||||
{
|
||||
desc: "1 path 2 service",
|
||||
ingress: buildIngress(
|
||||
iNamespace("testing"),
|
||||
iAnnotation(annotationKubernetesServiceWeights, `
|
||||
service1: 10%
|
||||
`),
|
||||
iRules(
|
||||
iRule(iHost("foo.test"), iPaths(
|
||||
onePath(iPath("/foo"), iBackend("service1", intstr.FromInt(8080))),
|
||||
onePath(iPath("/foo"), iBackend("service2", intstr.FromInt(8080))),
|
||||
)),
|
||||
),
|
||||
),
|
||||
expectError: false,
|
||||
expectedWeights: map[ingressService]percentageValue{
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/foo",
|
||||
service: "service1",
|
||||
}: newPercentageValueFromFloat64(0.05),
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/foo",
|
||||
service: "service2",
|
||||
}: newPercentageValueFromFloat64(0.90),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "2 path 2 service",
|
||||
ingress: buildIngress(
|
||||
iNamespace("testing"),
|
||||
iAnnotation(annotationKubernetesServiceWeights, `
|
||||
service1: 60%
|
||||
`),
|
||||
iRules(
|
||||
iRule(iHost("foo.test"), iPaths(
|
||||
onePath(iPath("/foo"), iBackend("service1", intstr.FromInt(8080))),
|
||||
onePath(iPath("/foo"), iBackend("service2", intstr.FromInt(8080))),
|
||||
onePath(iPath("/bar"), iBackend("service1", intstr.FromInt(8080))),
|
||||
onePath(iPath("/bar"), iBackend("service3", intstr.FromInt(8080))),
|
||||
)),
|
||||
),
|
||||
),
|
||||
expectError: false,
|
||||
expectedWeights: map[ingressService]percentageValue{
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/foo",
|
||||
service: "service1",
|
||||
}: newPercentageValueFromFloat64(0.30),
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/foo",
|
||||
service: "service2",
|
||||
}: newPercentageValueFromFloat64(0.40),
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/bar",
|
||||
service: "service1",
|
||||
}: newPercentageValueFromFloat64(0.30),
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/bar",
|
||||
service: "service3",
|
||||
}: newPercentageValueFromFloat64(0.10),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "2 path 3 service",
|
||||
ingress: buildIngress(
|
||||
iNamespace("testing"),
|
||||
iAnnotation(annotationKubernetesServiceWeights, `
|
||||
service1: 20%
|
||||
service3: 20%
|
||||
`),
|
||||
iRules(
|
||||
iRule(iHost("foo.test"), iPaths(
|
||||
onePath(iPath("/foo"), iBackend("service1", intstr.FromInt(8080))),
|
||||
onePath(iPath("/foo"), iBackend("service2", intstr.FromInt(8080))),
|
||||
onePath(iPath("/bar"), iBackend("service2", intstr.FromInt(8080))),
|
||||
onePath(iPath("/bar"), iBackend("service3", intstr.FromInt(8080))),
|
||||
)),
|
||||
),
|
||||
),
|
||||
expectError: false,
|
||||
expectedWeights: map[ingressService]percentageValue{
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/foo",
|
||||
service: "service1",
|
||||
}: newPercentageValueFromFloat64(0.10),
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/foo",
|
||||
service: "service2",
|
||||
}: newPercentageValueFromFloat64(0.80),
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/bar",
|
||||
service: "service3",
|
||||
}: newPercentageValueFromFloat64(0.05),
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/bar",
|
||||
service: "service2",
|
||||
}: newPercentageValueFromFloat64(0.80),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "1 path 4 service",
|
||||
ingress: buildIngress(
|
||||
iNamespace("testing"),
|
||||
iAnnotation(annotationKubernetesServiceWeights, `
|
||||
service1: 20%
|
||||
service2: 40%
|
||||
service3: 40%
|
||||
`),
|
||||
iRules(
|
||||
iRule(iHost("foo.test"), iPaths(
|
||||
onePath(iPath("/foo"), iBackend("service1", intstr.FromInt(8080))),
|
||||
onePath(iPath("/foo"), iBackend("service2", intstr.FromInt(8080))),
|
||||
onePath(iPath("/foo"), iBackend("service3", intstr.FromInt(8080))),
|
||||
onePath(iPath("/foo"), iBackend("service4", intstr.FromInt(8080))),
|
||||
)),
|
||||
),
|
||||
),
|
||||
expectError: false,
|
||||
expectedWeights: map[ingressService]percentageValue{
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/foo",
|
||||
service: "service1",
|
||||
}: newPercentageValueFromFloat64(0.10),
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/foo",
|
||||
service: "service2",
|
||||
}: newPercentageValueFromFloat64(0.40),
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/foo",
|
||||
service: "service3",
|
||||
}: newPercentageValueFromFloat64(0.10),
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/foo",
|
||||
service: "service4",
|
||||
}: newPercentageValueFromFloat64(0.00),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "2 path no service",
|
||||
ingress: buildIngress(
|
||||
iNamespace("testing"),
|
||||
iAnnotation(annotationKubernetesServiceWeights, `
|
||||
service1: 20%
|
||||
service2: 40%
|
||||
service3: 40%
|
||||
`),
|
||||
iRules(
|
||||
iRule(iHost("foo.test"), iPaths(
|
||||
onePath(iPath("/foo"), iBackend("noservice", intstr.FromInt(8080))),
|
||||
onePath(iPath("/bar"), iBackend("noservice", intstr.FromInt(8080))),
|
||||
)),
|
||||
),
|
||||
),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
desc: "2 path without weight",
|
||||
ingress: buildIngress(
|
||||
iNamespace("testing"),
|
||||
iAnnotation(annotationKubernetesServiceWeights, ``),
|
||||
iRules(
|
||||
iRule(iHost("foo.test"), iPaths(
|
||||
onePath(iPath("/foo"), iBackend("service1", intstr.FromInt(8080))),
|
||||
onePath(iPath("/bar"), iBackend("service2", intstr.FromInt(8080))),
|
||||
)),
|
||||
),
|
||||
),
|
||||
expectError: false,
|
||||
expectedWeights: map[ingressService]percentageValue{
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/foo",
|
||||
service: "service1",
|
||||
}: newPercentageValueFromFloat64(0.50),
|
||||
{
|
||||
host: "foo.test",
|
||||
path: "/bar",
|
||||
service: "service2",
|
||||
}: newPercentageValueFromFloat64(1.00),
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "2 path overflow",
|
||||
ingress: buildIngress(
|
||||
iNamespace("testing"),
|
||||
iAnnotation(annotationKubernetesServiceWeights, `
|
||||
service1: 70%
|
||||
service2: 80%
|
||||
`),
|
||||
iRules(
|
||||
iRule(iHost("foo.test"), iPaths(
|
||||
onePath(iPath("/foo"), iBackend("service1", intstr.FromInt(8080))),
|
||||
onePath(iPath("/foo"), iBackend("service2", intstr.FromInt(8080))),
|
||||
)),
|
||||
),
|
||||
),
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range testCases {
|
||||
test := test
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
weightAllocator, err := newFractionalWeightAllocator(test.ingress, client)
|
||||
if test.expectError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
for ingSvc, percentage := range test.expectedWeights {
|
||||
assert.Equal(t, int(percentage), weightAllocator.getWeight(ingSvc.host, ingSvc.path, ingSvc.service))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue