Kata-on-GKE runbook¶
Reproduce the Q226 result: a GitHub Actions runner running dockerd + kind
inside a Kata Containers micro-VM on GKE, with no privileged: true on the
runner pod.
This was run live and passed (GKE 1.35.5-gke.1241004, Ubuntu 24.04 /
containerd 2.1.5, c2-standard-4 nested-virt, Kata 3.32.0 / QEMU). Results and
the full constraint list are in
Kata Containers on GKE; the cluster-side how-to for
operators is Running DinD workloads under Kata.
Steps below mutate cloud resources. Use a throwaway project or cluster — never a production one — and tear it down when finished.
Before you start¶
gcloudauthenticated, with permission to create clusters.kubectlandhelmonPATH(make doctorchecks both).- Pin
--project/--zoneon everygcloudcall, and--contexton everykubectlcall. Parallel sessions share~/.kube/configand~/.config/gcloud.
export PROJECT=<throwaway-gcp-project>
export CLUSTER=<throwaway-cluster>
export ZONE=us-central1-a
export CTX="gke_${PROJECT}_${ZONE}_${CLUSTER}"
Step 1 — Create a nested-virt cluster¶
Acceptance criterion #1.
Create the nested-virt pool as the cluster's initial pool. A separate
node-pools create that hits a capacity stockout holds a cluster-level lock and
blocks every other mutation — including deleting the stuck pool — for tens of
minutes.
gcloud container clusters create "$CLUSTER" \
--project="$PROJECT" --zone="$ZONE" \
--num-nodes=1 --machine-type=c2-standard-4 \
--image-type=UBUNTU_CONTAINERD \
--enable-nested-virtualization \
--disk-size=100 --release-channel=regular \
--node-labels=gag.dev/kata-ci=true \
--workload-pool="${PROJECT}.svc.id.goog"
--enable-nested-virtualization requires UBUNTU_CONTAINERD (or
COS_CONTAINERD ≥ 1.28.4-gke.1083000) and GKE Standard — Autopilot cannot
do it. Machine family must be n2/n2d/c2/c2d; e2 and the GPU families
(a2/a3/g2) cannot. If you hit ZONE_RESOURCE_POOL_EXHAUSTED, that is a
capacity stockout, not a quota error — try another family or zone. Check the
per-family quota too (C2_CPUS defaults to 8 on a fresh project).
Harden the metadata server — Kata does not do this for you:
gcloud container node-pools update default-pool \
--project="$PROJECT" --cluster="$CLUSTER" --zone="$ZONE" \
--workload-metadata=GKE_METADATA # recreates the nodes
Verify /dev/kvm — the flag asserts intent, the device is the proof:
kubectl --context "$CTX" debug node/"$(kubectl --context "$CTX" get nodes \
-o jsonpath='{.items[0].metadata.name}')" -it --image=busybox -- ls -l /host/dev/kvm
Expected: crw-rw---- ... 10, 232 /host/dev/kvm. If absent, nested
virtualization did not take effect and nothing below will work.
Step 2 — Install Kata and the RuntimeClass¶
Acceptance criterion #2.
Upstream no longer publishes kata-deploy.yaml / kata-rbac.yaml release assets
(they 404). Install from the OCI Helm chart:
helm --kube-context "$CTX" install kata-deploy \
oci://quay.io/kata-containers/kata-deploy-charts/kata-deploy \
--version 3.32.0 -n kube-system \
-f deploy/kata-ci/kata-values.yaml --wait --timeout 15m
kubectl --context "$CTX" apply -f deploy/kata-ci/runtimeclass.yaml
Confirm the runtime landed and the node self-labelled. GKE preinstalls unrelated
gvisor / confidential-linked-runner classes, so check the label too:
kubectl --context "$CTX" -n kube-system rollout status ds/kata-deploy --timeout=10m
kubectl --context "$CTX" get nodes -l katacontainers.io/kata-runtime=true
kubectl --context "$CTX" get runtimeclass kata kata-qemu
Expected: the DaemonSet is Ready, the node carries
katacontainers.io/kata-runtime=true (applied by kata-deploy, not by you),
and both classes exist.
Step 3 — Start the unprivileged runner¶
Acceptance criterion #2 (continued).
kubectl --context "$CTX" apply -f deploy/kata-ci/runner-pod.yaml
kubectl --context "$CTX" wait --for=condition=Ready pod/kata-runner --timeout=7m
Prove it is unprivileged, and that the boundary is a real VM:
# Expect 0. Any other number means the invariant is broken.
kubectl --context "$CTX" get pod kata-runner -o json | grep -c '"privileged": true'
# Node kernel vs guest kernel MUST differ — identical means a silent runc fallback.
kubectl --context "$CTX" get node -o jsonpath='{.items[0].status.nodeInfo.kernelVersion}'; echo
kubectl --context "$CTX" exec kata-runner -- uname -r
# dockerd must be on overlay2. `vfs` means the block volume did not mount.
kubectl --context "$CTX" exec kata-runner -- docker info --format '{{.Driver}}'
Expected: 0; two different kernels (spike saw 6.8.0-1054-gke vs
6.18.35); overlay2.
If dockerd fails, add one capability at a time and re-test. Never set
privileged: true — that defeats the entire design.
Step 4 — kind create cluster inside the micro-VM¶
Acceptance criteria #3, #4, #6. This is the headline claim.
kind needs the guest's /dev/kmsg bound into its node container:
kubectl --context "$CTX" exec kata-runner -- sh -c 'cat > /root/kind-config.yaml <<YAML
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraMounts:
- hostPath: /dev/kmsg
containerPath: /dev/kmsg
YAML'
kubectl --context "$CTX" exec kata-runner -- \
kind create cluster --name kata-spike --config /root/kind-config.yaml \
--image "$KIND_NODE_IMAGE" --wait 300s # same digest as .github/workflows/e2e-reusable.yml
kubectl --context "$CTX" exec kata-runner -- kubectl --context kind-kata-spike get nodes
kubectl --context "$CTX" exec kata-runner -- sh -c \
'docker pull -q busybox:latest && kind load docker-image --name kata-spike busybox:latest'
Expected: the kind control-plane node reaches Ready, and kind load succeeds.
Spike timings: 58 s cold / 43 s warm for kind create cluster; 2 s for kind load
— well inside the ≤ ~6 min ceiling.
Step 5 — Confirm the metadata server is closed¶
Kata isolates the kernel, not the pod network. Verify Workload Identity is doing its job. Never print the token body.
kubectl --context "$CTX" run meta-id --image=busybox:1.36 --restart=Never \
--overrides='{"spec":{"runtimeClassName":"kata"}}' --command -- sh -c \
'wget -qO- --header="Metadata-Flavor: Google" \
http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/email'
kubectl --context "$CTX" logs meta-id
Expected: <project>.svc.id.goog — the workload-pool identity. If it returns a
*-compute@developer.gserviceaccount.com address, the pod can mint node
credentials: GKE_METADATA is not in effect and the runner is not safe for
untrusted code.
Step 6 — Tear down¶
Delete the PVC first. A Delete reclaim policy only fires when the PVC is
deleted — tearing down the cluster orphans the underlying PD, which keeps billing:
kubectl --context "$CTX" delete -f deploy/kata-ci/runner-pod.yaml # pod + PVC
gcloud container clusters delete "$CLUSTER" --project="$PROJECT" --zone="$ZONE" --quiet
Confirm you are deleting the throwaway cluster, then check for orphans — the
100Gi pd-balanced behind the Block PVC is the one that gets left behind:
gcloud compute disks list --project="$PROJECT" # expect: Listed 0 items.
gcloud compute instances list --project="$PROJECT"
gcloud compute addresses list --project="$PROJECT"
Delete any survivor with gcloud compute disks delete <name> --zone="$ZONE".
What is still outstanding¶
make e2e inside the runner (acceptance criterion #5) has not been run: it
needs a GAG e2e runner image bundling dockerd, kind and the Go/test toolchain,
which does not exist yet. Everything beneath it — Docker, kind, image loading,
pod scheduling in the inner cluster — is proven above. See
CI integration.