Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,70 @@ list, which briefly interrupts traffic on that port.
Setting it to an empty value (`""`) sends an empty CIDR list to CloudStack — it does not block all
traffic.

#### `service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-name`

**Type:** String

**Default:** Not set (no stickiness policy)

**Description:** Creates a CloudStack **LB stickiness policy** on every load balancer rule belonging
to the service, making the load balancer keep a client on the same backend node between requests.

The value is the CloudStack stickiness method name and is passed through to CloudStack, which
validates it against the methods the network's load balancer provider offers. The VirtualRouter
(HAProxy) provider supports `LbCookie`, `AppCookie` and `SourceBased`. An unsupported method makes
the service fail to sync with an `error creating stickiness policy` error.

Each service port has its own load balancer rule, so a service exposing several ports gets one
policy per port, all with the same method and parameters.

**Use Case:** Applications that keep per-client state in the backend — a session held in process
memory, for example — and therefore need successive requests from one client to land on the same
node.

**Example:**
```yaml
apiVersion: v1
kind: Service
metadata:
name: my-service
annotations:
service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-name: "LbCookie"
service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-param: "name=SERVERID"
spec:
type: LoadBalancer
```

**Note:** Removing the annotation deletes the stickiness policy and leaves the load balancer rule in
place. Changing either the method name or the parameters replaces the policy: the controller deletes
the existing policy and creates a new one, which resets whatever affinity state the load balancer
was holding.

#### `service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-param`

**Type:** String (comma-separated `key=value` list)

**Default:** Not set (no parameters)

**Description:** Parameters for the stickiness method selected by
`service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-name`. Which keys are
accepted depends on the method — `LbCookie` and `AppCookie` take a cookie `name`, `SourceBased`
takes `tablesize` and `expire`. CloudStack validates the keys, so an unknown parameter makes the
service fail to sync.

This annotation has no effect on its own: without a method name no policy is created.

**Format:** Comma-separated `key=value` pairs. Spaces around entries are trimmed, and only the first
`=` separates key from value, so a value may itself contain `=`. Entries without a `=` are ignored
rather than rejected, an empty value (`key=`) is passed through as an empty string, and if a key
repeats, the last occurrence wins.

**Example:**
```yaml
service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-name: "AppCookie"
service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-param: "name=JSESSIONID,mode=insert"
```

#### `service.beta.kubernetes.io/cloudstack-load-balancer-ip-associated-by-controller`

**Type:** Boolean (`"true"` or `"false"`)
Expand Down Expand Up @@ -262,6 +326,14 @@ annotation for it.
Any other value makes the service fail to sync with `unsupported load balancer affinity`. Other
CloudStack algorithms, such as `leastconn`, cannot currently be selected.

The algorithm is separate from stickiness. `spec.sessionAffinity: ClientIP` picks the load balancer
algorithm, while a CloudStack stickiness policy — cookie-based affinity, for instance — is
configured with the
[`stickiness-method-name`](#servicebetakubernetesiocloudstack-load-balancer-stickiness-method-name)
and
[`stickiness-method-param`](#servicebetakubernetesiocloudstack-load-balancer-stickiness-method-param)
annotations. The two can be used together.

### VPC Networks

VPC networks are supported. VPC networks normally do not offer the Firewall service, so the
Expand Down
161 changes: 157 additions & 4 deletions cloudstack_loadbalancer.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ const (
// associated the IP address. This annotation is set by the controller when it associates
// an unallocated IP, and is used to determine if the IP should be disassociated on deletion.
ServiceAnnotationLoadBalancerIPAssociatedByController = "service.beta.kubernetes.io/cloudstack-load-balancer-ip-associated-by-controller" //nolint:gosec

ServiceAnnotationLoadBalancerStickinessMethodName = "service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-name"
ServiceAnnotationLoadBalancerStickinessParam = "service.beta.kubernetes.io/cloudstack-load-balancer-stickiness-method-param"
)

type loadBalancer struct {
Expand All @@ -69,6 +72,7 @@ type loadBalancer struct {
networkID string
projectID string
rules map[string]*cloudstack.LoadBalancerRule
stickinessPolicies map[string]*cloudstack.LBStickinessPolicyStickinesspolicy
ipAssociatedByController bool
}

Expand Down Expand Up @@ -181,12 +185,36 @@ func (cs *CSCloud) EnsureLoadBalancer(ctx context.Context, clusterName string, s
// Delete the rule from the map, to prevent it being deleted.
delete(lb.rules, lbRuleName)
}

stickinessPolicy, stickinessPolicyNeedsUpdate, err := lb.checkStickinessPolicy(lbRule, service)
if err != nil {
return nil, err
}
if stickinessPolicyNeedsUpdate {
if stickinessPolicy != nil {
klog.V(4).Infof("Recreate stickiness policy: %v", lbRuleName)
if err := lb.deleteStickinessPolicy(stickinessPolicy.Id); err != nil {
return nil, err
}
delete(lb.stickinessPolicies, lbRule.Id)
} else {
klog.V(4).Infof("Creating stickiness policy: %v", lbRuleName)
}
if _, err := lb.createStickinessPolicy(lbRuleName, lbRule.Id, service); err != nil {
return nil, err
}
// Remove from map to mark as handled (map tracks initial state for comparison)
delete(lb.stickinessPolicies, lbRule.Id)
}
} else {
klog.V(4).Infof("Creating load balancer rule: %v", lbRuleName)
lbRule, err = lb.createLoadBalancerRule(lbRuleName, port, protocol, service)
if err != nil {
return nil, err
}
if _, err := lb.createStickinessPolicy(lbRuleName, lbRule.Id, service); err != nil {
return nil, err
}

klog.V(4).Infof("Assigning hosts (%v) to load balancer rule: %v", lb.hostIDs, lbRuleName)
if err = lb.assignHostsToRule(lbRule, lb.hostIDs); err != nil {
Expand Down Expand Up @@ -434,10 +462,11 @@ func (cs *CSCloud) GetLoadBalancerName(ctx context.Context, clusterName string,
// getLoadBalancer retrieves the IP address and ID and all the existing rules it can find.
func (cs *CSCloud) getLoadBalancer(service *corev1.Service) (*loadBalancer, error) {
lb := &loadBalancer{
CloudStackClient: cs.client,
name: cs.GetLoadBalancerName(context.TODO(), "", service),
projectID: cs.projectID,
rules: make(map[string]*cloudstack.LoadBalancerRule),
CloudStackClient: cs.client,
name: cs.GetLoadBalancerName(context.TODO(), "", service),
projectID: cs.projectID,
rules: make(map[string]*cloudstack.LoadBalancerRule),
stickinessPolicies: make(map[string]*cloudstack.LBStickinessPolicyStickinesspolicy),
}

p := cs.client.LoadBalancer.NewListLoadBalancerRulesParams()
Expand All @@ -462,6 +491,19 @@ func (cs *CSCloud) getLoadBalancer(service *corev1.Service) (*loadBalancer, erro

lb.ipAddr = lbRule.Publicip
lb.ipAddrID = lbRule.Publicipid

lbStickinessPoliciesParams := cs.client.LoadBalancer.NewListLBStickinessPoliciesParams()
lbStickinessPoliciesParams.SetLbruleid(lbRule.Id)
lbStickinessPolicies, err := cs.client.LoadBalancer.ListLBStickinessPolicies(lbStickinessPoliciesParams)
if err != nil {
return nil, fmt.Errorf("error retrieving stickiness policies: %v", err)
}
// CloudStack returns a policy wrapper per rule even when the rule has no
// stickiness policy, with an empty Stickinesspolicy list inside it.
if len(lbStickinessPolicies.LBStickinessPolicies) > 0 &&
len(lbStickinessPolicies.LBStickinessPolicies[0].Stickinesspolicy) > 0 {
lb.stickinessPolicies[lbRule.Id] = &lbStickinessPolicies.LBStickinessPolicies[0].Stickinesspolicy[0]
}
}

klog.V(4).Infof("Load balancer %v contains %d rule(s)", lb.name, len(lb.rules))
Expand Down Expand Up @@ -649,6 +691,60 @@ func (lb *loadBalancer) getCIDRList(service *corev1.Service) ([]string, error) {
return cidrList, nil
}

func (lb *loadBalancer) checkStickinessPolicy(lbRule *cloudstack.LoadBalancerRule, service *corev1.Service) (*cloudstack.LBStickinessPolicyStickinesspolicy, bool, error) {
stickinessPolicy := lb.stickinessPolicies[lbRule.Id]
stickinessMethodName := getStringFromServiceAnnotation(service, ServiceAnnotationLoadBalancerStickinessMethodName, "")
stickinessMethodParam := getStringFromServiceAnnotation(service, ServiceAnnotationLoadBalancerStickinessParam, "")
stickinessMethodParams := parseStickinessParams(stickinessMethodParam)

// If no policy exists and no method name is specified, no action needed
if stickinessPolicy == nil {
if stickinessMethodName == "" {
return nil, false, nil
}
klog.V(4).Infof("sticky policy not found for rule: %v", lbRule.Name)
return nil, true, nil
}

// If policy exists but method name is not specified, policy should be deleted
if stickinessMethodName == "" {
klog.V(4).Infof("sticky policy exists but annotation removed for rule: %v", lbRule.Name)
return stickinessPolicy, true, nil
}

// Policy exists and method name is specified - check if it matches
klog.V(4).Infof("sticky policy found for rule: %v", lbRule.Name)
if stickinessPolicy.Methodname != stickinessMethodName {
klog.V(4).Infof("sticky policy method name does not match: %v", lbRule.Name)
return stickinessPolicy, true, nil
}

// Check if params match
if len(stickinessPolicy.Params) != len(stickinessMethodParams) {
klog.V(4).Infof("sticky policy params length does not match: %v", lbRule.Name)
return stickinessPolicy, true, nil
}

// Check if all keys in stickinessPolicy.Params match stickinessMethodParams
for key, value := range stickinessPolicy.Params {
if stickinessMethodParams[key] != value {
klog.V(4).Infof("sticky policy param %v does not match: %v", key, value)
return stickinessPolicy, true, nil
}
}

// Check if all keys in stickinessMethodParams exist in stickinessPolicy.Params
for key := range stickinessMethodParams {
if _, exists := stickinessPolicy.Params[key]; !exists {
klog.V(4).Infof("sticky policy missing param: %v", key)
return stickinessPolicy, true, nil
}
}

// Policy matches desired state
return stickinessPolicy, false, nil
}

// checkLoadBalancerRule checks if the rule already exists and if it does, if it can be updated. If
// it does exist but cannot be updated, it will delete the existing rule so it can be created again.
func (lb *loadBalancer) checkLoadBalancerRule(lbRuleName string, port corev1.ServicePort, protocol LoadBalancerProtocol, service *corev1.Service, version semver.Version) (*cloudstack.LoadBalancerRule, bool, error) {
Expand Down Expand Up @@ -715,6 +811,45 @@ func (lb *loadBalancer) updateLoadBalancerRule(lbRuleName string, protocol LoadB
return err
}

// createStickinessPolicy creates a new stickiness policy and returns it.
func (lb *loadBalancer) createStickinessPolicy(lbRuleName string, lbRuleId string, service *corev1.Service) (*cloudstack.LBStickinessPolicyStickinesspolicy, error) {
stickinessMethodName := getStringFromServiceAnnotation(service, ServiceAnnotationLoadBalancerStickinessMethodName, "")
stickinessMethodParam := getStringFromServiceAnnotation(service, ServiceAnnotationLoadBalancerStickinessParam, "")
// If the stickiness method name is not set, we don't need to create a stickiness policy.
if stickinessMethodName == "" {
return nil, nil
}
p := lb.LoadBalancer.NewCreateLBStickinessPolicyParams(lbRuleId, stickinessMethodName, lbRuleName)

params := parseStickinessParams(stickinessMethodParam)
p.SetParam(params)

stickinessPolicy, err := lb.LoadBalancer.CreateLBStickinessPolicy(p)
if err != nil {
return nil, fmt.Errorf("error creating stickiness policy: %v", err)
}
if len(stickinessPolicy.Stickinesspolicy) == 0 {
return nil, fmt.Errorf("error creating stickiness policy: no policy returned for load balancer rule %v", lbRuleName)
}
return &cloudstack.LBStickinessPolicyStickinesspolicy{
Methodname: stickinessPolicy.Stickinesspolicy[0].Methodname,
Params: stickinessPolicy.Stickinesspolicy[0].Params,
Id: stickinessPolicy.Stickinesspolicy[0].Id,
Name: stickinessPolicy.Stickinesspolicy[0].Name,
State: stickinessPolicy.Stickinesspolicy[0].State,
}, nil
}

// deleteStickinessPolicy deletes a stickiness policy.
func (lb *loadBalancer) deleteStickinessPolicy(stickinessPolicyId string) error {
p := lb.LoadBalancer.NewDeleteLBStickinessPolicyParams(stickinessPolicyId)

if _, err := lb.LoadBalancer.DeleteLBStickinessPolicy(p); err != nil {
return fmt.Errorf("error deleting stickiness policy %v: %v", stickinessPolicyId, err)
}
return nil
}

// createLoadBalancerRule creates a new load balancer rule and returns it's ID.
func (lb *loadBalancer) createLoadBalancerRule(lbRuleName string, port corev1.ServicePort, protocol LoadBalancerProtocol, service *corev1.Service) (*cloudstack.LoadBalancerRule, error) {
p := lb.LoadBalancer.NewCreateLoadBalancerRuleParams(
Expand Down Expand Up @@ -772,6 +907,7 @@ func (lb *loadBalancer) deleteLoadBalancerRule(lbRule *cloudstack.LoadBalancerRu

// Delete the rule from the map as it no longer exists
delete(lb.rules, lbRule.Name)
delete(lb.stickinessPolicies, lbRule.Id)

return nil
}
Expand Down Expand Up @@ -1136,6 +1272,23 @@ func getStringFromServiceAnnotation(service *corev1.Service, annotationKey strin
return defaultSetting
}

// parseStickinessParams parses a comma-separated string of key=value pairs into a map.
// Empty values and malformed entries are ignored.
func parseStickinessParams(paramString string) map[string]string {
params := make(map[string]string)
for _, param := range strings.Split(paramString, ",") {
param = strings.TrimSpace(param)
if param == "" {
continue
}
parts := strings.SplitN(param, "=", 2)
if len(parts) == 2 {
params[parts[0]] = parts[1]
}
}
return params
}

// getBoolFromServiceAnnotation searches a given v1.Service for a specific annotationKey and either returns the annotation's boolean value or a specified defaultSetting
func getBoolFromServiceAnnotation(service *corev1.Service, annotationKey string, defaultSetting bool) bool {
klog.V(4).Infof("getBoolFromServiceAnnotation(%s/%s, %v, %v)", service.Namespace, service.Name, annotationKey, defaultSetting)
Expand Down
Loading
Loading