Kubernetes Jobs, CronJobs and queue workers
A worker that takes jobs from a queue runs all the time, so in Kubernetes it's a normal Deployment. Work that runs…
Installing Kubernetes with kubeadm is basically a handful of commands on each server. Then the control plane runs on your own server, that's the part of Kubernetes that decides what runs where. A cloud provider can also run it for you. But if you set it up yourself once, you know what stops working when one of its parts stops. That helps later when something breaks, also on a cluster that you rent.
I did the install by hand on three Ubuntu servers on AWS that had nothing on them. Then I stopped the scheduler and later the controller manager, and I checked what still worked without them. At the end a ticket shop ran on the cluster. I made the shop up, it's called Seatlark and it sells concert tickets online.
Each server needs a container runtime and the Kubernetes tools. Linux also has to forward the traffic between pods, and that's switched off by default. After that, kubeadm init took less than half a minute, and a worker joined the cluster in about two seconds. The servers stayed NotReady until I installed a network plugin, because Kubernetes doesn't connect the pods by itself.
The scheduler picks a server for each new pod. While it was stopped, the API server still took new pods, but they waited without a server until I started it again. The pods that were already running went on working.
Keeping the cluster running is more work than the install. Backups and upgrades are up to you, and also what happens when the control plane server dies. If you don't want that work, it's easier to rent the control plane.
kubeadm checks the server before it installs anything, so the servers have to be big enough. I used three servers on AWS with Ubuntu 24.04. One is the control plane. The other two are workers, the servers that run your containers. The Kubernetes docs ask for at least 2 GB of memory per server and at least 2 CPUs on control plane servers, and my t3.medium has 2 CPUs and 4 GB. The workers were t3.large with 8 GB, because they have to run the shop.
The servers need different hostnames, and they have to reach each other. These are the default ports involved. Some are only used locally, and NodePorts only matter when you expose a Service that way:
| Port | Used by | Who needs to reach it |
|---|---|---|
| 6443 | API server | Workers, and wherever you run kubectl |
| 2379 | etcd | The API server, locally here |
| 2380 | etcd | Other etcd servers, if you have more than one |
| 10250 | kubelet | The control plane, on each server |
| 10256 | kube-proxy health checks | Whatever checks its health |
| 10257 | Controller manager | Local connections only here |
| 10259 | Scheduler | Local connections only here |
| 30000 to 32767 | NodePort Services | Your users, on the ports you use |
The network plugin needs its own ports on top of that. I used Cilium, and it uses UDP port 8472 between the servers. To keep it simple, I allowed all traffic between the servers in one security group, which is AWS's firewall around a server. I also allowed SSH on port 22 and the shop on port 30080 from my own IP address.
These steps also work on Ubuntu 24.04 servers outside of AWS, as long as their kernel supports Cilium. Check the address ranges first, because Cilium uses 10.0.0.0/8 for pods by default. If that clashes with your network, choose another range.
Each server needs three things before kubeadm can do anything. That's the same on the control plane and on the workers.
A container runtime. That's the program that runs the containers, and here it's containerd:
sudo apt-get install -y containerd
sudo mkdir -p /etc/containerd
containerd config default | sed 's/SystemdCgroup = false/SystemdCgroup = true/' | sudo tee /etc/containerd/config.toml > /dev/null
sudo systemctl restart containerd
SystemdCgroup = true means containerd uses systemd to manage the CPU and memory limits of containers, like the rest of Ubuntu does. The kubelet is the part of Kubernetes that runs on each server, and it follows containerd's setting here. You don't want two programs managing those limits on one server, because that can cause problems when the server is under pressure.
IP forwarding. Traffic between pods has to pass through the server, and Linux has that switched off by default. On my servers the setting was 0 before I changed it, and 0 means off:
echo net.ipv4.ip_forward=1 | sudo tee /etc/sysctl.d/k8s.conf
sudo sysctl --system > /dev/null
The Kubernetes tools. They aren't in Ubuntu's own packages, so you add the Kubernetes package source first. The 1.37 in there is the version:
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.37/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes.gpg
echo "deb [signed-by=/etc/apt/keyrings/kubernetes.gpg] https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /" | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl
The kubelet starts the pods on its server. kubeadm sets up the cluster, and kubectl is the tool you use to talk to it. The last line puts the three on hold, so a normal system update can't change the Kubernetes version under a running cluster.
Swap is another thing to check. By default the kubelet doesn't start on a server with swap switched on. These AWS servers have no swap, so there was nothing to do. On your own servers you'd switch it off, or set the kubelet up to allow it.
Now the servers are ready, and this one command turns one of them into the control plane:
sudo kubeadm init
On my server it took less than half a minute, and it prints each step while it runs. These are the main ones:
| Step in the output | What it does |
|---|---|
preflight |
Checks the server, and downloads the images of the control plane |
certs |
Creates the certificates, so the parts of the cluster can trust each other |
kubeconfig |
Writes the config files that the parts use to reach the API server |
etcd, control-plane |
Writes one YAML file for each part of the control plane |
kubelet-start |
Starts the kubelet |
mark-control-plane |
Marks this server so that normal pods don't run on it |
bootstrap-token |
Creates the token that other servers use to join |
addons |
Installs CoreDNS and kube-proxy |
The YAML files from the etcd and control-plane steps are in /etc/kubernetes/manifests:
$ sudo ls /etc/kubernetes/manifests
etcd.yaml kube-apiserver.yaml kube-controller-manager.yaml kube-scheduler.yaml
Those four files are the control plane. etcd is a small database with the state of the cluster, and the API server is what kubectl and all the other parts talk to. The scheduler picks a server for each new pod, and the controller manager checks all the time that what's running is what you asked for.
The kubelet watches this folder and runs whatever is in there as a pod. That's called a static pod. A normal pod needs an API server and a scheduler before it can run, and these pods are the API server and the scheduler. So the kubelet starts them from the files, and no API server is needed for that.
To talk to the cluster, kubectl needs a config file. kubeadm wrote one, and you copy it to your home folder:
mkdir -p ~/.kube
sudo cp /etc/kubernetes/admin.conf ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config
That file holds the address of the API server and a key with admin access to the cluster, so treat it like a password. You can also use this file on your laptop, as long as your laptop can reach the API server address in it. Mine is a private EC2 address, so I'd first need network access to it, for example through a VPN.
The control plane is up, and now the workers join it. A worker needs the same three things as before, and then you run the line that kubeadm init printed at the end:
sudo kubeadm join 172.31.12.62:6443 --token ku6jgd.xxxxxxxxxxxxxxxx --discovery-token-ca-cert-hash sha256:...
The token proves that this server is allowed to join, and the hash lets the worker check that it's talking to the right control plane. The token is valid for 24 hours by default. If you need a new one, this command prints a new join line:
sudo kubeadm token create --print-join-command
On my servers the join took about two seconds. Then I checked the cluster:
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
control-1 NotReady control-plane 2m6s v1.37.2
worker-1 NotReady <none> 0s v1.37.2
Both servers are NotReady. The reason is in kubectl describe node worker-1, which says cni plugin not initialized.
Kubernetes gives each pod its own IP address, and pods have to reach each other, also across servers. But Kubernetes doesn't do that itself. A network plugin does, and these plugins follow a standard called CNI. Until one is there, a server stays NotReady, and CoreDNS has nowhere to run. CoreDNS is the DNS server of the cluster.
There are a few of these plugins. Calico and Flannel are also common. I used Cilium:
cilium install
That needs the Cilium command line tool, which you get from its GitHub releases. Cilium's pods started on each server, and in less than a minute both servers were Ready.
To test it, I called a pod on worker-2 from a pod on worker-1, using the pod's own address:
$ kubectl exec storefront-5b4f79f6c9-2j7tg -- wget -qO- 10.0.2.218:3000/healthz
ok
The ok came back from the other server, so pods can reach each other across servers now.
You don't want to type all of this again on each server, and sooner or later you'd make a mistake. Ansible is a common tool for this when you run your own servers. You write down what should be on a server, and Ansible connects over SSH and does it. You don't have to install an agent on the server.
This is the file I used for worker-2. It's the same three things as above:
- hosts: workers
become: true
tasks:
- name: Install containerd
ansible.builtin.apt:
name: containerd
update_cache: true
- name: Create the folder for its config
ansible.builtin.file:
path: /etc/containerd
state: directory
- name: Use the same containerd config as control-1
ansible.builtin.copy:
src: /etc/containerd/config.toml
dest: /etc/containerd/config.toml
notify: restart containerd
- name: Allow IP forwarding
ansible.builtin.copy:
content: "net.ipv4.ip_forward=1\n"
dest: /etc/sysctl.d/k8s.conf
notify: reload sysctl
- name: Add the key for the Kubernetes packages
ansible.builtin.get_url:
url: https://pkgs.k8s.io/core:/stable:/v1.37/deb/Release.key
dest: /etc/apt/keyrings/kubernetes.asc
- name: Add the Kubernetes packages
ansible.builtin.apt_repository:
repo: deb [signed-by=/etc/apt/keyrings/kubernetes.asc] https://pkgs.k8s.io/core:/stable:/v1.37/deb/ /
filename: kubernetes
- name: Install the Kubernetes tools
ansible.builtin.apt:
name: [kubelet, kubeadm, kubectl]
- name: Stop apt from upgrading them
ansible.builtin.dpkg_selections:
name: "{{ item }}"
selection: hold
loop: [kubelet, kubeadm, kubectl]
handlers:
- name: restart containerd
ansible.builtin.service:
name: containerd
state: restarted
- name: reload sysctl
ansible.builtin.command: sysctl --system
And this is the list of servers, hosts.ini:
[workers]
worker-2
I ran it from control-1, after installing ansible-core there. The playbook copies that server's containerd config to the worker, and the name worker-2 has to work over SSH already:
ansible-playbook -i hosts.ini prepare.yml
The first run took about 40 seconds, and most tasks said changed. When I ran it a second time, it said changed=0, because each task checks if its result is already there. So it's safe to run again. It's a bit like Kubernetes, with one difference. Ansible runs its tasks in order and then it's done, and Kubernetes keeps checking.
Here Ansible only prepares the server, and I still joined it to the cluster by hand. Kubespray is a project that uses Ansible to set up a whole cluster.
Turning a part off shows what it does. Because these are static pods, you only have to move a file out of the folder:
sudo mv /etc/kubernetes/manifests/kube-scheduler.yaml /tmp/
The kubelet noticed within seconds and stopped the scheduler. Then I started three copies of the shop's web app:
$ kubectl get pods -l app=storefront
NAME READY STATUS RESTARTS AGE
storefront-5b4f79f6c9-8j476 0/1 Pending 0 8s
storefront-5b4f79f6c9-jgcxk 0/1 Pending 0 8s
storefront-5b4f79f6c9-ppscg 0/1 Pending 0 8s
The API server stored the pods, and that's why they show up. But they stay Pending, because nothing picks a server for them. The pods also have no events, so kubectl describe doesn't tell you what's wrong. When I moved the file back, all three were running three seconds later.
I tried the same with the controller manager. With it stopped, I deleted one of the three pods, and no new one came. Eight seconds later there were still two. When I moved its file back, the third pod was there a second later.
| What I stopped | What still worked | What stopped working |
|---|---|---|
| Scheduler | kubectl, and pods that were already running | New pods got no server |
| Controller manager | kubectl, and pods that were already running | A deleted pod didn't get replaced |
I didn't test stopping etcd or the API server. Without the API server, kubectl can't reach the cluster at all. etcd holds the Kubernetes objects, so back it up, and also keep the certificates and keys in /etc/kubernetes/pki. The shop's database files need a separate backup.
With the web app already running from the scheduler test, I applied one file to add the rest of the shop:
kubectl apply -f shop.yaml
Before this I created the folder /mnt/orders-data on worker-1 for the database, and I loaded the shop's images onto the workers by hand. Normally the kubelet pulls images from a registry. Then I bought a ticket through port 30080 on worker-1, and that gave me order 1042.
kubeadm gets you a working cluster, and it stops there. Four things are still up to you.
A control plane that survives a dead server. With one control plane server, existing pods can keep running if it goes down. But until it's back, kubectl doesn't work, and pods don't get replaced on another server. kubeadm supports several control plane servers, but you have to plan and provide them. With etcd on those same servers you normally use three, with a load balancer in front of the API servers. If you want to expand that way later, set --control-plane-endpoint the first time you run kubeadm init. I didn't do that here.
Upgrades. kubeadm upgrade helps, but you do them one server at a time.
Traffic from outside. A Service of type LoadBalancer stays pending, because nothing here creates a load balancer. That's why I used a NodePort, which opens a port on each worker.
Disks. Nothing here creates persistent volumes when pods ask for them. My shop uses a local volume I prepared on worker-1, so its data stays on that server.
A rented cluster from a cloud provider takes care of most of this, and you still pay for the workers. I'd only run my own control plane if there's a reason for it, like having to use your own hardware.
I ran this on rented virtual servers, so I didn't test any of this on physical hardware. The commands are the same there, but a few things differ:
LoadBalancer Services an address on your own network. It doesn't work on AWS, so I didn't test it.Is this the same as Kubernetes the Hard Way? No. Kubernetes the Hard Way does by hand what kubeadm does for you, like creating each certificate. It's a good way to learn, but kubeadm is what you'd use on servers you want to keep.
Why not k3s or another small distribution? They're easier to install. I used kubeadm because it's the standard tool from the Kubernetes project, and because you see all the parts separately.
Do I need Docker on the servers? No. They only need containerd, and they run the same images you build with Docker.
Can pods run on the control plane? Not by default. kubeadm marks the server so that normal pods stay away, and on a small test cluster you can remove that mark.
I used four servers on AWS with Ubuntu 24.04. The control plane ran on a t3.medium, and the two workers were t3.large servers. A fourth server only built the shop's images, and it's not part of the cluster. I tested this on November 6, 2026.
These are the versions:
Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.
Take a look at vroni.com