Skip to main content

Setting up your first multi-node cluster with kind

· 7 min read
Maintainer, NativeHub

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-config.yaml
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:

  • extraPortMappings publishes ports from the control-plane container to your host, which is how browser traffic on localhost:80 reaches an Ingress controller inside the cluster.
  • node-labels: ingress-ready=true marks that node so the ingress-nginx manifest — which includes a matching nodeSelector — 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

demo.yaml
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:

setup-registry.sh
#!/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

CommandWhat it does
kind get clustersList clusters
kind get nodes --name XList a cluster's node containers
kind load docker-image IMG --name XCopy a local image into the nodes
kind export logs ./logs --name XDump all node and control-plane logs
kind delete cluster --name XTear it all down
docker exec -it X-worker bashShell 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: LoadBalancer Services stay <pending> unless you install MetalLB or run cloud-provider-kind.
  • No persistent storage beyond the local-path provisioner, which is ReadWriteOnce and 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