Multi-Node Config
Control-Plane & Workers
# kind-multinode.yaml — production-like topology for CI
apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
name: ci-cluster
nodes:
# Single control-plane (HA control-planes are possible but slow to create in CI)
- role: control-plane
image: kindest/node:v1.30.2
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
# Two worker nodes
- role: worker
image: kindest/node:v1.30.2
- role: worker
image: kindest/node:v1.30.2
Worker Labels via kubeadm Patches
# Label workers at cluster creation time using kubeadmConfigPatches
nodes:
- role: worker
kubeadmConfigPatches:
- |
kind: JoinConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "topology.kubernetes.io/zone=us-east-1a,workload=backend"
- role: worker
kubeadmConfigPatches:
- |
kind: JoinConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "topology.kubernetes.io/zone=us-east-1b,workload=frontend"
# Create cluster from config
kind create cluster --config kind-multinode.yaml
# Verify worker labels
kubectl get nodes --show-labels | grep workload
# ci-cluster-worker Ready worker 60s v1.30.2 ...,workload=backend,...
# ci-cluster-worker2 Ready worker 55s v1.30.2 ...,workload=frontend,...
Networking
ExtraPortMappings for Ingress
# Install nginx ingress controller configured for Kind
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
# Wait for ingress controller to become ready
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=120s
# Deploy a test app
kubectl create deployment webapp --image=nginx --port=80
kubectl expose deployment webapp --port=80
# Create an Ingress
cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: webapp-ingress
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /webapp
pathType: Prefix
backend:
service:
name: webapp
port:
number: 80
EOF
# Access through localhost (extraPortMappings mapped 80 → container 80)
curl http://localhost/webapp
NodePort Exposure
# Expose as NodePort and access via port-forward (simplest for CI)
kubectl expose deployment webapp --type=NodePort --port=80
# Get the assigned NodePort
NODE_PORT=$(kubectl get svc webapp -o jsonpath='{.spec.ports[0].nodePort}')
echo "NodePort: $NODE_PORT"
# Access via the Kind container IP
KIND_IP=$(docker inspect ci-cluster-control-plane \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
curl http://$KIND_IP:$NODE_PORT/
# Or use kubectl port-forward (works in any network context including CI)
kubectl port-forward svc/webapp 8080:80 &
curl http://localhost:8080/
kill %1 # stop port-forward
Kind in GitHub Actions
Full Workflow YAML
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# 1. Check out source
- name: Checkout code
uses: actions/checkout@v4
# 2. Set up Go (for Go-based controllers)
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
# 3. Create Kind cluster (uses engageintellect/kind-action or engineerd/setup-kind)
- name: Create Kind cluster
uses: helm/kind-action@v1.10.0
with:
version: v0.23.0
kubectl_version: v1.30.2
cluster_name: ci-cluster
config: ./kind-config.yaml # your multi-node config file
# 4. Build the application image
- name: Build image
run: |
docker build -t myapp:${{ github.sha }} .
docker tag myapp:${{ github.sha }} myapp:latest
# 5. Load image into Kind (avoids registry push)
- name: Load image into Kind
run: kind load docker-image myapp:${{ github.sha }} --name ci-cluster
# 6. Deploy the application
- name: Deploy application
run: |
kubectl apply -f k8s/
kubectl set image deployment/myapp myapp=myapp:${{ github.sha }}
# 7. Wait for rollout to complete
- name: Wait for deployment
run: |
kubectl rollout status deployment/myapp --timeout=120s
kubectl wait pod \
--for=condition=ready \
--selector=app=myapp \
--timeout=120s
# 8. Run integration/E2E tests
- name: Run E2E tests
run: |
# Port-forward in background
kubectl port-forward svc/myapp 8080:80 &
sleep 3 # wait for port-forward to establish
# Run your test suite
go test ./tests/e2e/... -v -timeout 5m \
-base-url=http://localhost:8080
# 9. Capture debug info on failure
- name: Debug on failure
if: failure()
run: |
kubectl get all -A
kubectl describe pods --all-namespaces
kubectl logs -l app=myapp --tail=50 || true
Matrix Testing Against Multiple Kubernetes Versions
# Test against multiple Kubernetes versions in parallel
jobs:
e2e-matrix:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
k8s-version:
- v1.28.9
- v1.29.4
- v1.30.2
steps:
- uses: actions/checkout@v4
- name: Create Kind cluster (k8s ${{ matrix.k8s-version }})
uses: helm/kind-action@v1.10.0
with:
node_image: kindest/node:${{ matrix.k8s-version }}
cluster_name: test-${{ matrix.k8s-version }}
- name: Run tests
run: |
kubectl apply -f k8s/
kubectl rollout status deployment/myapp --timeout=120s
go test ./tests/... -v
CI image caching: GitHub Actions caches Docker layer pulls per runner. The
kindest/node image is ~700 MB but is cached after the first pull on a given runner. To minimise pull time, use a pinned image digest in your Kind config rather than a floating tag.
Loading Images into Kind
# ── Method 1: kind load docker-image ──────────────────────────
# For images already built in the CI environment
docker build -t myapp:ci .
kind load docker-image myapp:ci --name ci-cluster
# Load multiple images at once
kind load docker-image myapp:ci sidecar:ci --name ci-cluster
# ── Method 2: Local registry (for faster iteration) ───────────
# Start registry (once per workflow, reused across steps)
docker run -d -p 5001:5000 --name kind-registry --restart=always registry:2
# Connect to Kind network
docker network connect kind kind-registry
# Push and pull normally (no need for kind load)
docker build -t localhost:5001/myapp:ci .
docker push localhost:5001/myapp:ci
# In your Deployment:
# image: localhost:5001/myapp:ci
# imagePullPolicy: Always (or IfNotPresent)
# ── Method 3: Kind archive loading (largest images) ─────────
# Export image to tarball, then archive-load (most reliable for 1GB+ images)
docker save myapp:ci -o /tmp/myapp.tar
kind load image-archive /tmp/myapp.tar --name ci-cluster
Flux & Kind
Kind is an excellent environment for testing Flux GitOps workflows locally or in CI. You can bootstrap Flux onto a Kind cluster and verify that your Kustomization and HelmRelease objects reconcile correctly before pushing to a staging cluster.
# Install Flux CLI
curl -s https://fluxcd.io/install.sh | sudo bash
# Check Kind cluster satisfies Flux prerequisites
flux check --pre
# Bootstrap Flux onto Kind cluster using a Git repo
# (Replace with your real GitHub repo and token)
flux bootstrap github \
--owner=your-org \
--repository=gitops-config \
--branch=main \
--path=clusters/kind-ci \
--personal \
--token-auth
# Flux creates its own namespace and starts reconciling
kubectl get pods -n flux-system
# NAME READY STATUS RESTARTS
# helm-controller-xxxxxxxxx-xxxxx 1/1 Running 0
# kustomize-controller-xxxxxxxxx-xxxxx 1/1 Running 0
# notification-controller-xxxxxxxxx-xxxxx 1/1 Running 0
# source-controller-xxxxxxxxx-xxxxx 1/1 Running 0
# Watch reconciliation
flux get all
# clusters/kind-ci/myapp.yaml — a Kustomization for Kind CI testing
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: myapp
namespace: flux-system
spec:
interval: 1m
path: ./apps/myapp/overlays/dev
prune: true
sourceRef:
kind: GitRepository
name: gitops-config
healthChecks:
- apiVersion: apps/v1
kind: Deployment
name: myapp
namespace: default
timeout: 2m
# Force a reconciliation (useful in CI after pushing changes)
flux reconcile kustomization myapp --with-source
# Wait for specific Kustomization to be ready
kubectl wait kustomization/myapp \
-n flux-system \
--for=condition=Ready \
--timeout=120s
Exercises
Exercise 1 — Full CI Workflow: Create a GitHub repository with a simple Go HTTP server, a Dockerfile, Kubernetes manifests, and a GitHub Actions workflow using
helm/kind-action. The workflow should build the image, load it into Kind, deploy, wait for rollout, and verify the HTTP endpoint returns 200. Make a code change, push, and watch the CI pipeline pass.
Exercise 2 — Matrix K8s Versions: Extend your GitHub Actions workflow with a matrix strategy testing against Kubernetes v1.28, v1.29, and v1.30. Confirm all three pass. Then introduce a manifest that uses a feature only available in v1.29+ and verify the v1.28 job fails with a clear error.
Exercise 3 — Flux on Kind: Install Flux CLI and bootstrap Flux onto a local Kind cluster using a test GitHub repo. Create a simple Kustomization pointing to a
dev overlay. Push a manifest change to the repo and watch Flux reconcile it in real time with flux get all -w. Verify with kubectl get pods that the updated deployment is running.
Key Takeaways
Key Takeaways:
- Kind multi-node clusters: define all nodes in
kind-config.yamland usekubeadmConfigPatchesto set worker labels extraPortMappingsbind container ports to host ports — map 80/443 on the control-plane for ingress controller accesshelm/kind-actionis the standard GitHub Action; it creates, configures, and provideskubectlin one step- Load images with
kind load docker-imagein CI (no registry needed); use a local registry for repeated iteration in dev - Matrix testing against multiple Kubernetes versions runs in parallel and catches API deprecation early
- Flux bootstraps onto Kind in minutes — use it to validate GitOps configurations before promoting to staging