Back to Distributed Systems & Kubernetes Series

Minikube Track Part 2: Multi-Node & Profiles

June 6, 2026 Wasil Zafar 28 min read

Minikube can simulate multi-node clusters for testing PodAffinity, DaemonSets, and workload scheduling. Profiles let you switch between multiple isolated clusters in seconds — one for each project.

Table of Contents

  1. Multi-Node Clusters
  2. Node Labels & Taints
  3. Profiles
  4. Networking in Multi-Node
  5. Persistent Storage
  6. Exercises
  7. Key Takeaways

Multi-Node Clusters

Minikube's --nodes flag starts a cluster with multiple nodes — one control-plane and N-1 workers. This is invaluable for testing scheduling constraints, DaemonSets (which need multiple nodes to verify they run everywhere), and inter-node networking behaviour.

Starting a Multi-Node Cluster

# Start a 3-node cluster (1 control-plane + 2 workers)
minikube start --nodes=3 --driver=docker

# With a profile name and specific Kubernetes version
minikube start -p multinode-test --nodes=3 \
  --driver=docker \
  --kubernetes-version=v1.30.2 \
  --cpus=2 --memory=2048

# Verify nodes are ready
kubectl get nodes -o wide
# NAME                 STATUS   ROLES           AGE   VERSION   INTERNAL-IP    OS-IMAGE
# multinode-test       Ready    control-plane   90s   v1.30.2   192.168.49.2   Ubuntu 22.04
# multinode-test-m02   Ready    worker          60s   v1.30.2   192.168.49.3   Ubuntu 22.04
# multinode-test-m03   Ready    worker          45s   v1.30.2   192.168.49.4   Ubuntu 22.04

Node Roles

By default Minikube names nodes as <profile> for the control-plane and <profile>-m02, <profile>-m03, etc. for workers. Kubernetes taints the control-plane node with node-role.kubernetes.io/control-plane:NoSchedule so regular pods schedule only on workers.

# Inspect node labels and taints
kubectl describe node multinode-test | grep -A 5 Taints
# Taints:  node-role.kubernetes.io/control-plane:NoSchedule

kubectl describe node multinode-test-m02 | grep -A 5 Taints
# Taints:  <none>

# Check node capacity
kubectl get nodes -o custom-columns=\
NAME:.metadata.name,CPU:.status.capacity.cpu,MEM:.status.capacity.memory
# NAME                 CPU   MEM
# multinode-test       2     2048Mi
# multinode-test-m02   2     2048Mi
# multinode-test-m03   2     2048Mi

Adding Nodes to an Existing Cluster

# Add a node to a running cluster (without restart)
minikube node add -p multinode-test

# List nodes managed by Minikube
minikube node list -p multinode-test
# multinode-test       192.168.49.2
# multinode-test-m02   192.168.49.3
# multinode-test-m03   192.168.49.4
# multinode-test-m04   192.168.49.5

# Delete a specific worker node
minikube node delete multinode-test-m04 -p multinode-test

Node Labels & Taints

Adding Labels for Scheduling Tests

# Label nodes to simulate topology zones
kubectl label node multinode-test-m02 topology.kubernetes.io/zone=us-east-1a
kubectl label node multinode-test-m03 topology.kubernetes.io/zone=us-east-1b

# Add custom labels for workload placement
kubectl label node multinode-test-m02 workload=backend
kubectl label node multinode-test-m03 workload=frontend

# Verify labels
kubectl get nodes --show-labels | grep workload

Taints & Tolerations

# Taint a node to test toleration behaviour
kubectl taint node multinode-test-m03 dedicated=gpu:NoSchedule

# A pod without the toleration will not schedule on m03:
kubectl run test-notolerated --image=nginx
kubectl describe pod test-notolerated | grep -A 5 Events
# Events:
#   Warning  FailedScheduling  ... 0/2 nodes are available: 1 node(s) had taint...

# A pod WITH the toleration schedules on m03:
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: gpu-pod
spec:
  tolerations:
    - key: "dedicated"
      operator: "Equal"
      value: "gpu"
      effect: "NoSchedule"
  nodeSelector:
    workload: frontend      # still pin to m03
  containers:
    - name: gpu-app
      image: nginx
EOF

# Remove the taint when done
kubectl taint node multinode-test-m03 dedicated=gpu:NoSchedule-

PodAffinity & Anti-Affinity Testing

# Deploy a backend pod and then an anti-affinity frontend
# The frontend should land on a DIFFERENT node from the backend
apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
spec:
  replicas: 2
  selector:
    matchLabels:
      app: frontend
  template:
    metadata:
      labels:
        app: frontend
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchExpressions:
                  - key: app
                    operator: In
                    values:
                      - backend
              topologyKey: kubernetes.io/hostname
      containers:
        - name: frontend
          image: nginx
# Verify frontend replicas landed on different nodes from backend
kubectl get pods -o wide --selector=app=frontend
# NAME            READY  STATUS   NODE
# frontend-xxxxx  1/1    Running  multinode-test-m02
# frontend-yyyyy  1/1    Running  multinode-test-m03

# Test DaemonSets — should run on all worker nodes
kubectl create -f - <<EOF
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: node-logger
spec:
  selector:
    matchLabels:
      app: node-logger
  template:
    metadata:
      labels:
        app: node-logger
    spec:
      tolerations:
        - key: node-role.kubernetes.io/control-plane
          effect: NoSchedule
      containers:
        - name: logger
          image: busybox
          command: ["sh", "-c", "while true; do echo $(hostname); sleep 10; done"]
EOF

kubectl get pods -o wide -l app=node-logger
# Should have one pod per node (3 total)

Profiles

Each Minikube profile is a completely independent cluster with its own kubeconfig context, container/VM, addon set, and Kubernetes version. Switching profiles is instant — no restart needed.

Listing Profiles

# Show all profiles with status
minikube profile list
# |----------------|-----------|---------|--------------|------|---------|---------|-------|--------|
# | Profile        | VM Driver | Runtime |      IP      | Port | Version | Status  | Nodes | Active |
# |----------------|-----------|---------|--------------|------|---------|---------|-------|--------|
# | minikube       | docker    | docker  | 192.168.49.2 | 8443 | v1.30.2 | Running |     1 | *      |
# | multinode-test | docker    | docker  | 192.168.49.3 | 8443 | v1.30.2 | Running |     3 |        |
# | legacy-k126    | docker    | docker  | 192.168.49.4 | 8443 | v1.26.0 | Stopped |     1 |        |
# |----------------|-----------|---------|--------------|------|---------|---------|-------|--------|

# The asterisk (*) marks the currently active profile

Switching Profiles

# Switch active profile — also switches kubectl context
minikube profile multinode-test
kubectl config current-context
# multinode-test

# Or use kubectl directly to switch context
kubectl config use-context minikube

# Create a brand-new profile for a new project
minikube start -p my-new-project --driver=docker --kubernetes-version=v1.31.0

# Stop a specific profile (preserves data)
minikube stop -p legacy-k126

# Start a stopped profile
minikube start -p legacy-k126

# Delete a profile permanently (removes VM and all data)
minikube delete -p legacy-k126

# Delete ALL profiles
minikube delete --all --purge
Shell integration: Add alias mk='minikube -p $(kubectl config current-context)' to your shell config so Minikube commands automatically target whichever cluster kubectl is pointing at. This prevents accidentally running minikube stop on the wrong profile.

Networking in Multi-Node

NodePort Services

# Expose a deployment as a NodePort service
kubectl expose deployment myapp --type=NodePort --port=80

# Get the URL (Minikube handles IP + NodePort for you)
minikube service myapp --url -p multinode-test
# http://192.168.49.3:32000

# Open in browser automatically
minikube service myapp -p multinode-test

# If you need a specific node's IP
kubectl get nodes -o jsonpath='{.items[1].status.addresses[0].address}'
# 192.168.49.3

kubectl get svc myapp -o jsonpath='{.spec.ports[0].nodePort}'
# 32000

curl http://192.168.49.3:32000

LoadBalancer & Tunnel

# LoadBalancer services get <pending> EXTERNAL-IP by default
kubectl expose deployment myapp --type=LoadBalancer --port=80
kubectl get svc myapp
# NAME    TYPE           CLUSTER-IP    EXTERNAL-IP   PORT(S)        AGE
# myapp   LoadBalancer   10.96.0.100   <pending>     80:31234/TCP   5s

# minikube tunnel assigns a real IP from your localhost range
# Run in a SEPARATE terminal (requires sudo on Linux/macOS)
sudo minikube tunnel -p multinode-test

# Now EXTERNAL-IP is populated
kubectl get svc myapp
# NAME    TYPE           CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE
# myapp   LoadBalancer   10.96.0.100   127.0.0.1     80/TCP    30s

curl http://127.0.0.1/
Tunnel cleanup: minikube tunnel creates IP routes on your host. Press Ctrl+C to stop the tunnel, or run minikube tunnel --cleanup to explicitly remove leftover routes after unexpected termination.

Persistent Storage

Minikube ships with the storage-provisioner addon enabled by default. It provides a standard StorageClass backed by host paths inside the Minikube VM — enough for development PVCs.

# Check the default StorageClass
kubectl get storageclass
# NAME                 PROVISIONER                RECLAIMPOLICY   VOLUMEBINDINGMODE
# standard (default)   k8s.io/minikube-hostpath   Delete          Immediate

# Create a PVC using the default StorageClass
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: myapp-data
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
  # storageClassName: standard  # optional — default is used if omitted
EOF

# Verify it binds (should go Bound immediately with dynamic provisioning)
kubectl get pvc myapp-data
# NAME         STATUS   VOLUME                              CAPACITY   STORAGECLASS
# myapp-data   Bound    pvc-xxxxxxxx-xxxx-xxxx-xxxx-xxxx    1Gi        standard

# Mount in a Pod
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: myapp-with-storage
spec:
  containers:
    - name: app
      image: nginx
      volumeMounts:
        - mountPath: /data
          name: app-storage
  volumes:
    - name: app-storage
      persistentVolumeClaim:
        claimName: myapp-data
EOF
# In multi-node clusters, hostPath PVs bind to a specific node.
# If your pod reschedules to a different node, it won't see the old data.
# For multi-node persistence tests, use the CSI hostpath driver addon:
minikube addons enable csi-hostpath-driver
minikube addons enable volumesnapshots

# This creates a csi-hostpath StorageClass
kubectl get storageclass
# NAME                 PROVISIONER              RECLAIMPOLICY
# csi-hostpath-sc      hostpath.csi.k8s.io      Delete
# standard (default)   k8s.io/minikube-hostpath  Delete

Exercises

Exercise 1 — Multi-Node Scheduling: Start a 3-node Minikube cluster. Label m02 as tier=backend and m03 as tier=frontend. Deploy two Deployments — one with nodeSelector: tier: backend and one with tier: frontend. Verify with kubectl get pods -o wide that each pod lands on the intended node.
Exercise 2 — DaemonSet: Create a DaemonSet using the busybox image that prints the node hostname every 10 seconds. Add a toleration for the control-plane taint. Verify you get 3 pods (one per node). Then add a 4th node with minikube node add and watch the DaemonSet automatically schedule a 4th pod.
Exercise 3 — Tunnel: Deploy nginx, expose it as a LoadBalancer service. Observe the <pending> EXTERNAL-IP. Run minikube tunnel in a separate terminal (with sudo). Verify the EXTERNAL-IP is assigned and curl returns the nginx welcome page. Stop the tunnel and verify the EXTERNAL-IP returns to pending.

Key Takeaways

Key Takeaways:
  • Use minikube start --nodes=N to create a multi-node cluster for scheduling, affinity, and DaemonSet testing
  • Add nodes dynamically to a running cluster with minikube node add — no restart required
  • Label nodes to simulate topology zones and test nodeSelector, podAffinity, and anti-affinity rules
  • Profiles keep clusters completely isolated: different Kubernetes versions, different addon sets, different kubeconfig contexts
  • minikube service opens NodePort services in the browser; minikube tunnel provides real IPs for LoadBalancer services
  • Default standard StorageClass uses hostPath provisioning — adequate for dev; use csi-hostpath-driver for multi-node PVC tests