Skip to content

Commit 25de738

Browse files
committed
feat(loadbalancer): add health check port annotation
Signed-off-by: Zadkiel AHARONIAN <hello@zadkiel.fr>
1 parent 3bcbeb8 commit 25de738

File tree

4 files changed

+223
-3
lines changed

4 files changed

+223
-3
lines changed

docs/loadbalancer-annotations.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ The default value is `5s`. The duration are go's time.Duration (ex: `1s`, `2m`,
6969
This is the annotation to set the number of consecutive unsuccessful health checks, after wich the server will be considered dead.
7070
The default value is `5`.
7171

72+
### `service.beta.kubernetes.io/scw-loadbalancer-health-check-port`
73+
This is the annotation to explicitly define the port used for health checks.
74+
It is possible to set a single port for all backends like `18080` or per port like `80:10080;443:10443`.
75+
The port must be a valid TCP/UDP port (1-65535).
76+
If not set, the service port is used as the health check port.
77+
7278
### `service.beta.kubernetes.io/scw-loadbalancer-health-check-http-uri`
7379
This is the annotation to set the URI that is used by the `http` health check.
7480
It is possible to set the uri per port, like `80:/;443,8443:mydomain.tld/healthz`.

scaleway/loadbalancers.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,9 +1158,13 @@ func servicePortToBackend(service *v1.Service, loadbalancer *scwlb.LB, port v1.S
11581158
return nil, err
11591159
}
11601160

1161-
healthCheck := &scwlb.HealthCheck{
1162-
Port: port.NodePort,
1161+
healthCheck := &scwlb.HealthCheck{}
1162+
1163+
healthCheckPort, err := getHealthCheckPort(service, port.NodePort)
1164+
if err != nil {
1165+
return nil, err
11631166
}
1167+
healthCheck.Port = healthCheckPort
11641168

11651169
healthCheckDelay, err := getHealthCheckDelay(service)
11661170
if err != nil {
@@ -1649,7 +1653,7 @@ func (l *loadbalancers) updateBackend(service *v1.Service, loadbalancer *scwlb.L
16491653
if _, err := l.api.UpdateHealthCheck(&scwlb.ZonedAPIUpdateHealthCheckRequest{
16501654
Zone: loadbalancer.Zone,
16511655
BackendID: backend.ID,
1652-
Port: backend.ForwardPort,
1656+
Port: backend.HealthCheck.Port,
16531657
CheckDelay: backend.HealthCheck.CheckDelay,
16541658
CheckTimeout: backend.HealthCheck.CheckTimeout,
16551659
CheckMaxRetries: backend.HealthCheck.CheckMaxRetries,

scaleway/loadbalancers_annotations.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ const (
8686
// NB: Required when setting service.beta.kubernetes.io/scw-loadbalancer-health-check-type to "pgsql"
8787
serviceAnnotationLoadBalancerHealthCheckPgsqlUser = "service.beta.kubernetes.io/scw-loadbalancer-health-check-pgsql-user"
8888

89+
// serviceAnnotationLoadBalancerHealthCheckPort is the annotation to explicitly define the port used for health checks
90+
// It is possible to set a single port for all backends like "18080" or per port like "80:10080;443:10443"
91+
// The port must be a valid TCP/UDP port (1-65535)
92+
// If not set, the service port is used as the health check port
93+
serviceAnnotationLoadBalancerHealthCheckPort = "service.beta.kubernetes.io/scw-loadbalancer-health-check-port"
94+
8995
// serviceAnnotationLoadBalancerSendProxyV2 is the annotation that enables PROXY protocol version 2 (must be supported by backend servers)
9096
// The default value is "false" and the possible values are "false" or "true"
9197
// or a comma delimited list of the service port on which to apply the proxy protocol (for instance "80,443")
@@ -629,6 +635,38 @@ func getHealthCheckTransientCheckDelay(service *v1.Service) (*scw.Duration, erro
629635
}, nil
630636
}
631637

638+
// getHealthCheckPort returns the port to use for health checks.
639+
// It supports per-port configuration with the format "80:10080;443:10443" or a single port like "18080".
640+
// If the annotation is not set, it returns the provided nodePort as default.
641+
func getHealthCheckPort(service *v1.Service, nodePort int32) (int32, error) {
642+
annotation, ok := service.Annotations[serviceAnnotationLoadBalancerHealthCheckPort]
643+
if !ok {
644+
return nodePort, nil
645+
}
646+
647+
portStr, err := getValueForPort(service, nodePort, annotation)
648+
if err != nil {
649+
klog.Errorf("could not get value for annotation %s and port %d", serviceAnnotationLoadBalancerHealthCheckPort, nodePort)
650+
return 0, err
651+
}
652+
653+
if portStr == "" {
654+
return nodePort, nil
655+
}
656+
657+
port, err := strconv.ParseInt(portStr, 10, 32)
658+
if err != nil {
659+
klog.Errorf("invalid value for annotation %s: %s is not a valid port number", serviceAnnotationLoadBalancerHealthCheckPort, portStr)
660+
return 0, errLoadBalancerInvalidAnnotation
661+
}
662+
if port < 1 || port > 65535 {
663+
klog.Errorf("invalid value for annotation %s: port %d is out of range (1-65535)", serviceAnnotationLoadBalancerHealthCheckPort, port)
664+
return 0, errLoadBalancerInvalidAnnotation
665+
}
666+
667+
return int32(port), nil
668+
}
669+
632670
func getForceInternalIP(service *v1.Service) bool {
633671
forceInternalIP, ok := service.Annotations[serviceAnnotationLoadBalancerForceInternalIP]
634672
if !ok {

scaleway/loadbalancers_test.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,178 @@ func TestGetValueForPort(t *testing.T) {
283283
}
284284
}
285285

286+
func TestGetHealthCheckPort(t *testing.T) {
287+
testCases := []struct {
288+
name string
289+
svc *v1.Service
290+
nodePort int32
291+
result int32
292+
errMessage string
293+
}{
294+
{
295+
name: "no annotation, returns nodePort",
296+
svc: &v1.Service{
297+
Spec: v1.ServiceSpec{
298+
Ports: []v1.ServicePort{
299+
{
300+
NodePort: 30080,
301+
Port: 80,
302+
},
303+
},
304+
},
305+
},
306+
nodePort: 30080,
307+
result: 30080,
308+
errMessage: "",
309+
},
310+
{
311+
name: "minimum valid port",
312+
svc: &v1.Service{
313+
ObjectMeta: metav1.ObjectMeta{
314+
Annotations: map[string]string{
315+
"service.beta.kubernetes.io/scw-loadbalancer-health-check-port": "1",
316+
},
317+
},
318+
Spec: v1.ServiceSpec{
319+
Ports: []v1.ServicePort{
320+
{
321+
NodePort: 30080,
322+
Port: 80,
323+
},
324+
},
325+
},
326+
},
327+
nodePort: 30080,
328+
result: 1,
329+
errMessage: "",
330+
},
331+
{
332+
name: "maximum valid port",
333+
svc: &v1.Service{
334+
ObjectMeta: metav1.ObjectMeta{
335+
Annotations: map[string]string{
336+
"service.beta.kubernetes.io/scw-loadbalancer-health-check-port": "65535",
337+
},
338+
},
339+
Spec: v1.ServiceSpec{
340+
Ports: []v1.ServicePort{
341+
{
342+
NodePort: 30080,
343+
Port: 80,
344+
},
345+
},
346+
},
347+
},
348+
nodePort: 30080,
349+
result: 65535,
350+
errMessage: "",
351+
},
352+
// Error cases
353+
{
354+
name: "port too low (0)",
355+
svc: &v1.Service{
356+
ObjectMeta: metav1.ObjectMeta{
357+
Annotations: map[string]string{
358+
"service.beta.kubernetes.io/scw-loadbalancer-health-check-port": "0",
359+
},
360+
},
361+
Spec: v1.ServiceSpec{
362+
Ports: []v1.ServicePort{
363+
{
364+
NodePort: 30080,
365+
Port: 80,
366+
},
367+
},
368+
},
369+
},
370+
nodePort: 30080,
371+
result: 0,
372+
errMessage: "load balancer invalid annotation",
373+
},
374+
{
375+
name: "port too high (65536)",
376+
svc: &v1.Service{
377+
ObjectMeta: metav1.ObjectMeta{
378+
Annotations: map[string]string{
379+
"service.beta.kubernetes.io/scw-loadbalancer-health-check-port": "65536",
380+
},
381+
},
382+
Spec: v1.ServiceSpec{
383+
Ports: []v1.ServicePort{
384+
{
385+
NodePort: 30080,
386+
Port: 80,
387+
},
388+
},
389+
},
390+
},
391+
nodePort: 30080,
392+
result: 0,
393+
errMessage: "load balancer invalid annotation",
394+
},
395+
{
396+
name: "negative port",
397+
svc: &v1.Service{
398+
ObjectMeta: metav1.ObjectMeta{
399+
Annotations: map[string]string{
400+
"service.beta.kubernetes.io/scw-loadbalancer-health-check-port": "-1",
401+
},
402+
},
403+
Spec: v1.ServiceSpec{
404+
Ports: []v1.ServicePort{
405+
{
406+
NodePort: 30080,
407+
Port: 80,
408+
},
409+
},
410+
},
411+
},
412+
nodePort: 30080,
413+
result: 0,
414+
errMessage: "load balancer invalid annotation",
415+
},
416+
{
417+
name: "non-numeric value",
418+
svc: &v1.Service{
419+
ObjectMeta: metav1.ObjectMeta{
420+
Annotations: map[string]string{
421+
"service.beta.kubernetes.io/scw-loadbalancer-health-check-port": "not-a-number",
422+
},
423+
},
424+
Spec: v1.ServiceSpec{
425+
Ports: []v1.ServicePort{
426+
{
427+
NodePort: 30080,
428+
Port: 80,
429+
},
430+
},
431+
},
432+
},
433+
nodePort: 30080,
434+
result: 0,
435+
errMessage: "load balancer invalid annotation",
436+
},
437+
}
438+
439+
for _, tc := range testCases {
440+
t.Run(tc.name, func(t *testing.T) {
441+
result, err := getHealthCheckPort(tc.svc, tc.nodePort)
442+
if result != tc.result {
443+
t.Errorf("getHealthCheckPort: got %d, expected %d", result, tc.result)
444+
}
445+
if err == nil && tc.errMessage != "" {
446+
t.Errorf("getHealthCheckPort: expected error %q, got nil", tc.errMessage)
447+
}
448+
if err != nil && tc.errMessage == "" {
449+
t.Errorf("getHealthCheckPort: unexpected error %v", err)
450+
}
451+
if err != nil && tc.errMessage != "" && err.Error() != tc.errMessage {
452+
t.Errorf("getHealthCheckPort: got error %q, expected %q", err.Error(), tc.errMessage)
453+
}
454+
})
455+
}
456+
}
457+
286458
func TestFilterNodes(t *testing.T) {
287459
service := &v1.Service{
288460
ObjectMeta: metav1.ObjectMeta{

0 commit comments

Comments
 (0)