Lesson 02 of 66 · 13 min read · Updated
Setting Up a Local Multi-Node Cluster with kind and Docker Desktop
On this page
In the previous article we looked inside a four-node cluster: one control-plane node and three workers. Many Kubernetes features only make sense with more than one node. Pods spread across workers, DaemonSets put one copy on every node, affinity rules and topology spread constraints choose between nodes. A single-node minikube or Docker Desktop cluster hides all of that.
This article shows two ways to build a multi-node cluster on your own machine. Both are based on kind (Kubernetes IN Docker), which runs each Kubernetes node as a Docker container. The first way is Docker Desktop's built-in Kubernetes with the kind provisioner. The second is the kind CLI with a config file, which works anywhere Docker runs.
We will cover the following topics:
- How kind turns Docker containers into Kubernetes nodes
- Creating a multi-node cluster in Docker Desktop
- Creating the same cluster with the kind CLI and a config file
- Port mappings, node labels and pinned Kubernetes versions
- Getting locally built .NET images into the cluster, and the stale-image trap
- Reaching LoadBalancer Services from your machine
- Switching contexts, resetting and deleting clusters
Technical Requirements
- Docker Desktop on macOS or Windows, or Docker Engine on Linux.
- About 4 CPUs and 8 GB of memory available to Docker for a four-node cluster. Each node runs its own kubelet and containerd.
kubectl. Docker Desktop installs it for you.- The .NET 10 SDK to build the sample image.
- For the CLI part: the kind binary. The examples use kind v0.33.0.
Core Concepts
What kind Actually Does
kind was built to test Kubernetes itself, and that is why it is so faithful. Each node is a Docker container started from a kindest/node image. Inside that container run systemd, containerd and the kubelet, exactly like on a real machine. The control-plane node additionally runs the API server, etcd, the scheduler and the controller manager as static Pods, created by kubeadm.
All node containers join a Docker network called kind, so they reach each other like machines on the same LAN. Your Pods then run as containers inside those node containers.
| Layer | What it is on kind |
|---|---|
| Machine | A Docker container (kindest/node:v1.36.x) |
| Container runtime | containerd inside that container |
| CNI | kindnet |
| Storage | local-path-provisioner, StorageClass standard |
| LoadBalancer | Not built in. Docker Desktop adds one, plain kind needs cloud-provider-kind |
Docker Desktop: kubeadm or kind
Docker Desktop offers two ways to provision its built-in Kubernetes:
| Provisioner | Nodes | Use it when |
|---|---|---|
| kubeadm | One node only | You want the simplest possible local cluster |
| kind | One or more nodes, with a choice of Kubernetes version | You need several nodes or a specific version |
The kind provisioner requires the containerd image store in Docker Desktop. It does not work with the classic Docker image store. If the option is greyed out, enable the containerd image store first under Settings → General.
Two Clusters, Same Shape
| Docker Desktop (kind) | kind CLI | |
|---|---|---|
| Created from | Docker Desktop dashboard | kind create cluster --config |
| Context name | docker-desktop | kind-<cluster-name> |
| Node names | desktop-control-plane, desktop-worker, desktop-worker2, ... | <name>-control-plane, <name>-worker, ... |
| Local images | Served to the nodes automatically | kind load docker-image |
| LoadBalancer Services | Work out of the box, reachable on localhost | Need cloud-provider-kind or a NodePort with a port mapping |
| Config file | No, settings in the UI | Yes, versioned in Git |
Docker Desktop is the most convenient choice for daily work. The CLI is the better choice when the cluster definition must be reproducible, for example in CI or for a team.
How Local Images Reach the Nodes
This is the part that confuses most .NET developers. You run docker build -t orders-api:1.0 . on your machine, but the nodes have their own containerd inside their containers. They do not automatically see your Docker images.
- Docker Desktop solves this for you. Every node is configured with a registry mirror,
registry-mirror:1273, which serves the images from Docker Desktop's image store. When a node needsorders-api:1.0, it pulls it from the mirror in milliseconds. - Plain kind has no such mirror. You copy the image into every node with
kind load docker-image orders-api:1.0 --name <cluster>.
In both cases the node keeps a copy of the image. That leads to the trap shown in the step-by-step example: rebuild an image under the same tag, and with imagePullPolicy: IfNotPresent the nodes keep running the old copy.
Pinning the Kubernetes Version
Every kind release has a default node image. kind v0.33.0 defaults to Kubernetes v1.37.0. To get another version, pass a node image with its digest, taken from the release notes:
Always use the digest. The tag alone may point to an image built for a different kind version.
Hands-On: Kubernetes Commands
List contexts and switch between clusters
Each cluster is a context in your kubeconfig.
Show the images Docker Desktop uses for Kubernetes
The Docker Desktop CLI plugin lists the images of both provisioners.
List kind clusters and their nodes
Works for clusters created with the kind CLI.
Load a local image into a kind cluster
Copies the image into the containerd of every node.
List images inside a node
crictl runs inside every kind node.
Write the kubeconfig somewhere else
--kubeconfig keeps a throwaway cluster out of your main ~/.kube/config.
Delete a kind cluster
Removes the node containers and the context.
Step-by-Step Example
The Scenario
We create a four-node cluster in Docker Desktop, deploy a .NET 10 API built on our machine, reach it through a LoadBalancer Service, and walk into the stale-image trap on purpose. Then we build an equivalent cluster with the kind CLI from a config file.
Step 1 — Create the Cluster in Docker Desktop
- Open the Docker Desktop Dashboard and select the Kubernetes view.
- Select Create cluster.
- Choose kind as the provisioner.
- Pick the Kubernetes version and set the number of nodes to 4. Docker Desktop creates one control-plane node and three workers.
- Leave Show system containers (advanced) off unless you want to see the node containers in
docker ps. - Select Create and wait until the status shows the cluster as running.
Docker Desktop writes a context called docker-desktop into your kubeconfig and selects it.
Step 2 — Verify the Nodes
The INTERNAL-IP addresses come from the kind Docker network. The control-plane node carries a NoSchedule taint, so your workloads land on the three workers.
The node containers are hidden from docker ps by default, but they exist and you can open a shell in them:
Step 3 — Build a .NET Image Locally
Create a minimal API:
Replace Program.cs:
Add a Dockerfile next to the project folder:
The image only exists in Docker Desktop's image store. Nothing has been pushed to a registry.
Step 4 — Deploy It with a LoadBalancer Service
Save as hello-local.yaml:
Each worker got one replica, and the events show where the image came from:
157 milliseconds for a 92 MB image: the node pulled it from Docker Desktop's local registry mirror, not from the internet. You can see the mirror in the node's containerd configuration:
Step 5 — Call the LoadBalancer from Your Machine
The Service got an EXTERNAL-IP from Docker Desktop's built-in cloud provider. That address lives on the kind Docker network, but Docker Desktop also publishes the Service port on localhost:
Pick Service ports that are free on your machine. Two LoadBalancer Services cannot both use port 80 on localhost.
Step 6 — Walk into the Stale-Image Trap
Change the version string in Program.cs to "1.0 (rebuilt)", rebuild under the same tag, and restart the Deployment:
New Pods, old code. The events explain why:
With IfNotPresent the kubelet found hello-local:1.0 in the node's own cache and never asked the mirror again.
Step 7 — Get Out of the Trap
There are two clean fixes:
- Use a new tag for every build (
1.0.1, a Git SHA, a timestamp). This is what you should do in real pipelines anyway, because a tag then always means the same bits. - Use the Always pull policy (
imagePullPolicy: Always) while you iterate locally. The kubelet then asks the mirror every time, which costs only milliseconds on Docker Desktop.
Avoid the latest tag for this. latest silently switches the default pull policy to Always, which hides the problem locally and causes surprises later.
Step 8 — The Same Cluster with the kind CLI
Install kind. On macOS with Homebrew use brew install kind. Otherwise download the binary for your platform from the release page:
Describe the cluster in kind-lab.yaml. This is the file you commit to Git:
Three things are worth noting:
extraPortMappingspublishes a port of the node container on your machine. Plain kind has no LoadBalancer, so the usual pattern is a NodePort Service on a fixed port plus this mapping.labelssets node labels at creation time. You will use these later for node selectors and affinity.- Add
image: kindest/node:v1.36.4@sha256:...to every node if you want to pin the version.
Create the cluster:
kind switched your current context to kind-lab. Check the labels:
Step 9 — Load the Image and Use the Port Mapping
Plain kind cannot see your Docker images, so load the image first:
Then expose the Deployment on the NodePort that the config file mapped:
The request entered the control-plane node container on port 30080, and kube-proxy forwarded it to a Pod on one of the workers.
After a rebuild under the same tag, run kind load docker-image again. The stale-image trap from Step 6 applies here too.
Step 10 — Clean Up
Remove the demo from the Docker Desktop cluster:
Delete the kind CLI cluster. This removes the node containers and its context:
In Docker Desktop, Reset cluster in the Kubernetes view recreates the cluster from scratch, and it is also how you move to a newer Kubernetes version. Docker Desktop does not upgrade an existing cluster automatically. Stop removes the Kubernetes containers.
Summary
- kind runs every Kubernetes node as a Docker container with its own containerd and kubelet, which makes real multi-node clusters cheap on a laptop.
- Docker Desktop's kind provisioner creates a multi-node cluster from the UI. It requires the containerd image store, and its context is
docker-desktop. - The kind CLI builds the same cluster from a versioned config file, with
extraPortMappings, nodelabelsand pinned node images. - Docker Desktop feeds locally built images to the nodes through a registry mirror. Plain kind needs
kind load docker-image. - Nodes cache images. Rebuilding under the same tag with
IfNotPresentkeeps the old code running. Use a new tag per build, orimagePullPolicy: Alwayswhile iterating. - On Docker Desktop, LoadBalancer Services are reachable on
localhost:<port>. On plain kind use a NodePort plus a port mapping, or installcloud-provider-kind.
With a multi-node cluster in place, the next articles move from the cluster to the workload, starting with the kubeconfig file that connects kubectl to all of these clusters.