Skip to content

Commit 7266ebe

Browse files
committed
feat: add nodeselector annotation to limit LB pool members
1 parent 16fd5c0 commit 7266ebe

File tree

11 files changed

+687
-117
lines changed

11 files changed

+687
-117
lines changed

.golangci.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,6 @@ linters:
2929
- usestdlibvars
3030
- whitespace
3131
- wsl
32-
settings:
33-
lll:
34-
line-length: 80
35-
tab-width: 4
3632
exclusions:
3733
generated: lax
3834
presets:

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,17 @@ Changes to following annotations may lead to new connections timing out until th
287287
- `k8s.cloudscale.ch/loadbalancer-health-monitor-type`
288288
- `k8s.cloudscale.ch/loadbalancer-health-monitor-http`
289289

290+
Changes to the `k8s.cloudscale.ch/loadbalancer-node-selector` annotation are generally safe,
291+
as long as the selector matches valid node labels.
292+
When using `externalTrafficPolicy: Local`, ensure the selector targets nodes that are
293+
actually running the backend pods. Otherwise, traffic will be dropped.
294+
295+
Unlike the well-known node label [`node.kubernetes.io/exclude-from-external-load-balancers=true`](https://kubernetes.io/docs/reference/labels-annotations-taints/#node-kubernetes-io-exclude-from-external-load-balancers),
296+
which globally excludes nodes from *all* LoadBalancer services, this annotation allows
297+
targeting a specific subset of nodes on a per-service basis.
298+
Note that the `exclude-from-external-load-balancers` label is applied first: nodes with
299+
this label are excluded before the `loadbalancer-node-selector` is evaluated.
300+
290301
##### Listener Port Changes
291302

292303
Changes to the outward bound service port have a downtime ranging from 15s to 120s, depending on the action. Since the name of the port is used to avoid expensive pool recreation, the impact is minimal if the port name does not change.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Deploys the docker.io/nginxdemos/hello:plain-text container and creates a
2+
# loadbalancer service with a node-selector annotation for it:
3+
#
4+
# export KUBECONFIG=path/to/kubeconfig
5+
# kubectl apply -f nginx-hello-nodeselector.yml
6+
#
7+
# Wait for `kubectl describe service hello` to show "Loadbalancer Ensured",
8+
# then use the IP address found under "LoadBalancer Ingress" to connect to the
9+
# service.
10+
#
11+
# You can also use the following shortcut:
12+
#
13+
# curl http://$(kubectl get service hello -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
14+
#
15+
# If you adjust the nodeSelector of the deployment, or the `loadbalancer-node-selector` annotation on the service,
16+
# you'll notice that if they don't match, the request will fail.
17+
#
18+
---
19+
apiVersion: apps/v1
20+
kind: Deployment
21+
metadata:
22+
name: hello
23+
spec:
24+
replicas: 2
25+
selector:
26+
matchLabels:
27+
app: hello
28+
template:
29+
metadata:
30+
labels:
31+
app: hello
32+
spec:
33+
containers:
34+
- name: hello
35+
image: docker.io/nginxdemos/hello:plain-text
36+
nodeSelector:
37+
kubernetes.io/hostname: k8test-worker-2
38+
---
39+
apiVersion: v1
40+
kind: Service
41+
metadata:
42+
labels:
43+
app: hello
44+
annotations:
45+
k8s.cloudscale.ch/loadbalancer-node-selector: "kubernetes.io/hostname=k8test-worker-2"
46+
name: hello
47+
spec:
48+
ports:
49+
- port: 80
50+
protocol: TCP
51+
targetPort: 80
52+
name: http
53+
selector:
54+
app: hello
55+
type: LoadBalancer
56+
externalTrafficPolicy: Local

pkg/cloudscale_ccm/cloud.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ import (
99
"strings"
1010
"time"
1111

12-
cloudscale "github.com/cloudscale-ch/cloudscale-go-sdk/v6"
12+
"github.com/cloudscale-ch/cloudscale-go-sdk/v6"
1313
"golang.org/x/oauth2"
14-
"k8s.io/client-go/kubernetes"
14+
corev1 "k8s.io/api/core/v1"
15+
"k8s.io/client-go/kubernetes/scheme"
16+
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
17+
"k8s.io/client-go/tools/record"
1518
"k8s.io/klog/v2"
1619

1720
cloudprovider "k8s.io/cloud-provider"
@@ -32,6 +35,8 @@ const (
3235
type cloud struct {
3336
instances *instances
3437
loadbalancer *loadbalancer
38+
39+
eventRecorder record.EventRecorder
3540
}
3641

3742
// Register this provider with Kubernetes.
@@ -112,8 +117,27 @@ func (c *cloud) Initialize(
112117

113118
// This cannot be configured earlier, even though it seems better situated
114119
// in newCloudscaleClient
115-
c.loadbalancer.k8s = kubernetes.NewForConfigOrDie(
116-
clientBuilder.ConfigOrDie("cloudscale-cloud-controller-manager"))
120+
c.loadbalancer.k8s = clientBuilder.ClientOrDie(
121+
"cloudscale-cloud-controller-manager",
122+
)
123+
124+
eventBroadcaster := record.NewBroadcaster()
125+
eventBroadcaster.StartRecordingToSink(&v1.EventSinkImpl{
126+
Interface: c.loadbalancer.k8s.CoreV1().Events(""),
127+
})
128+
c.eventRecorder = eventBroadcaster.NewRecorder(scheme.Scheme,
129+
corev1.EventSource{
130+
Component: "cloudscale-cloud-controller-manager",
131+
},
132+
)
133+
134+
go func() {
135+
// wait until stop chan closes
136+
<-stop
137+
eventBroadcaster.Shutdown()
138+
}()
139+
140+
c.loadbalancer.recorder = c.eventRecorder
117141
}
118142

119143
// LoadBalancer returns a balancer interface. Also returns true if the

pkg/cloudscale_ccm/loadbalancer.go

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,20 @@ package cloudscale_ccm
22

33
import (
44
"context"
5-
"errors"
65
"fmt"
76
"slices"
87
"strings"
98

10-
"github.com/cloudscale-ch/cloudscale-cloud-controller-manager/pkg/internal/kubeutil"
119
"github.com/cloudscale-ch/cloudscale-go-sdk/v6"
1210
v1 "k8s.io/api/core/v1"
1311
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/labels"
1413
"k8s.io/client-go/kubernetes"
14+
"k8s.io/client-go/tools/record"
1515
"k8s.io/klog/v2"
1616
"k8s.io/utils/ptr"
17+
18+
"github.com/cloudscale-ch/cloudscale-cloud-controller-manager/pkg/internal/kubeutil"
1719
)
1820

1921
// Annotations used by the loadbalancer integration of cloudscale_ccm. Those
@@ -208,7 +210,7 @@ const (
208210
// connections timing out while the monitor is updated.
209211
LoadBalancerHealthMonitorTimeoutS = "k8s.cloudscale.ch/loadbalancer-health-monitor-timeout-s"
210212

211-
// LoadBalancerHealthMonitorDownThreshold is the number of the checks that
213+
// LoadBalancerHealthMonitorUpThreshold is the number of the checks that
212214
// need to succeed before a pool member is considered up. Defaults to 2.
213215
LoadBalancerHealthMonitorUpThreshold = "k8s.cloudscale.ch/loadbalancer-health-monitor-up-threshold"
214216

@@ -278,7 +280,7 @@ const (
278280
// Changing this annotation on an established service is considered safe.
279281
LoadBalancerListenerTimeoutMemberDataMS = "k8s.cloudscale.ch/loadbalancer-timeout-member-data-ms"
280282

281-
// LoadBalancerSubnetLimit is a JSON list of subnet UUIDs that the
283+
// LoadBalancerListenerAllowedSubnets is a JSON list of subnet UUIDs that the
282284
// loadbalancer should use. By default, all subnets of a node are used:
283285
//
284286
// * `[]` means that anyone is allowed to connect (default).
@@ -291,12 +293,21 @@ const (
291293
// This is an advanced feature, useful if you have nodes that are in
292294
// multiple private subnets.
293295
LoadBalancerListenerAllowedSubnets = "k8s.cloudscale.ch/loadbalancer-listener-allowed-subnets"
296+
297+
// LoadBalancerNodeSelector can be set to restrict which nodes are added to the LB pool.
298+
// It accepts a standard Kubernetes label selector string.
299+
//
300+
// N.B.: If the node-selector annotation doesn't match any nodes, the LoadBalancer will remove all members
301+
// from the LB pool, effectively causing a downtime on the LB.
302+
// Make sure to verify the node selector well before setting it.
303+
LoadBalancerNodeSelector = "k8s.cloudscale.ch/loadbalancer-node-selector"
294304
)
295305

296306
type loadbalancer struct {
297-
lbs lbMapper
298-
srv serverMapper
299-
k8s kubernetes.Interface
307+
lbs lbMapper
308+
srv serverMapper
309+
k8s kubernetes.Interface
310+
recorder record.EventRecorder
300311
}
301312

302313
// GetLoadBalancer returns whether the specified load balancer exists, and
@@ -387,24 +398,34 @@ func (l *loadbalancer) EnsureLoadBalancer(
387398
return nil, err
388399
}
389400

390-
// Refuse to do anything if there are no nodes
391-
if len(nodes) == 0 {
392-
return nil, errors.New(
393-
"no valid nodes for service found, please verify there is " +
394-
"at least one that allows load balancers",
401+
filteredNodes, err := filterNodesBySelector(serviceInfo, nodes)
402+
if err != nil {
403+
return nil, err
404+
}
405+
406+
if len(filteredNodes) == 0 {
407+
l.recorder.Event(
408+
service,
409+
v1.EventTypeWarning,
410+
"NoValidNodes",
411+
fmt.Sprintf("No valid nodes for service found, "+
412+
"double-check node-selector annotation: %s: %s",
413+
LoadBalancerNodeSelector,
414+
serviceInfo.annotation(LoadBalancerNodeSelector),
415+
),
395416
)
396417
}
397418

398419
// Reconcile
399-
err := reconcileLbState(ctx, l.lbs.client, func() (*lbState, error) {
420+
err = reconcileLbState(ctx, l.lbs.client, func() (*lbState, error) {
400421
// Get the desired state from Kubernetes
401-
servers, err := l.srv.mapNodes(ctx, nodes).All()
422+
servers, err := l.srv.mapNodes(ctx, filteredNodes).All()
402423
if err != nil {
403424
return nil, fmt.Errorf(
404425
"unable to get load balancer for %s: %w", service.Name, err)
405426
}
406427

407-
return desiredLbState(serviceInfo, nodes, servers)
428+
return desiredLbState(serviceInfo, filteredNodes, servers)
408429
}, func() (*lbState, error) {
409430
// Get the current state from cloudscale.ch
410431
return actualLbState(ctx, &l.lbs, serviceInfo)
@@ -442,6 +463,28 @@ func (l *loadbalancer) EnsureLoadBalancer(
442463
return result, nil
443464
}
444465

466+
func filterNodesBySelector(
467+
serviceInfo *serviceInfo,
468+
nodes []*v1.Node,
469+
) ([]*v1.Node, error) {
470+
selector := labels.Everything()
471+
if v := serviceInfo.annotation(LoadBalancerNodeSelector); v != "" {
472+
var err error
473+
selector, err = labels.Parse(v)
474+
if err != nil {
475+
return nil, fmt.Errorf("unable to parse selector: %w", err)
476+
}
477+
}
478+
selectedNodes := make([]*v1.Node, 0, len(nodes))
479+
for _, node := range nodes {
480+
if selector.Matches(labels.Set(node.Labels)) {
481+
selectedNodes = append(selectedNodes, node)
482+
}
483+
}
484+
485+
return selectedNodes, nil
486+
}
487+
445488
// UpdateLoadBalancer updates hosts under the specified load balancer.
446489
// Implementations must treat the *v1.Service and *v1.Node
447490
// parameters as read-only and not modify them.
@@ -461,16 +504,34 @@ func (l *loadbalancer) UpdateLoadBalancer(
461504
return err
462505
}
463506

507+
filteredNodes, err := filterNodesBySelector(serviceInfo, nodes)
508+
if err != nil {
509+
return err
510+
}
511+
512+
if len(filteredNodes) == 0 {
513+
l.recorder.Event(
514+
service,
515+
v1.EventTypeWarning,
516+
"NoValidNodes",
517+
fmt.Sprintf("No valid nodes for service found, "+
518+
"double-check node-selector annotation: %s: %s",
519+
LoadBalancerNodeSelector,
520+
serviceInfo.annotation(LoadBalancerNodeSelector),
521+
),
522+
)
523+
}
524+
464525
// Reconcile
465526
return reconcileLbState(ctx, l.lbs.client, func() (*lbState, error) {
466527
// Get the desired state from Kubernetes
467-
servers, err := l.srv.mapNodes(ctx, nodes).All()
528+
servers, err := l.srv.mapNodes(ctx, filteredNodes).All()
468529
if err != nil {
469530
return nil, fmt.Errorf(
470531
"unable to get load balancer for %s: %w", service.Name, err)
471532
}
472533

473-
return desiredLbState(serviceInfo, nodes, servers)
534+
return desiredLbState(serviceInfo, filteredNodes, servers)
474535
}, func() (*lbState, error) {
475536
// Get the current state from cloudscale.ch
476537
return actualLbState(ctx, &l.lbs, serviceInfo)

0 commit comments

Comments
 (0)