Highly available Kubernetes on BGP. Part 4: kube-vip in BGP mode and the first master
All parts of the series “Highly available Kubernetes on BGP”The router is ready to wait for neighbors, the nodes are prepared, and it's time to create the cluster. In this part we'll bring up the VIP for the API server with kube-vip in BGP mode, initialize the first master and join the other two.
How kube-vip works in BGP mode
kube-vip runs as a static pod on every master: the manifest lives in /etc/kubernetes/manifests/kube-vip.yaml, and kubelet starts it by itself — before and independently of the apiserver. Neither Helm nor kubectl apply is involved.
In BGP mode:
- kube-vip puts the VIP (
192.168.20.1/32) on thelointerface — not oneth0. The address must not answer ARP, and loopback is the standard place for that. - Inside kube-vip runs a BGP speaker (based on GoBGP), which opens an eBGP session with the router.
- All three masters announce at the same time. No leader is elected, no lease is needed.
- Instead of moving the address, a health check does the work: kube-vip polls the local apiserver, and if it doesn't respond, withdraws the announcement. The router removes the master from ECMP.
The health check deserves special mention. Since everyone announces, the router spreads requests across the masters. If the apiserver dies on one node while kubelet and the kube-vip pod stay alive, the node keeps announcing the route, and a third of all requests will go into a black hole. That's why the health check is enabled in the manifest: after three failed checks in a row, the announcement is withdrawn.
DNS for the API
Create an A record control.k8s.example.com → 192.168.20.1 in DNS and, as agreed in part 2, duplicate it in /etc/hosts on every node.
The kube-vip manifest
The first master. On the other masters exactly two values differ: bgp_routerid (the node's own address) and control_plane_health_check_address (the node's name).
apiVersion: v1
kind: Pod
metadata:
name: kube-vip
namespace: kube-system
spec:
containers:
- args:
- manager
env:
- name: address
value: "192.168.20.1"
- name: port
value: "6443"
- name: vip_subnet
value: "32"
- name: vip_interface
value: lo
- name: vip_arp
value: "false"
- name: vip_nodename
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: cp_enable
value: "true"
- name: cp_namespace
value: kube-system
- name: bgp_enable
value: "true"
- name: bgp_as
value: "65020"
- name: bgp_routerid
value: "192.168.10.17"
- name: bgp_peeraddress
value: "192.168.10.1"
- name: bgp_peeras
value: "65010"
- name: control_plane_health_check_address
value: "https://k8s-master-1.example.com:6443/livez"
- name: control_plane_health_check_ca_path
value: /etc/kubernetes/pki/ca.crt
- name: control_plane_health_check_period_seconds
value: "5"
- name: control_plane_health_check_timeout_seconds
value: "3"
- name: control_plane_health_check_failure_threshold
value: "3"
- name: prometheus_server
value: :2112
image: ghcr.io/kube-vip/kube-vip:v1.2.4
imagePullPolicy: IfNotPresent
name: kube-vip
resources:
requests:
memory: 64Mi
limits:
memory: 512Mi
securityContext:
capabilities:
add:
- NET_ADMIN
- NET_RAW
drop:
- ALL
volumeMounts:
- mountPath: /etc/kubernetes/admin.conf
name: kubeconfig
- mountPath: /etc/kubernetes/pki/ca.crt
name: ca-cert
readOnly: true
hostAliases:
- hostnames:
- kubernetes
ip: 127.0.0.1
hostNetwork: true
volumes:
- hostPath:
path: /etc/kubernetes/super-admin.conf # only for the duration of kubeadm init, see below
name: kubeconfig
- hostPath:
path: /etc/kubernetes/pki/ca.crt
type: File
name: ca-cert
Parameters
| Parameter | Value | Meaning |
|---|---|---|
address |
192.168.20.1 |
The VIP. Lives in a subnet that doesn't physically exist |
vip_interface |
lo |
in BGP mode the address must not answer ARP |
vip_arp |
"false" |
ARP announcements are off |
cp_enable |
"true" |
control plane mode |
bgp_as |
"65020" |
our AS (the masters' AS) |
bgp_routerid |
the node's address | Router ID of the local speaker, each has its own |
bgp_peeraddress, bgp_peeras |
192.168.10.1, 65010 |
the router, an eBGP session |
control_plane_health_check_* |
protection against a black hole | |
prometheus_server |
:2112 |
kube-vip metrics |
What's not in the manifest. If you start from an ARP-mode example, remove vip_leaderelection, vip_leasename, vip_leaseduration, vip_renewdeadline and vip_retryperiod: that's the mechanism for electing a single address owner, and BGP doesn't need it. Same goes for dhcp_mode and dns_mode.
The health check address is the node name, not localhost. The apiserver certificate's SANs don't include localhost, and TLS verification against it will fail. By node name, anonymous /livez returns 200: kubeadm sets up the system:public-info-viewer role for this. The CA is mounted via a separate hostPath volume.
The memory limit. In kube-vip versions v1.2.1–v1.2.3, BGP mode leaked memory — about 18 MB per hour on each master, because defer resp.Body.Close() in the health check loop never fired. I found the cause and described it in kube-vip#1778; the fix shipped in v1.2.4. Use that version or newer. I kept the 512Mi limit as insurance: on the fixed version it's unreachable (growth of 0.03 MB per hour), but if the leak comes back, the container will hit the limit, kubelet will restart it within seconds, and meanwhile the other two masters will announce the VIP.
The first master's snag: super-admin.conf
kube-vip needs a kubeconfig with administrator rights. In the manifest that's /etc/kubernetes/admin.conf. But on the first master it doesn't exist yet: kubeadm init creates it at the very end. And since version 1.29, kubeadm also creates super-admin.conf, which appears already at the kubeconfig generation stage.
So for the duration of kubeadm init the manifest points to super-admin.conf (as shown above), and after initialization we'll switch back to admin.conf. This is needed only on the first master.
Put the manifest in place before running kubeadm init:
sudo mkdir -p /etc/kubernetes/manifests
sudo cp kube-vip.yaml /etc/kubernetes/manifests/kube-vip.yaml
kubelet doesn't see it yet: it's not configured or running yet, and it will pick up the manifest as soon as kubeadm starts it.
kubeadm init
The initialization configuration:
# kubeadm-init.yaml
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 192.168.10.17
nodeRegistration:
criSocket: unix:///run/containerd/containerd.sock
ignorePreflightErrors:
- DirAvailable--etc-kubernetes-manifests # the kube-vip manifest is already there
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.36.4
controlPlaneEndpoint: control.k8s.example.com:6443
networking:
serviceSubnet: 10.96.0.0/12
proxy:
disabled: true
A few remarks:
controlPlaneEndpointis a name, not an IP address. It goes into the certificates and kubeconfigs. The VIP itself isn't in the SANs, and that gives freedom: the address can be changed without reissuing certificates (though/etc/hostsand DNS will have to be fixed).proxy.disabled: true— there will be nokube-proxyin the cluster: services are handled by Calico in eBPF mode (part 5). The field also works withkubeadm upgrade: it will reportSkipping the addon/kube-proxy phaseand won't bringkube-proxyback.podSubnetis deliberately not set. Calico uses its own IPAM, and we'll set the pod pool in theInstallation(otherwise Calico creates the stock192.168.0.0/16, which will overlap with a home network).advertiseAddressis the node's real address, not the VIP.
Run it:
sudo kubeadm init --config kubeadm-init.yaml --upload-certs
--upload-certs uploads the control plane certificates into a secret so the other masters can fetch them on join. The encryption key (certificate-key) is valid for 2 hours; after that, kubeadm init phase upload-certs --upload-certs will issue a new one.
If initialization gets stuck for a long time waiting for the control plane, look at the kube-vip log: sudo crictl logs $(sudo crictl ps -a --name kube-vip -q | head -1). Some kubeadm steps access the cluster by the name control.k8s.example.com, that is, via the VIP, and the VIP doesn't appear immediately: first the apiserver has to come up and pass the health check, then the BGP session with the router has to be established. It usually takes a few seconds.
When it finishes, kubeadm prints the join commands for masters and workers — save them.
Switching back to admin.conf
mkdir -p $HOME/.kube
sudo cp /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
sudo sed -i 's#/etc/kubernetes/super-admin.conf#/etc/kubernetes/admin.conf#' /etc/kubernetes/manifests/kube-vip.yaml
Editing the manifest is the deployment: kubelet watches the directory and recreates the pod by itself. A few seconds later:
ip -brief addr show lo # 192.168.20.1/32 on lo
sudo crictl ps --name kube-vip
The kube-vip log should contain the lines BGP health check passed, announcing route (with cidr=192.168.20.1/32) and the session state BGP_FSM_ESTABLISHED. On the router:
sudo vtysh -c 'show bgp summary'
ip route show 192.168.20.1
The neighbor 192.168.10.17 should show a number in the State/PfxRcd column (1), and the kernel table should get a route 192.168.20.1 via 192.168.10.17. Check access from any machine on the network:
curl -sk https://192.168.20.1:6443/livez ; echo
The second and third masters
On the first master (or wherever the command from the kubeadm init output is):
sudo kubeadm join control.k8s.example.com:6443 \
--token <token> \
--discovery-token-ca-cert-hash sha256:<hash> \
--control-plane --certificate-key <key> \
--apiserver-advertise-address 192.168.10.18
Here --apiserver-advertise-address is the address of the node being joined.
kubeadm join --control-plane creates admin.conf on the new master at the very end of the join, so here we put the kube-vip manifest in place after join. While kube-vip isn't running on the new master, the others serve the VIP just fine: there's no leader, and nobody waits for anybody.
Take the first master's manifest in its final form (with hostPath pointing to admin.conf, after the sed edit above), copy it to the new master and change two values: the address in bgp_routerid and the node name in the health check address:
sudo sed -e 's/192.168.10.17/192.168.10.18/' -e 's/k8s-master-1/k8s-master-2/' kube-vip-master-1.yaml \
| sudo tee /etc/kubernetes/manifests/kube-vip.yaml
Repeat for the third (.19, k8s-master-3). The result on the router:
ip route show 192.168.20.1
192.168.20.1 proto bgp metric 20
nexthop via 192.168.10.17 dev eth1 weight 1
nexthop via 192.168.10.18 dev eth1 weight 1
nexthop via 192.168.10.19 dev eth1 weight 1
Three nexthops is ECMP: the router spreads API requests across the three masters.
What's next
The nodes are still NotReady, and the CoreDNS pods are Pending: the cluster has no network. The masters have the NoSchedule taint by default, and I don't remove it — user workloads live only on the workers.
kubectl get nodes
Check that etcd has formed with three members:
kubectl -n kube-system get pods -l component=etcd -o wide
What's important to know
kubectl delete poddoesn't restart a static pod. kubelet only recreates the "mirror" object, while the container keeps running. Restarting is done only on the node:sudo crictl stop $(sudo crictl ps --name kube-vip -q).- A static pod can't reference ConfigMaps or Secrets, only the downward API and
hostPath. - Upgrading kube-vip means bumping the tag in the manifest on the node, one master at a time, and waiting until the pod is
Readyandannouncing routeandPeer Upreappear in the log. The VIP doesn't disappear between masters: the remaining two announce it.
In part 5 we'll install Calico with eBPF so the cluster gets a network, and join the worker nodes.
Only registered users can leave a comment.
Sign in or register to leave a comment.