Skip to content

Commit ae6b878

Browse files
authored
Merge pull request #197 from aslafy-z/feat/healthchecknodeportannotation
feat(loadbalancer): add healthCheckNodePort opt-in support
2 parents b519ff9 + 2c5df87 commit ae6b878

File tree

4 files changed

+626
-61
lines changed

4 files changed

+626
-61
lines changed

docs/loadbalancer-annotations.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,3 +239,18 @@ This annotation is ignored when `service.beta.kubernetes.io/scw-loadbalancer-ext
239239
The possible formats are:
240240
- `<pn-id>`: will attach a single Private Network to the LB.
241241
- `<pn-id>,<pn-id>`: will attach the two Private Networks to the LB.
242+
243+
### `service.beta.kubernetes.io/scw-loadbalancer-health-check-from-service`
244+
245+
This is the annotation to configure the load balancer backend to use the service's `healthCheckNodePort` for health checks.
246+
When enabled for a port, the health check will use the `healthCheckNodePort` from the service specification instead of the regular `NodePort`.
247+
This is particularly useful when the service has `externalTrafficPolicy: Local`, which automatically allocates a `healthCheckNodePort` for health checking.
248+
The possible values are `false`, `true` or `*` for all ports or a comma delimited list of the service ports (for instance `80,443`).
249+
250+
**Important:** When this annotation is enabled, the health check configuration is overridden with the following settings:
251+
- **Protocol:** HTTP
252+
- **Method:** GET
253+
- **URI:** `/healthz`
254+
- **Expected Code:** 200
255+
256+
This configuration is specifically designed to work with Kubernetes' standard health check endpoint. All other health check type annotations (such as `service.beta.kubernetes.io/scw-loadbalancer-health-check-type`, `service.beta.kubernetes.io/scw-loadbalancer-health-check-http-uri`, etc.) are ignored for the ports where this annotation is enabled.

scaleway/loadbalancers.go

Lines changed: 72 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,10 @@ func isPortInRange(r string, p int32) (bool, error) {
994994
if err != nil {
995995
return false, err
996996
}
997+
// Validate port is within valid range (1-65535)
998+
if intPort < 1 || intPort > 65535 {
999+
return false, fmt.Errorf("port %d is outside valid range (1-65535)", intPort)
1000+
}
9971001
if int64(p) == intPort {
9981002
return true, nil
9991003
}
@@ -1158,8 +1162,74 @@ func servicePortToBackend(service *v1.Service, loadbalancer *scwlb.LB, port v1.S
11581162
return nil, err
11591163
}
11601164

1161-
healthCheck := &scwlb.HealthCheck{
1162-
Port: port.NodePort,
1165+
healthCheck, err := getNativeHealthCheck(service, port.Port)
1166+
if err != nil {
1167+
return nil, err
1168+
}
1169+
1170+
if healthCheck == nil {
1171+
healthCheck = &scwlb.HealthCheck{
1172+
Port: port.NodePort,
1173+
}
1174+
1175+
healthCheckType, err := getHealthCheckType(service, port.NodePort)
1176+
if err != nil {
1177+
return nil, err
1178+
}
1179+
1180+
switch healthCheckType {
1181+
case "mysql":
1182+
hc, err := getMysqlHealthCheck(service, port.NodePort)
1183+
if err != nil {
1184+
return nil, err
1185+
}
1186+
healthCheck.MysqlConfig = hc
1187+
case "ldap":
1188+
hc, err := getLdapHealthCheck(service, port.NodePort)
1189+
if err != nil {
1190+
return nil, err
1191+
}
1192+
healthCheck.LdapConfig = hc
1193+
case "redis":
1194+
hc, err := getRedisHealthCheck(service, port.NodePort)
1195+
if err != nil {
1196+
return nil, err
1197+
}
1198+
healthCheck.RedisConfig = hc
1199+
case "pgsql":
1200+
hc, err := getPgsqlHealthCheck(service, port.NodePort)
1201+
if err != nil {
1202+
return nil, err
1203+
}
1204+
healthCheck.PgsqlConfig = hc
1205+
case "tcp":
1206+
hc, err := getTCPHealthCheck(service, port.NodePort)
1207+
if err != nil {
1208+
return nil, err
1209+
}
1210+
healthCheck.TCPConfig = hc
1211+
case "http":
1212+
hc, err := getHTTPHealthCheck(service, port.NodePort)
1213+
if err != nil {
1214+
return nil, err
1215+
}
1216+
healthCheck.HTTPConfig = hc
1217+
case "https":
1218+
hc, err := getHTTPSHealthCheck(service, port.NodePort)
1219+
if err != nil {
1220+
return nil, err
1221+
}
1222+
healthCheck.HTTPSConfig = hc
1223+
default:
1224+
klog.Errorf("wrong value for healthCheckType")
1225+
return nil, errLoadBalancerInvalidAnnotation
1226+
}
1227+
1228+
healthCheckSendProxy, err := getHealthCheckSendProxy(service)
1229+
if err != nil {
1230+
return nil, err
1231+
}
1232+
healthCheck.CheckSendProxy = healthCheckSendProxy
11631233
}
11641234

11651235
healthCheckDelay, err := getHealthCheckDelay(service)
@@ -1186,65 +1256,6 @@ func servicePortToBackend(service *v1.Service, loadbalancer *scwlb.LB, port v1.S
11861256
}
11871257
healthCheck.TransientCheckDelay = healthCheckTransientCheckDelay
11881258

1189-
healthCheckSendProxy, err := getHealthCheckSendProxy(service)
1190-
if err != nil {
1191-
return nil, err
1192-
}
1193-
healthCheck.CheckSendProxy = healthCheckSendProxy
1194-
1195-
healthCheckType, err := getHealthCheckType(service, port.NodePort)
1196-
if err != nil {
1197-
return nil, err
1198-
}
1199-
1200-
switch healthCheckType {
1201-
case "mysql":
1202-
hc, err := getMysqlHealthCheck(service, port.NodePort)
1203-
if err != nil {
1204-
return nil, err
1205-
}
1206-
healthCheck.MysqlConfig = hc
1207-
case "ldap":
1208-
hc, err := getLdapHealthCheck(service, port.NodePort)
1209-
if err != nil {
1210-
return nil, err
1211-
}
1212-
healthCheck.LdapConfig = hc
1213-
case "redis":
1214-
hc, err := getRedisHealthCheck(service, port.NodePort)
1215-
if err != nil {
1216-
return nil, err
1217-
}
1218-
healthCheck.RedisConfig = hc
1219-
case "pgsql":
1220-
hc, err := getPgsqlHealthCheck(service, port.NodePort)
1221-
if err != nil {
1222-
return nil, err
1223-
}
1224-
healthCheck.PgsqlConfig = hc
1225-
case "tcp":
1226-
hc, err := getTCPHealthCheck(service, port.NodePort)
1227-
if err != nil {
1228-
return nil, err
1229-
}
1230-
healthCheck.TCPConfig = hc
1231-
case "http":
1232-
hc, err := getHTTPHealthCheck(service, port.NodePort)
1233-
if err != nil {
1234-
return nil, err
1235-
}
1236-
healthCheck.HTTPConfig = hc
1237-
case "https":
1238-
hc, err := getHTTPSHealthCheck(service, port.NodePort)
1239-
if err != nil {
1240-
return nil, err
1241-
}
1242-
healthCheck.HTTPSConfig = hc
1243-
default:
1244-
klog.Errorf("wrong value for healthCheckType")
1245-
return nil, errLoadBalancerInvalidAnnotation
1246-
}
1247-
12481259
backend := &scwlb.Backend{
12491260
Name: fmt.Sprintf("%s_tcp_%d", string(service.UID), port.NodePort),
12501261
Pool: nodeIPs,

scaleway/loadbalancers_annotations.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,12 @@ const (
236236
// - "<pn-id>": will attach a single Private Network to the LB.
237237
// - "<pn-id>,<pn-id>": will attach the two Private Networks to the LB.
238238
serviceAnnotationPrivateNetworkIDs = "service.beta.kubernetes.io/scw-loadbalancer-pn-ids"
239+
240+
// serviceAnnotationLoadBalancerHealthCheckFromService is the annotation to use healthCheckNodePort from the service
241+
// The possible values are "false", "true" or "*" for all ports or a comma delimited list of the service port
242+
// (for instance "80,443"). When enabled for a port, the health check will use the service's healthCheckNodePort
243+
// instead of the regular NodePort, overriding any other health check configuration.
244+
serviceAnnotationLoadBalancerHealthCheckFromService = "service.beta.kubernetes.io/scw-loadbalancer-health-check-from-service"
239245
)
240246

241247
func getLoadBalancerID(service *v1.Service) (scw.Zone, string, error) {
@@ -1057,3 +1063,41 @@ func getEnableHTTP3(service *v1.Service) (bool, error) {
10571063
}
10581064
return strconv.ParseBool(enableHTTP3)
10591065
}
1066+
1067+
// getNativeHealthCheck returns the healthCheckNodePort config if the feature is enabled.
1068+
// Returns nil if standard legacy logic should be used.
1069+
func getNativeHealthCheck(service *v1.Service, targetPort int32) (*scwlb.HealthCheck, error) {
1070+
annotationValue := service.Annotations[serviceAnnotationLoadBalancerHealthCheckFromService]
1071+
isEnabled, err := isPortInRange(annotationValue, targetPort)
1072+
if err != nil {
1073+
return nil, fmt.Errorf("invalid health check annotation: %w", err)
1074+
}
1075+
1076+
if !isEnabled {
1077+
return nil, nil
1078+
}
1079+
1080+
// If the user requested the feature but K8s hasn't assigned the port yet (usually means
1081+
// externalTrafficPolicy is not set to Local), we must fall back to avoid blackholing traffic.
1082+
hcNodePort := service.Spec.HealthCheckNodePort
1083+
if hcNodePort == 0 {
1084+
klog.Warningf("Annotation '%s' is active for %s/%s, but HealthCheckNodePort is 0. "+
1085+
"Ensure 'externalTrafficPolicy: Local'. Falling back to standard NodePort.",
1086+
serviceAnnotationLoadBalancerHealthCheckFromService, service.Namespace, service.Name)
1087+
return nil, nil
1088+
}
1089+
1090+
// Validate healthCheckNodePort is within valid range
1091+
if hcNodePort < 1 || hcNodePort > 65535 {
1092+
return nil, fmt.Errorf("invalid healthCheckNodePort %d: port must be in range 1-65535", hcNodePort)
1093+
}
1094+
1095+
return &scwlb.HealthCheck{
1096+
Port: hcNodePort,
1097+
HTTPConfig: &scwlb.HealthCheckHTTPConfig{
1098+
Method: "GET",
1099+
Code: scw.Int32Ptr(200),
1100+
URI: "/healthz",
1101+
},
1102+
}, nil
1103+
}

0 commit comments

Comments
 (0)