Skip to content
Writing

Kubernetes on Your Desk: Building a Real Cluster with Two Machines

April 9, 202611 min read
Technical
Most Kubernetes tutorials hand you a single-node playground (Minikube, Kind, Docker Desktop) and call it a day. You type kubectl get pods, see a green checkmark, and learn almost nothing about what Kubernetes actually does. This guide is different. You'll build a real, multi-node, cross-architecture cluster using two physical machines you already own. By the end, you'll understand not just how to run Kubernetes, but why it works the way it does.
MachineRoleArchitectureOS
A desktop or laptop running LinuxControl plane + workeramd64 (Intel/AMD)Ubuntu 22.04+ recommended
A second machine (Mac, another Linux box, Raspberry Pi)Worker nodeAny (amd64 or arm64)macOS, Ubuntu, Raspberry Pi OS
Both machines must be on the same local network (same Wi-Fi or connected to the same router). Our setup: An Ubuntu desktop (Ryzen 5 5600X, 16GB RAM) as the control plane, and a MacBook Pro (Apple M4 Pro, 24GB RAM) as a worker node. The fact that these are different CPU architectures (amd64 vs arm64) makes things more interesting and more realistic. Real-world clusters often mix architectures. Time required: 2-3 hours for a working cluster. Another hour to deploy workloads and understand what's happening.
Kubernetes (K8s) is a system that manages containers across multiple machines. That's it. Everything else is detail. But let's unpack what that actually means, because "manages containers across multiple machines" is doing a lot of heavy lifting.
A container is a way to package an application with everything it needs to run (code, libraries, configuration) into a single unit that runs the same way everywhere. If you've used Docker, you've used containers. The problem: one machine running containers is fine. Ten machines running containers is chaos. Which machine has capacity? What happens when one crashes? How do containers on different machines talk to each other?
Kubernetes is the answer to "I have containers and more than one machine." It handles:
  • Scheduling: You say "run 4 copies of my web app." Kubernetes decides which machines to put them on, based on available CPU, memory, and your rules.
  • Self-healing: A container crashes? Kubernetes restarts it. A whole machine dies? Kubernetes moves the containers to surviving machines.
  • Networking: Every container gets an IP address. Containers on different machines can talk to each other as if they're on the same network.
  • Scaling: Traffic spike? Tell Kubernetes to run more copies. Traffic drops? Scale back down.
  • Rolling updates: Deploy a new version of your app without downtime. Kubernetes replaces containers one by one.
On a single machine, you never see Kubernetes do the interesting work. Scheduling decisions are trivial when there's only one option. Networking between nodes doesn't exist. Failover is impossible. With two machines, you'll see:
  • Pods (containers) get scheduled across different physical hardware
  • Network traffic flows between nodes transparently
  • You can drain a node (take it offline) and watch workloads migrate
This is what Kubernetes was built for. One node is a toy. Two nodes is a cluster.
Every Kubernetes cluster has two types of machines: Control plane (the brain):
  • Runs the API server, the single front door for all commands
  • Runs the scheduler, which decides where containers go
  • Runs the controller manager, which watches for things that need fixing
  • Stores all cluster state in etcd (a distributed database)
Worker nodes (the muscle):
  • Run your actual containers
  • Report their health back to the control plane
  • Run a component called kubelet that takes orders from the API server
In our setup, the Ubuntu PC will be the control plane (and also run containers; it does double duty). The Mac will be a pure worker node.
Before any Kubernetes setup, your two machines need to talk to each other. We'll use SSH (Secure Shell) to control the Linux machine from the Mac.
On your Ubuntu machine, open a terminal and run:
sudo apt install openssh-server -y
This installs the OpenSSH server, which listens for incoming SSH connections on port 22. The -y flag auto-confirms the installation. Verify it's running:
sudo systemctl status ssh
You should see active (running) in green. If not:
sudo systemctl start ssh
sudo systemctl enable ssh    # start automatically on boot
Still on the Ubuntu machine:
hostname -I
This prints all IP addresses assigned to the machine. The first one is usually your local network IP, something like 192.168.x.x or 10.0.x.x. Write this IP down. You'll use it constantly.
On your Mac (or second Linux machine), open a terminal and run:
ssh your-username@<LINUX_IP>
First time connecting? You'll see a fingerprint warning. Type yes. This is normal; your Mac is verifying it hasn't talked to this machine before.
Standard Kubernetes (kubeadm) requires multiple system services configured separately, etcd set up manually, minimum 2GB RAM just for the control plane, plus certificate management and networking plugins. k3s is a lightweight, CNCF-certified Kubernetes distribution by Rancher (now SUSE). It bundles everything into a single binary under 100MB. Same Kubernetes API, same kubectl, same YAML manifests; just less operational overhead.
In your SSH session to the Ubuntu machine, run:
curl -sfL https://get.k3s.io | sh -
The script detects your OS and architecture (linux/amd64 in our case), downloads the k3s binary, sets up k3s as a systemd service, and configures it as a server (control plane + worker node). It also installs a bundled containerd, flannel, CoreDNS, and Traefik.
sudo k3s kubectl get nodes
Expected output:
NAME                    STATUS   ROLES           AGE   VERSION
master-fluffy-ms-7c94   Ready    control-plane   60s   v1.34.5+k3s1
If you see Ready, your control plane is working.
For another machine to join this cluster, it needs a secret token:
sudo cat /var/lib/rancher/k3s/server/node-token
Save this token. You'll need it in Part 3 when joining the Mac as a worker node.
This is where it gets interesting. We're adding a second machine, with a different CPU architecture, to the cluster.
k3s doesn't run natively on macOS. It's a Linux application. So we need a Linux virtual machine on the Mac. We'll use Multipass, which creates lightweight Ubuntu VMs.
brew install --cask multipass
This is the step most tutorials get wrong. By default, Multipass creates VMs on an internal NAT network. Your Mac can reach the VM, but your Ubuntu PC cannot. Kubernetes needs bidirectional connectivity between all nodes. The fix: bridge the VM onto your home network so it gets an IP on the same subnet as the Ubuntu PC.
multipass networks    # find your network interface (usually en0 for Wi-Fi)
multipass launch --name k3s-worker --cpus 4 --memory 4G --disk 20G --network en0
Verify it got a bridged IP:
multipass info k3s-worker
You should see two IPv4 addresses. One on 192.168.2.x (NAT, ignore this) and one on 192.168.101.x (bridged, this is the one we need).
Here's the second gotcha: the VM has two network interfaces, and both k3s and its networking layer (flannel) will default to the NAT interface, the one the Ubuntu PC can't reach. You need to explicitly tell k3s to use the bridged interface.
multipass exec k3s-worker -- bash -c 'curl -sfL https://get.k3s.io | K3S_URL=https://<PC_IP>:6443 K3S_TOKEN=<YOUR_TOKEN> INSTALL_K3S_EXEC="--node-ip=<VM_BRIDGED_IP> --flannel-iface=enp0s2" sh -'
The two critical flags:
  • --node-ip=<VM_BRIDGED_IP> tells kubelet to advertise the bridged IP to the control plane
  • --flannel-iface=enp0s2 tells flannel to send VXLAN tunnel traffic over the bridged interface
What happens if you skip these flags: the node will appear Ready in kubectl get nodes, and you'll think everything works. But pods on the worker node won't be able to reach pods on the control plane (or vice versa), because flannel's VXLAN tunnels are going through the NAT network. DNS resolution will fail, Services won't route cross-node, and you'll spend an hour debugging. Ask us how we know.
sudo k3s kubectl get nodes -o wide
To confirm the architectures:
sudo k3s kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.nodeInfo.architecture}{"\n"}{end}'
k3s-worker              arm64
master-fluffy-ms-7c94   amd64
Two nodes. Two architectures. One cluster.
Pod: The smallest deployable unit. Usually one container. Pods are ephemeral; they're created, they run, they die. Deployment: A declaration of "I want N copies of this pod running at all times." If a pod dies, the deployment controller creates a new one. Service: A stable network endpoint for a set of pods. Pods come and go (different IPs each time), but a Service provides a fixed address.
sudo k3s kubectl create deployment nginx --image=nginx --replicas=4
sudo k3s kubectl get pods -o wide
The scheduler distributed pods across both nodes. It balanced the load automatically. The nginx image ran on both amd64 and arm64 without any special configuration because nginx is a multi-arch image.
sudo k3s kubectl expose deployment nginx --port=80 --type=NodePort
sudo k3s kubectl get service nginx
Open http://192.168.101.26:<NODE_PORT> in your browser. That request went through the Service, which routed it to one of the four pods running on either machine.
sudo k3s kubectl run test-curl --image=curlimages/curl --rm -it --restart=Never -- curl http://nginx
The curl pod might be on the Ubuntu PC, hitting an nginx pod on the Mac, or vice versa. Kubernetes networking made them find each other by name, across physical machines, transparently.
So far, the scheduler has been placing pods wherever it wants. But what if you need a pod to run on a specific architecture?
Every node has labels. k3s automatically sets kubernetes.io/arch to amd64 or arm64.
apiVersion: v1
kind: Pod
metadata:
  name: amd64-only
spec:
  nodeSelector:
    kubernetes.io/arch: amd64
  containers:
  - name: busybox
    image: busybox
    command: ["sleep", "3600"]
sudo k3s kubectl apply -f amd64-only.yaml
sudo k3s kubectl get pod amd64-only -o wide
It will only be on the Ubuntu PC. The scheduler respected the nodeSelector constraint.
nodeSelector is a hard requirement. Node affinity lets you express preferences:
apiVersion: v1
kind: Pod
metadata:
  name: prefer-arm
spec:
  affinity:
    nodeAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        preference:
          matchExpressions:
          - key: kubernetes.io/arch
            operator: In
            values:
            - arm64
  containers:
  - name: busybox
    image: busybox
    command: ["sleep", "3600"]
This says "I'd prefer arm64, but amd64 is fine if arm64 is full."
sudo k3s kubectl scale deployment nginx --replicas=8    # scale up
sudo k3s kubectl scale deployment nginx --replicas=2    # scale down
sudo k3s kubectl get pods -w                             # watch in real time
sudo k3s kubectl drain k3s-worker --ignore-daemonsets --delete-emptydir-data
sudo k3s kubectl get pods -o wide -w    # watch pods migrate
sudo k3s kubectl uncordon k3s-worker    # bring it back
On the Ubuntu PC: /usr/local/bin/k3s-uninstall.sh On the Mac VM: /usr/local/bin/k3s-agent-uninstall.sh To delete the VM: multipass delete k3s-worker && multipass purge
alias k='ssh master-fluffy@192.168.101.26 "sudo k3s kubectl"'
Now k get nodes -o wide works from your Mac.
sudo k3s kubectl config view --raw    # on Ubuntu, copy the output
ssh -f -N -L 6443:127.0.0.1:6443 master-fluffy@192.168.101.26
KUBECONFIG=~/.kube/k3s-config.yaml kubectl get nodes -o wide
Why not just change the server IP? k3s uses separate TLS certificate authorities for server and client authentication. The SSH tunnel avoids this by presenting the connection as local, where k3s accepts both CAs.
TermWhat It Is
ClusterA set of machines running Kubernetes
NodeA single machine in the cluster
Control planeThe node(s) running the brain
PodOne or more containers running together
Deployment"Keep N copies of this pod running"
ServiceA stable address for a group of pods
kubectlCLI tool to talk to Kubernetes
k3sLightweight Kubernetes distribution
NodeSelector"Run this pod only on matching nodes"
DrainGracefully empty a node for maintenance
  1. Deploy a real application with multiple services talking to each other
  2. Set up monitoring with Prometheus + Grafana
  3. Try Helm, the package manager for Kubernetes
  4. Experiment with persistent storage
  5. Break things on purpose: kill pods, drain nodes, disconnect network. See how Kubernetes responds.
Built with a Ryzen 5 5600X desktop running Ubuntu 24.04 and a MacBook Pro M4 Pro, connected over a home network. Total cost beyond hardware you already own: $0.