Continuing my Kubernetes the Hard Way homelab build. Steps 01-07 are already done, this covers step 08.
Original guide: 08-bootstrapping-kubernetes-controllers.md
Thoughts I had while doing this
Much like the previous step with etcd, this one is about installing more components. Mainly the Kubernetes API Server, Scheduler, and Controller Manager.
Same pattern as before: copy the binaries over to the controller node, configure them, and each one becomes a running Linux service.
What I was trying to figure out was what each of these components is for. I already get etcd, it stores the state. Here's my attempt at explaining the other three:
-
API Server - this is where every API call from the other Kubernetes components ends up. If you want to do anything in the cluster, it has to go through here, since it's the only component that reads/writes directly to etcd. That's also why it's loaded with security config (
ca.crt,ca.key, etc.). It's the gatekeeper in front of the database. - Scheduler - decides which node a pod should run on. It just checks whether a pod already has an assigned node; if not, it calls the API server to assign one. The API server is the one that actually writes that update to etcd, the scheduler just makes the decision and reports it.
-
Controller Manager - as far as I understand it, this is really just a bundle of other controllers. If the API server is the one directly reading/writing etcd, and the scheduler just decides pod placement, the controller manager is just "everything else." A couple examples of what that covers:
- "One of the physical servers running our pods went down, let me do something about it."
- "A job failed inside a pod, let me retry it."
A gotcha along the way: apiserver wouldn't start
Hit a wall trying to get kube-apiserver up:
root@server:~# systemctl is-active kube-apiserver
activating
root@server:~# systemctl status kube-apiserver
● kube-apiserver.service - Kubernetes API Server
Loaded: loaded (/etc/systemd/system/kube-apiserver.service; enabled; vendor preset: enabled)
Active: activating (auto-restart) (Result: exit-code) since Thu 2026-09-17 13:13:42 UTC; 89ms ago
Docs: https://github.com/kubernetes/kubernetes
Process: 56537 ExecStart=/usr/local/bin/kube-apiserver --allow-privileged=true ... (code=exited, status=1/FAILURE)
Main PID: 56537 (code=exited, status=1/FAILURE)
CPU: 48ms
Sep 17 13:13:42 server systemd[1]: kube-apiserver.service: Main process exited, code=exited, status=1/FAILURE
Sep 17 13:13:42 server systemd[1]: kube-apiserver.service: Failed with result 'exit-code'.
Checked the logs for the actual error:
root@server:~# journalctl -u kube-apiserver
...
Sep 17 13:08:07 server kube-apiserver[55147]: I0917 13:08:07.493634 55147 server.go:147] Version: v1.32.3
Sep 17 13:08:07 server kube-apiserver[55147]: E0917 13:08:07.493805 55147 run.go:72] "command failed" err="failed to create listener: failed to listen on 0.0.0.0:6443: listen tcp 0.0.0.0:6443: bind: address already in use"
Sep 17 13:08:07 server systemd[1]: kube-apiserver.service: Main process exited, code=exited, status=1/FAILURE
Sep 17 13:08:07 server systemd[1]: kube-apiserver.service: Failed with result 'exit-code'.
Sep 17 13:08:12 server systemd[1]: kube-apiserver.service: Scheduled restart job, restart counter is at 1.
...
Something else was already bound to port 6443:
root@server:~# sudo ss -tlnp | grep 6443
LISTEN 0 4096 *:6443 *:* users:(("k3s-server",pid=623,fd=13))
Turned out I'd tried setting up k3s on this same machine a few months back and forgot about it. Stopped and disabled the leftover service:
root@server:~# sudo systemctl stop k3s
sudo systemctl disable k3s
Removed /etc/systemd/system/multi-user.target.wants/k3s.service.
After that, kube-apiserver started cleanly:
root@server:~# systemctl is-active kube-apiserver
active
root@server:~# kubectl cluster-info \
--kubeconfig admin.kubeconfig
Kubernetes control plane is running at https://127.0.0.1:6443
Now here's the actual step.
Prerequisites
root@luger-VirtualBox:~/kubernetes-the-hard-way# scp \
downloads/controller/kube-apiserver \
downloads/controller/kube-controller-manager \
downloads/controller/kube-scheduler \
downloads/client/kubectl \
units/kube-apiserver.service \
units/kube-controller-manager.service \
units/kube-scheduler.service \
configs/kube-scheduler.yaml \
configs/kube-apiserver-to-kubelet.yaml \
root@server:~/
kube-apiserver 100% 89MB 90.4MB/s 00:00
kube-controller-manager 100% 82MB 93.6MB/s 00:00
kube-scheduler 100% 63MB 157.4MB/s 00:00
kubectl 100% 55MB 153.5MB/s 00:00
kube-apiserver.service 100% 1374 1.6MB/s 00:00
kube-controller-manager.service 100% 735 936.9KB/s 00:00
kube-scheduler.service 100% 281 349.7KB/s 00:00
kube-scheduler.yaml 100% 191 231.0KB/s 00:00
kube-apiserver-to-kubelet.yaml 100% 727 952.4KB/s 00:00
Provision the Kubernetes control plane
root@server:~# mkdir -p /etc/kubernetes/config
Install the Kubernetes controller binaries
root@server:~# {
mv kube-apiserver \
kube-controller-manager \
kube-scheduler kubectl \
/usr/local/bin/
}
Configure the Kubernetes API Server
root@server:~# {
mkdir -p /var/lib/kubernetes/
mv ca.crt ca.key \
kube-api-server.key kube-api-server.crt \
service-accounts.key service-accounts.crt \
encryption-config.yaml \
/var/lib/kubernetes/
}
root@server:~# mv kube-apiserver.service \
/etc/systemd/system/kube-apiserver.service
Configure the Kubernetes Controller Manager
root@server:~# mv kube-controller-manager.kubeconfig /var/lib/kubernetes/
root@server:~# mv kube-controller-manager.service /etc/systemd/system/
Configure the Kubernetes Scheduler
root@server:~# mv kube-scheduler.kubeconfig /var/lib/kubernetes/
root@server:~# mv kube-scheduler.yaml /etc/kubernetes/config/
root@server:~# mv kube-scheduler.service /etc/systemd/system/
Start the controller services
root@server:~# {
systemctl daemon-reload
systemctl enable kube-apiserver \
kube-controller-manager kube-scheduler
systemctl start kube-apiserver \
kube-controller-manager kube-scheduler
}
Created symlink /etc/systemd/system/multi-user.target.wants/kube-apiserver.service → /etc/systemd/system/kube-apiserver.service.
Created symlink /etc/systemd/system/multi-user.target.wants/kube-controller-manager.service → /etc/systemd/system/kube-controller-manager.service.
Created symlink /etc/systemd/system/multi-user.target.wants/kube-scheduler.service → /etc/systemd/system/kube-scheduler.service.
API server verification
root@server:~# kubectl cluster-info \
--kubeconfig admin.kubeconfig
Kubernetes control plane is running at https://127.0.0.1:6443
To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.
RBAC for kubelet authorization
This part just makes sure the API server is allowed to talk to the kubelet APIs on the worker nodes. As I understand it, the command here applies whatever's defined in kube-apiserver-to-kubelet.yaml to the cluster:
root@server:~# kubectl apply -f kube-apiserver-to-kubelet.yaml \
--kubeconfig admin.kubeconfig
clusterrole.rbac.authorization.k8s.io/system:kube-apiserver-to-kubelet created
clusterrolebinding.rbac.authorization.k8s.io/system:kube-apiserver created
Verification
root@luger-VirtualBox:~/kubernetes-the-hard-way# curl --cacert ca.crt \
https://server.kubernetes.local:6443/version
{
"major": "1",
"minor": "32",
"gitVersion": "v1.32.3",
"gitCommit": "32cc146f75aad04beaaa245a7157eb35063a9f99",
"gitTreeState": "clean",
"buildDate": "2025-03-11T19:52:21Z",
"goVersion": "go1.23.6",
"compiler": "gc",
"platform": "linux/amd64"
}
TLS is working end-to-end. The jumpbox hit the API server over HTTPS using only the CA cert.
Summary
Setup the three control plane components: API server, controller manager, and scheduler as systemd services on the controller node, and placed RBAC so the API server can talk to kubelets on the workers.
Top comments (0)