Setting up your first multi-node cluster with kind
kind — Kubernetes IN Docker — runs each Kubernetes node as a Docker container. That sounds like a toy, but the nodes run real kubelets, a real control plane, and real container runtimes, which makes it the closest you can get to a production topology on a laptop. It is also what the Kubernetes project itself uses for conformance testing.
Every command below has been written to run as-is.
Install
Prerequisites: Docker running, and at least 4 GB of memory available to it (8 GB is comfortable for three nodes).
# macOS
brew install kind kubectl
# Linux (amd64)
[ "$(uname -m)" = x86_64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.30.0/kind-linux-amd64
chmod +x ./kind && sudo mv ./kind /usr/local/bin/kind
# Windows
winget install Kubernetes.kind
kind version
docker info | grep -E 'Total Memory|Server Version'
A single node is one command
kind create cluster --name quickstart
kubectl cluster-info --context kind-quickstart
kind delete cluster --name quickstart
Useful for a smoke test. It teaches you nothing about scheduling, because there is only one node — and it is a control-plane node carrying a taint, which kind removes so your Pods can land somewhere.
The multi-node cluster
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: nativehub
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
labels:
topology.kubernetes.io/zone: zone-a
node-type: general
- role: worker
labels:
topology.kubernetes.io/zone: zone-b
node-type: general
kind create cluster --config kind-config.yaml
kubectl get nodes -o wide
NAME STATUS ROLES VERSION
nativehub-control-plane Ready control-plane v1.34.0
nativehub-worker Ready <none> v1.34.0
nativehub-worker2 Ready <none> v1.34.0
Each node is a Docker container — this is not a metaphor:
docker ps --filter "name=nativehub" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
docker exec -it nativehub-worker crictl ps
Note crictl, not docker. Inside the node, containerd is the runtime; there is no Docker daemon
in there.
Two details in that config do real work:
extraPortMappingspublishes ports from the control-plane container to your host, which is how browser traffic onlocalhost:80reaches an Ingress controller inside the cluster.node-labels: ingress-ready=truemarks that node so the ingress-nginx manifest — which includes a matchingnodeSelector— schedules there.
Pin the Kubernetes version
kind's default node image tracks the kind release. To match a specific cluster version, pass a digest-pinned image:
kind create cluster --config kind-config.yaml \
--image kindest/node:v1.34.0
Each kind release supports a specific set of node images — check the release notes rather than
guessing, since an unsupported combination fails during kubeadm init with an unhelpful timeout.
Install an ingress controller
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.12.0/deploy/static/provider/kind/deploy.yaml
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=180s
That manifest is the kind-specific variant: it uses hostPort and the ingress-ready node
selector, which is what makes the extraPortMappings above line up.
Deploy something and route to it
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 4
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
# Spread replicas across zones so a "zone" outage cannot take everything.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: web }
containers:
- name: echo
image: ealen/echo-server:0.9.2
ports:
- containerPort: 80
resources:
requests: { cpu: 25m, memory: 32Mi }
limits: { memory: 64Mi }
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector: { app: web }
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
kubectl apply -f demo.yaml
kubectl rollout status deployment/web
curl -s localhost/ | jq '.host, .request.headers."x-forwarded-for"'
Check where the scheduler put things:
kubectl get pods -l app=web -o custom-columns=\
'NAME:.metadata.name,NODE:.spec.nodeName,IP:.status.podIP'
With topologySpreadConstraints, the four replicas are split evenly across the two labelled
workers. That is genuinely useful to see — it is the same mechanism that keeps production workloads
spread across availability zones.
Load local images without a registry
The most common kind papercut: you build an image locally, reference it in a manifest, and the Pod
sits in ErrImagePull. The nodes are separate containers with their own image stores — your host's
Docker images are not visible to them.
docker build -t myapp:dev .
kind load docker-image myapp:dev --name nativehub
# Verify it landed
docker exec -it nativehub-worker crictl images | grep myapp
Set imagePullPolicy: IfNotPresent in the manifest. With the default Always behaviour for
untagged or :latest images, the kubelet will try to pull from Docker Hub and fail anyway.
A local registry (the better workflow)
kind load copies the image into every node on every build, which gets slow. A local registry the
cluster can pull from is faster and closer to reality:
#!/usr/bin/env bash
set -euo pipefail
reg_name='kind-registry'
reg_port='5001'
# 1. Start a registry container if it is not already running
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run -d --restart=always -p "127.0.0.1:${reg_port}:5000" \
--network bridge --name "${reg_name}" registry:2
fi
# 2. Create the cluster, telling containerd where to find the mirror
cat <<EOF | kind create cluster --name nativehub --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
- role: worker
- role: worker
EOF
# 3. Point each node at the registry
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
for node in $(kind get nodes --name nativehub); do
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
[host."http://${reg_name}:5000"]
EOF
done
# 4. Join the registry to kind's network so the nodes can reach it by name
docker network connect "kind" "${reg_name}" 2>/dev/null || true
chmod +x setup-registry.sh && ./setup-registry.sh
docker build -t localhost:5001/myapp:dev .
docker push localhost:5001/myapp:dev
# Reference it in your manifests as localhost:5001/myapp:dev
Simulate a node failure
This is the best reason to run multiple nodes locally.
# Watch in one terminal
kubectl get pods -l app=web -o wide --watch
# Kill a node in another
docker stop nativehub-worker2
Within about 40 seconds the node controller marks the node NotReady; after the eviction timeout
(five minutes by default) its Pods are marked for deletion and the ReplicaSet schedules replacements
on the surviving worker. Bring it back:
docker start nativehub-worker2
kubectl get nodes
You can also practise a graceful drain, which is what a real node upgrade does:
kubectl drain nativehub-worker --ignore-daemonsets --delete-emptydir-data
kubectl get pods -o wide # everything moved off
kubectl uncordon nativehub-worker
Everyday kind commands
| Command | What it does |
|---|---|
kind get clusters | List clusters |
kind get nodes --name X | List a cluster's node containers |
kind load docker-image IMG --name X | Copy a local image into the nodes |
kind export logs ./logs --name X | Dump all node and control-plane logs |
kind delete cluster --name X | Tear it all down |
docker exec -it X-worker bash | Shell into a node |
When kind is not the right tool
kind is excellent for CI, controller development, and learning. Its limits are real:
- No cloud LoadBalancer.
type: LoadBalancerServices stay<pending>unless you install MetalLB or runcloud-provider-kind. - No persistent storage beyond the local-path provisioner, which is
ReadWriteOnceand node-local — you cannot exercise real CSI behaviour. - Nodes share the host kernel, so kernel-level differences from your production nodes will not show up.
- Everything is ephemeral by design. Deleting the cluster deletes the data.
For a persistent single-node environment, minikube or k3d suit better. For anything involving cloud integrations, nothing substitutes for a real cluster.
Clean up
kind delete cluster --name nativehub
docker stop kind-registry && docker rm kind-registry
Further reading
- Deploy your first app to Kubernetes — the natural next step.
- Kubernetes networking — what the Ingress above is doing.
- The official kind documentation.