Skip to content

Local cluster: Lb

vind ships LoadBalancer support out of the box — no MetalLB, no cloud provider integration needed. Creating a Service of type LoadBalancer makes vind spin up a dedicated HAProxy container that gets its own IP on the cluster's Docker bridge network and forwards traffic to the Service's backing pods. This page walks through a live test of that path, end to end, with an explanation of what's actually happening at each step.

Reference

Getting Started with vind: First Kubernetes Deployment with Built-in LoadBalancer

1. Baseline — nothing but cluster-internal Services yet

$ kubectl get svc -A
NAMESPACE     NAME         TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                  AGE
default       kubernetes   ClusterIP   10.96.0.1        <none>        443/TCP                  86m
kube-system   kube-dns     ClusterIP   10.108.231.123   <none>        53/UDP,53/TCP,9153/TCP   86m

$ kubectl config current-context
vcluster-docker_local-k8s

Only the two default ClusterIP services exist — expected, since nothing has requested a LoadBalancer yet.

2. Create a test deployment and expose it

$ kubectl create deployment nginx-test --image=nginx
deployment.apps/nginx-test created

$ kubectl get pod
NAME                         READY   STATUS    RESTARTS   AGE
nginx-test-c8697b5cc-wrmfh   1/1     Running   0          111s

$ kubectl get svc nginx-test
NAME         TYPE           CLUSTER-IP      EXTERNAL-IP      PORT(S)        AGE
nginx-test   LoadBalancer   10.104.183.62   172.18.255.254   80:31681/TCP   14s

EXTERNAL-IP populates almost immediately — no <pending> state, unlike a real cloud cluster waiting on a cloud LB controller. 172.18.255.254 is not a real external/routable address; it's an IP on vind's own Docker bridge network for this cluster (confirmed in step 4 below). The 80:31681/TCP also shows the usual NodePort (31681) got allocated underneath, same as any LoadBalancer Service — vind's HAProxy container is what actually answers on 172.18.255.254:80 and forwards to it.

3. Confirm it's actually reachable

$ kubectl get svc -A
NAMESPACE     NAME         TYPE           CLUSTER-IP       EXTERNAL-IP      PORT(S)                  AGE
default       kubernetes   ClusterIP      10.96.0.1        <none>           443/TCP                  99m
default       nginx-test   LoadBalancer   10.104.183.62    172.18.255.254   80:31681/TCP             4m32s
kube-system   kube-dns     ClusterIP      10.108.231.123   <none>           53/UDP,53/TCP,9153/TCP   99m

$ curl -v 172.18.255.254
*   Trying 172.18.255.254:80...
* Connected to 172.18.255.254 (172.18.255.254) port 80
> GET / HTTP/1.1
> Host: 172.18.255.254
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: nginx/1.31.2
...
* Connection #0 to host 172.18.255.254 left intact

This is the actual proof: curl from the host laptop, straight to the EXTERNAL-IP, no kubectl port-forward, no minikube tunnel equivalent, no manual step. That only works because the Docker bridge is directly routable from a Linux host — see the platform caveat at the bottom.

4. What made this work — the HAProxy container

$ docker ps
CONTAINER ID   IMAGE                          COMMAND                  CREATED          STATUS          PORTS                                           NAMES
10aa96f4d791   haproxy:3.3-alpine             "docker-entrypoint.s…"   12 seconds ago   Up 11 seconds   80/tcp                                          vcluster.lb.local-k8s.nginx-test.default
96a1075b352e   ghcr.io/loft-sh/vm-container   "/entrypoint.sh"         2 hours ago      Up 2 hours                                                      vcluster.node.local-k8s.worker-3
f3cba5829f44   ghcr.io/loft-sh/vm-container   "/entrypoint.sh"         2 hours ago      Up 2 hours                                                      vcluster.node.local-k8s.worker-2
61e7dd1a8ed6   ghcr.io/loft-sh/vm-container   "/entrypoint.sh"         2 hours ago      Up 2 hours                                                      vcluster.node.local-k8s.worker-1
34dea50e8187   ghcr.io/loft-sh/vm-container   "/entrypoint.sh"         2 hours ago      Up 2 hours      0.0.0.0:10354->8443/tcp, [::]:10354->8443/tcp   vcluster.cp.local-k8s

Creating the LoadBalancer Service made vind start a new container, vcluster.lb.local-k8s.nginx-test.default — naming follows vcluster.lb.<cluster-name>.<service-name>.<namespace>, so it's one HAProxy container per LoadBalancer Service, deleted automatically when the Service is deleted.

Note its PORTS column: 80/tcp, with no 0.0.0.0:...-> host-port mapping like vcluster.cp.local-k8s has. It isn't reachable because Docker published a port to the host (like experimental.docker.ports, see PORTS.md) — it's reachable because it has its own IP directly on the Docker bridge network, which the host can route to without any port mapping at all.

5. Where 172.18.255.254 actually comes from

$ ip a s
...
7: br-cb43565b1f64: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether ba:a8:0d:1d:31:3b brd ff:ff:ff:ff:ff:ff
    inet 172.18.0.1/16 brd 172.18.255.255 scope global br-cb43565b1f64
       valid_lft forever preferred_lft forever
...

br-cb43565b1f64 is the Docker bridge network vind created for this cluster (vcluster.local-k8s, see README.md's "Docker container mapping" section) — its subnet is 172.18.0.0/16. 172.18.255.254, the Service's EXTERNAL-IP, is an address on that same /16, assigned to the HAProxy container. Since the bridge's gateway (172.18.0.1) is a real interface on the host (visible in ip a s output as br-cb43565b1f64), the Linux kernel routes host-originated traffic to any IP in that /16 straight to the matching container — no port-forwarding, no NAT rule needed, which is exactly why curl 172.18.255.254 in step 3 worked directly from the host.

How it all fits together

kubectl apply (Service type=LoadBalancer)
        │
        ▼
vind creates vcluster.lb.<cluster>.<service>.<namespace>  (haproxy:3.3-alpine)
        │  joined to the cluster's own Docker bridge (br-...)
        │  gets an IP from that bridge's subnet — becomes EXTERNAL-IP
        ▼
HAProxy forwards → Service's NodePort (allocated automatically, e.g. 31681)
        ▼
kube-proxy / CNI routes → the actual backing Pod(s)

Cleanup

kubectl delete deployment nginx-test
kubectl delete svc nginx-test
docker ps --filter name=vcluster.lb.local-k8s.nginx-test   # confirm the HAProxy container is gone

Platform caveat

This whole flow — automatic EXTERNAL-IP, directly curl-able from the host — is Linux-specific, because it relies on the Docker bridge network being a real, host-routable interface (br-cb43565b1f64 above). On macOS, Docker runs inside a Linux VM (Docker Desktop/Rancher Desktop), so the bridge IP isn't reachable from the actual macOS host — you'd need sudo vcluster create for full HAProxy support, or fall back to experimental.docker.loadBalancer.forwardPorts / NodePort. See PORTS.md for the related forwardPorts option.