Skip to content

Load balancing & Services

To expose a workload outside the cluster, you create a Service type=LoadBalancer. CAPVCO turns that into a real whitesky.cloud L4 load balancer with a public frontend, pooling your nodes behind it. This guide covers the two ways to run it and the Service features that are (and aren't) supported.

Two modes — pick one

There are two add-ons that reconcile Service type=LoadBalancer. Enable exactly one — they both run the same Service-LB controller and will fight if both are on.

Add-on Label Also does node init? Use when
Service-LB (standalone) …/service-lb: vco No You just want LoadBalancer Services. Start here.
Cloud-controller-manager …/ccm: enabled Yes You want --cloud-provider=external node initialisation and LoadBalancer Services.
Cloud-controller-manager, internal …/ccm: internal Yes The same, on a private cluster: frontends default to the cloudspace private gateway instead of its external IP. Required there — …/ccm: enabled is refused.

Never enable both

The CCM already runs the Service-LB controller internally. Enabling service-lb alongside ccm gives you two controllers reconciling the same Services against each other. Pick one.

Two steps — a label on the Cluster, and the add-on's ClusterResourceSet applied once to the management cluster (exactly like the CNI add-on).

1. Label the Cluster with capvco.infrastructure.cluster.x-k8s.io/service-lb: vco. The base template ships it commented out, ready to uncomment before you clusterctl generate — or add it to an existing cluster:

kubectl label cluster <name> capvco.infrastructure.cluster.x-k8s.io/service-lb=vco

2. Apply the add-on to the management cluster (once — it delivers the vcolb controller to every cluster carrying that label, via ClusterResourceSet):

kubectl apply -f https://gitlab.whitesky.cloud/api/v4/projects/730/packages/generic/capvco/v0.6.1/service-lb.yaml

The manager then injects the per-cluster vco-lb-credential Secret into the workload cluster automatically (the vcolb controller reads it to reach the whitesky.cloud API) — nothing else to create. vcolb runs inside the workload cluster and does not touch node initialisation or set --cloud-provider=external, so there's no uninitialized taint to worry about — it's the low-friction option.

Cloud-controller-manager

Use the cluster-template-ccm.yaml template, or add the label:

metadata:
  labels:
    capvco.infrastructure.cluster.x-k8s.io/ccm: enabled

…and apply the CCM add-on to the management cluster once (same pattern as above; it also reads the auto-injected vco-lb-credential):

kubectl apply -f https://gitlab.whitesky.cloud/api/v4/projects/730/packages/generic/capvco/v0.6.1/ccm.yaml

The CCM (vco-ccm) does two jobs:

  1. Node initialisation — sets node addresses and topology.kubernetes.io/region + zone labels, and removes the node.cloudprovider.kubernetes.io/uninitialized taint.
  2. Service load balancing — same controller as the standalone add-on.

--cloud-provider=external and the CCM go together — or not at all

Node init only happens when kubelets run with --cloud-provider=external (the ccm template sets this). That flag makes kubelet register nodes tainted uninitialized until a CCM clears them. So: set --cloud-provider=external only if the CCM add-on is enabled — otherwise your nodes stay tainted forever and never schedule. The non-CCM path (base template) deliberately leaves it off and sets the provider-id via a kubelet drop-in instead.

Using it

Nothing whitesky.cloud-specific in your manifests — it's a stock Service:

apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: LoadBalancer
  selector: {app: web}
  ports:
    - port: 80
      targetPort: 8080
{
  "apiVersion": "v1",
  "kind": "Service",
  "metadata": {
    "name": "web"
  },
  "spec": {
    "type": "LoadBalancer",
    "selector": {
      "app": "web"
    },
    "ports": [
      {
        "port": 80,
        "targetPort": 8080
      }
    ]
  }
}
kubectl get svc web -w      # EXTERNAL-IP populates when the LB is up

The controller allocates a public frontend IP, builds a server pool of your Ready nodes, creates one L4 LB per service port (frontend IP:port → node :nodePort), and writes the address back to status.loadBalancer.ingress. Deleting the Service tears the LB and pool down (a finalizer handles it).

Internal (private) load balancers

By default a Service frontend lands on the cloudspace external IP. It can go on the private network gateway instead, so the Service is reachable from inside the cloudspace — or over a link into it — and from nowhere on the internet.

Per Service, by annotation:

metadata:
  annotations:
    vcolb.infrastructure.cluster.x-k8s.io/internal: "true"

or by class, which is immutable and so cannot be edited away later:

spec:
  loadBalancerClass: vco.whitesky.cloud/internal

status.loadBalancer.ingress[0].ip becomes <private-network>.1.

On a private cluster you usually want the default flipped, so a Service has to ask to be public. Use the service-lb-internal add-on bundle instead of service-lb: same controller, started with --default-internal-load-balancers, selected by capvco.infrastructure.cluster.x-k8s.io/service-lb: vco-internal rather than vco. An individual Service then opts out with the annotation set to "false", or loadBalancerClass: vco.whitesky.cloud/public.

Coexisting with MetalLB, kube-vip, and friends

spec.loadBalancerClass is honoured. A Service carrying another implementation's class is ignored completely — not reconciled, not finalized — so the two controllers do not fight over status.loadBalancer. A Service with no class is handled by CAPVCO, which is what every pre-existing Service looks like.

Supported Service features

Feature Supported How
externalTrafficPolicy: Local ✅ Pools only Ready nodes that host a Ready endpoint (via EndpointSlices). Cluster (default) pools all Ready nodes.
loadBalancerSourceRanges ✅ Enforced with a per-Service Traefik ipAllowList middleware. The loadBalancerSourceRanges field and the equivalent annotation both work.
Multiple ports ✅ One backend LB per port.
LB-level health checks ❌ The platform's L4 ingress (Traefik TCP) has no health checks. The controller compensates by pooling only Ready nodes — which is why externalTrafficPolicy: Local is meaningful here.
Session affinity (sessionAffinity: ClientIP) ⚠️ Not an LB feature — it's handled by kube-proxy on the node. No LB-level lever.

About the frontend IP

The frontend uses the cloudspace's external-network IP. Per-Service dedicated public IPs need an attached external network / public-IP quota and are not yet wired — see known limitations.

Geo clusters

The Service-LB controller currently uses the cloudspace of the first node it finds. A geo cluster ideally wants an LB per cloudspace; that per-site behaviour isn't done yet. For geo today, LoadBalancer Services work but aren't site-aware — see known limitations.


Next: publish DNS names for those Services automatically → ExternalDNS.