Helm Configuration¶
Parameters¶
The helm chart provides a comprehensive set of configuration options. You may view the parameters and their descriptions by going to the GitHub source or by using helm:
# List available kata-deploy chart versions:
# helm search repo kata-deploy-charts/kata-deploy --versions
#
# Then replace X.Y.Z below with the desired chart version:
helm show values --version X.Y.Z oci://ghcr.io/kata-containers/kata-deploy-charts/kata-deploy
shims¶
Each shims entry selects a runtime configuration exposed as a Kubernetes
RuntimeClass. See the kata-deploy RuntimeClass guide
for the purpose and prerequisites of every built-in class before selecting
which configurations to enable.
Kata ships a number of pre-built hypervisor artifacts, and the chart creates one
RuntimeClass per enabled shim. Every shim values.yaml lists is enabled by
default, and the file lists exactly those shims that run on an ordinary node —
they read images through whatever snapshotter containerd already uses, and ask
nothing of the host beyond hardware virtualization:
qemuandqemu-runtime-rs— QEMU (amd64, arm64, s390x, ppc64le)clhandclh-runtime-rs— Cloud Hypervisor (amd64)clh-azure,clh-azure-runtime-rsandopenvmm-azure-runtime-rs— Microsoft Hypervisor (mshv) hosts (amd64)dragonball— Dragonball (amd64)
So a default helm install gives you a set of RuntimeClasses that can all
actually start a pod on a node of a supported architecture.
RuntimeClasses do not schedule by architecture
A RuntimeClass is cluster-wide, and the scheduling constraint the chart puts
on it does not include the node's architecture. On a mixed-architecture
cluster an amd64-only class such as kata-clh or kata-dragonball is still
created, and a pod using it can be scheduled onto an arm64, s390x or ppc64le
node, where that handler was never installed. On such a cluster, add the
architecture to the class itself through the shim's
runtimeClass.nodeSelector, for example
--set-string shims.clh.runtimeClass.nodeSelector.kubernetes\.io/arch=amd64.
The hardware- and snapshotter-specific shims are not in values.yaml
Confidential computing (SNP, TDX, IBM SE), NVIDIA, Firecracker and peer-pod
shims each come with a prerequisite the chart cannot satisfy on its own: a
particular containerd snapshotter (nydus, erofs, devmapper), a specific CPU, a
GPU, or a cloud provider running elsewhere. Enabled by default they would
create RuntimeClasses that look usable and fail the first pod scheduled onto
them, so each lives in a values file you opt into with -f, together with the
snapshotter setup and CRI configuration it needs.
Pick the one that matches your nodes from Examples below —
try-kata-tee.values.yaml,
try-kata-nvidia-gpu.values.yaml,
try-kata-nvidia-cpu.values.yaml,
try-kata-fc.values.yaml or
try-kata-remote.values.yaml.
You may selectively enable or disable specific shims. For example:
Shims can also have configuration options specific to them:
qemu-runtime-rs:
enabled: ~
supportedArches:
- amd64
dropIn: |
[agent.kata]
dial_timeout = 999
allowedHypervisorAnnotations: []
containerd:
snapshotter: ""
runtimeClass:
nodeSelector:
example.io/feature: "true"
The optional shims.<shim>.dropIn field lets you add a custom Kata drop-in for a
default (non-custom) runtime. kata-deploy writes it as
config.d/50-user-overrides.toml for that shim.
enabled: true alone cannot bring back a shim that is not in values.yaml
A shim is only considered for an architecture that its own supportedArches
lists, and a shim block that does not set the field matches none — so
--set shims.qemu-tdx.enabled=true on its own would install nothing, which the
chart rejects rather than leaving you a RuntimeClass no pod can run on. Use the
profile that carries the shim, or copy its whole block out of that profile,
which is also where its snapshotter and guest-pull settings live.
Containerd pod annotations¶
kata-deploy builds each containerd runtime's pod_annotations allowlist from
that runtime's effective Kata configuration. Only
io.katacontainers.config.hypervisor.* annotations whose names occur in the
runtime's enable_annotations list are forwarded. This includes entries
enabled with shims.<shim>.allowedHypervisorAnnotations or a Kata
configuration drop-in. Agent, runtime, and container-resource annotations are
not forwarded unless listed below.
Additional containerd annotation patterns can be enabled globally or for one shim:
containerd:
extraPodAnnotations:
- sgx.intel.com/epc
shims:
qemu:
containerd:
extraPodAnnotations:
- example.com/kata-*
Patterns use containerd's matching syntax.
Warning
containerd.extraPodAnnotations: ["io.katacontainers.*"] restores the former,
unrestricted Kata annotation forwarding. That is a security risk: any Kata
annotation reaches the runtime.
With env.multiInstallSuffix, each installation derives the allowlists from
its own installed Kata configurations and applies them only to its own suffixed
runtime handlers.
It's best to reference the default values.yaml file above for more details.
NVIDIA guest settings¶
The NVIDIA GPU images boot NVRC as their init
process, which brings the NVIDIA stack up before the Kata agent starts. The
shims.<shim>.nvrc block configures it. Every key becomes an nvrc.* guest
kernel parameter, so it applies to all sandboxes on that shim and takes effect
when a sandbox boots. The GPU shims are not in values.yaml, so the block lives
in the profile that carries them:
enableDCGM runs nv-hostengine and dcgm-exporter inside the guest. The
exporter serves GPU metrics on port 9400 of the sandbox, which is the pod IP, so
anything that can reach the pod can scrape /metrics without a sidecar:
It is off by default because it costs guest memory and an extra process in every sandbox on the shim. Enable it per shim, on the shims whose workloads you want to observe.
Note
The block is only meaningful on the qemu-nvidia-gpu* shims, and kata-deploy
refuses to install if it finds it on any other shim. DCGM itself ships in the
NVIDIA GPU guest images — with composable images it arrives in the GPU
extension — so a shim using a guest image without it will not serve metrics.
Under the hood kata-deploy appends nvrc.dcgm=on to the shim's kernel command
line, in the same config.d/30-kernel-params.toml drop-in that carries the proxy
and debug settings.
defaultShim¶
defaultShim selects, per architecture, which shim the auto-created default
RuntimeClass (runtimeClasses.createDefault) resolves to:
defaultShim:
amd64: qemu-runtime-rs
arm64: qemu-runtime-rs
s390x: qemu-runtime-rs
ppc64le: qemu-runtime-rs
Since the Kata Containers 4.0 release, the default is the Rust runtime
(runtime-rs, qemu-runtime-rs) on every architecture that ships a
runtime-rs build (x86_64, aarch64, s390x and ppc64le). The Go runtime remains selectable
(e.g. via the kata-qemu RuntimeClass) but is
deprecated.
See the config migration guide
for per-option differences when migrating.
Custom Runtimes¶
Kata allows you to create custom runtime configurations. This is done by overlaying one of the pre-existing runtime configs with user-provided configs. For example, we can use the qemu-nvidia-gpu as a base config and overlay our own parameters to it:
customRuntimes:
enabled: false
runtimes:
my-gpu-runtime:
baseConfig: "qemu-nvidia-gpu" # Required: existing config to use as base
dropIn: | # Optional: overrides via config.d mechanism
[hypervisor.qemu]
default_memory = 1024
default_vcpus = 4
runtimeClass: |
kind: RuntimeClass
apiVersion: node.k8s.io/v1
metadata:
name: kata-my-gpu-runtime
labels:
app.kubernetes.io/managed-by: kata-deploy
handler: kata-my-gpu-runtime
overhead:
podFixed:
memory: "640Mi"
cpu: "500m"
scheduling:
nodeSelector:
katacontainers.io/kata-runtime: "true"
# Optional: CRI-specific configuration
containerd:
snapshotter: "nydus" # Configure containerd snapshotter (nydus, erofs, etc.)
crio:
pullType: "guest-pull" # Configure CRI-O runtime_pull_image = true
Again, view the default values.yaml file for more details.
Drop-In Runtime Configuration¶
The base runtime configuration shipped with Kata Containers can be modified using an
overlay method. This can be done directly on the filesystem using the instructions
found here.
You can also use the customRuntimes.runtimes.[name].dropIn configuration in the helm
chart to achieve the same results.
shims.<shim>.hypervisor.rootless¶
Runs a shim's VMM as a throwaway unprivileged user per sandbox, instead of
root:
Only the runtime-rs QEMU shims and clh-runtime-rs run their VMM
unprivileged, so only they accept it: qemu-runtime-rs and its variants, plus
clh-runtime-rs. Asking for it on any other shim
fails the install. Everything outside the VMM — the shim, virtiofsd, the guest
— is unaffected either way.
Enabling it turns rootless = true on for that shim and provisions the host
device access an unprivileged VMM needs, because neither half is any use without
the other:
| Device | Group | Needed by |
|---|---|---|
/dev/kvm |
kvm |
every VMM |
/dev/sev |
kata-sev |
AMD SEV-SNP, when an SNP shim is rootless |
/dev/uv |
kata-se |
IBM Secure Execution, when qemu-se-runtime-rs is rootless |
Each shim asks only for what it needs, so a node running an SNP shim privileged
alongside a rootless qemu-runtime-rs never gets the kata-sev group.
Each device gets group read/write, recorded as a udev rule under
/etc/udev/rules.d so it outlives a reboot or the device being re-created.
Uninstall removes the rule, as does the next install once no shim asks for
rootless. The TEE devices deliberately do not join kvm, so a
plain rootless sandbox cannot reach the SEV or ultravisor platform interface just
because it needs KVM, and the groups are created empty, so no existing user gains
anything.
A device the node does not have is skipped, and one that already grants unprivileged access is left exactly as the node has it, recorded in the rule only when it already has the rule's group and mode.
Only devices the VMM opens by path are provisioned, which is why
/dev/vhost-vsock and /dev/vhost-net are absent: the shim opens them as root
and passes the descriptors to the VMM, so the VMM user needs no access to them.
Requires udev on the node
Reboot persistence relies on the node running a udev implementation that
reads /etc/udev/rules.d (systemd-udevd or eudev) and keeping /etc
across reboots — true of every supported k8sDistribution. A node using
busybox mdev, or an image that rebuilds /etc at boot such as
Bottlerocket, accepts the file and never acts on it. The devices are still
reconciled, so sandboxes run until the next reboot, but the permissions do
not stick. See
How to run a rootless VMM for what to do
on those hosts.
Requires deploymentMode: job
The DaemonSet does its host work from a single unprivileged container with no
host root, so it cannot create the groups or reconcile the device nodes. The
chart refuses a rootless shim in that mode rather than configuring sandboxes
that could not start. job is the default mode.
Intel TDX needs nothing here when its quote generation service is reached over AF_VSOCK, which is the supported path. A deployment that points the shim at a QGS Unix socket has to grant the VMM user write access to it separately; see How to run a rootless VMM for that and for the full host access contract.
k8sDistribution¶
Different Kubernetes distributions keep containerd's configuration in different places, so the chart needs to be told which one this cluster is. The value picks the host directory that gets mounted into the install:
Any other value means the default location, /etc/containerd/ — so kubeadm and
vanilla are as good as k8s. Set containerd.configDir instead if your
containerd matches none of the presets.
A wrong value stops the install before changing the node
This value picks the directory, while which file to write is worked out on
the node itself, from the CRI runtime actually running there. Declaring k8s on
a k3s cluster therefore describes a perfectly good k3s configuration inside a
directory k3s does not read — a node that never runs a Kata workload, with
nothing to show for it. The install compares the two and stops before writing
anything, naming the value to set. An explicit containerd.configDir overrides
the derivation this check is about, so it does not apply in that case.
The value carries one thing beyond that directory: where the kubelet keeps its root
directory. A pod's ConfigMap, Secret, projected and downward-API volumes are
written under it, and the Go runtime watches that path so later updates to them
reach the running guest; the kubelet's Pod Resources API socket sits under it too,
which both runtimes read to learn the GPUs a pod was allocated before cold-plugging
them. k0s uses /var/lib/k0s/kubelet and microk8s
/var/snap/microk8s/common/var/lib/kubelet; k3s, rke2 and vanilla Kubernetes
leave the kubelet's own /var/lib/kubelet alone and need nothing.
The kubelet is not the CRI runtime
Unlike the containerd directory, this cannot be worked out on the node: a k0s
node running CRI-O or a containerd of its own still keeps its volumes under
/var/lib/k0s. So declare the flavour even when you pin
containerd.configDir — that pin takes only the check above out of the way.
The volume watch is the Go runtime's alone, runtime-rs recognising those volumes
by the shape of their paths wherever the kubelet root is, but the socket is read
by both. Getting either wrong is quiet: volume updates stop arriving, and GPU
cold plug falls back to CDI annotations.
Only default installations are supported
The install finds the node's CRI runtime by probing the systemd units each distribution is known by, and restarts that unit, waits on it for readiness, and hangs the snapshotter's service dependency off it:
| Distribution | Units probed |
|---|---|
| K3s | k3s, k3s-agent |
| RKE2 | rke2-server, rke2-agent |
| k0s | k0scontroller, k0sworker |
| MicroK8s | snap.microk8s.daemon-containerd |
A node running one of these under any other unit name, under another init
system, or with its containerd configuration somewhere other than the
location that distribution installs it in, is not supported. Nothing detects
it, and k8sDistribution cannot make up the difference: it says which
configuration layout to write, never which unit to act on.
K3s is the easiest to get into this state. Its installer accepts
INSTALL_K3S_NAME, INSTALL_K3S_SYSTEMD_DIR and
INSTALL_K3S_TYPE, and can
install under openrc rather than systemd, so leave those unset.
erofs and nydus require a node that has not used them before
kata-deploy writes erofs and nydus settings into containerd's configuration. Those settings — dm-verity mode, the merged-layer layout, the nydus proxy root — have to match what existing layers were built to, or containerd can no longer mount them.
Changing them on a node that has already pulled images under a different erofs or
nydus configuration breaks those layers. Deploy kata-deploy with erofs or nydus
only on nodes where neither snapshotter has been used before, and leave
snapshotter.setup empty on nodes that already have one of them configured.
nodeBinaries¶
Some of what Kata needs on a node is not part of Kata: containerd's EROFS
snapshotter, for instance, needs a mkfs.erofs from erofs-utils 1.8.2 or newer,
which most distributions still do not package. When updating the node's packages is
not on offer, nodeBinaries takes the binaries out of container images instead:
nodeBinaries:
erofs-utils: # (1)!
image: quay.io/kata-containers/erofs-utils:1.9.3
binaries: [mkfs.erofs, dump.erofs, fsck.erofs] # (2)!
pullPolicy: IfNotPresent # (3)!
- Each key names an entry and becomes the name of the container staging it, so it has to be a valid container name — lowercase letters, digits and dashes, beginning and ending with a letter or a digit — and cannot be one of the names kata-deploy's own containers use. The render fails, naming the key, when it is neither.
- Only what is listed here is taken, so an image built on a distribution does not put the rest of its userland on the node. Each one names a single binary, not a path or a pattern, and the render fails on anything else.
- Optional; defaults to the chart's
imagePullPolicy.
Every entry gets a container of its own, which copies the listed binaries into a
pod-local volume and reaches nothing of the node's. One further container then
installs whatever was staged into /usr/local/bin, ahead of /usr/bin in
containerd's PATH, and records the names it installed so that a later run can take
exactly those out again. That container runs the kubectl image, kata-deploy's own
being distroless and having no shell to do this with.
Each image needs a POSIX shell, cp, and every binary the entry lists, statically
built, in one of /usr/local/bin, /usr/local/sbin, /usr/bin, /usr/sbin,
/bin, /sbin or its root. Private images use the chart's imagePullSecrets.
Adding another binary is a values change and nothing else, so this is the way to cover anything else a node turns out to lack.
It will not replace a binary it did not install
A file already in /usr/local/bin under a name an entry claims fails the
install, rather than being replaced, and the same goes for two entries claiming
the same name. Nothing on the node records where a binary in /usr/local/bin
came from, so kata-deploy only ever removes what its own marker file names.
Remove the file, or drop it from nodeBinaries to keep using it.
An install it refuses this way changes nothing: every name is checked before any of them is written or removed, so the node keeps the set it already had.
An uninstall takes the binaries out again, and changing an entry replaces what it installed. Dropping every entry leaves them in place until the release is uninstalled.
Side-by-side installs own separate sets
Each release's marker file is named after its env.multiInstallSuffix, so
installing or uninstalling one leaves the binaries another one installed alone.
Two releases claiming the same name is still a conflict: whichever installs
second finds a file it did not install and fails.
One image per architecture being deployed to
An image with no manifest for a node's architecture stalls that node's install
on the pull, so cover every architecture in the cluster. The erofs-utils image
above is published for amd64 and arm64 only.
This requires deploymentMode: job. The staged pipeline is what puts the binaries
in place before the host check looks for them; the DaemonSet runs the whole install
in one container and has no such ordering. Setting nodeBinaries in daemonset mode
fails the render rather than deploying something that cannot work.
Deployment Modes (DaemonSet vs Job)¶
The chart can install Kata on nodes in one of two ways, selected with the
top-level deploymentMode value.
Both modes install all enabled runtimes and their RuntimeClasses, including the TEE, NVIDIA, Firecracker and remote configurations supplied by profiles, subject to their supported architectures. Choosing a mode does not change the runtime selection; it changes how kata-deploy prepares and manages the nodes.
daemonset: the long-runningkata-deployDaemonSet installs Kata on every matching node and reverts it when the pod is terminated (i.e. on uninstall). This is the historical behavior and is unchanged.job(default): there is no always-on component. A tinyk8s-job-dispatcherJob runs as apost-install/post-upgradehook, enumerates the selected nodes live via the Kubernetes API, and creates one node-pinned installJobper node. Each per-node Job runs the staged install pipeline as orderedinitContainersand then exits:
With selinux.enabled, one more privileged one-shot container runs
ahead of all of them to load the policy the confined stages ask for.
On helm uninstall, a pre-delete dispatcher fans out per-node Jobs that run
the pipeline in reverse (revert-cri -> remove-artifacts). Unlike
the DaemonSet, nothing keeps running on the node after installation
completes, and the dispatcher itself only ever talks to the API server — it
never touches the host (so it ships as a separate, minimal image,
job.dispatcherImage).
The privilege split is explicit, and it is what job mode buys you over the
DaemonSet: the privileged pods hold no API credentials at all. Everything
that needs the API server is done by the dispatcher, which runs fully
unprivileged (runAsNonRoot, all capabilities dropped, no privilege
escalation, read-only root filesystem, RuntimeDefault seccomp), never touches
the host, and can be confined to nodes you trust.
job is the default; ask for daemonset explicitly to keep the historical
behavior:
The main tradeoffs are:
job |
daemonset |
|
|---|---|---|
| After installation | Per-node Jobs exit, freeing their pod resources; privileged host work carries no API token. | A kata-deploy pod and its API token remain on every selected node for the life of the release. |
| New matching nodes | Require a Helm upgrade or scheduled reconciliation. | The DaemonSet automatically places an installer pod on each new matching node. |
| Additional node preparation | The staged pipeline can install host binaries through nodeBinaries and provision rootless VMM access. |
Install required host binaries yourself before deploying; nodeBinaries and the chart's rootless option require Job mode. |
job mode installs Kata on the nodes in the cluster when the release is applied
The job deployment installs Kata on the nodes that are part of the cluster
when helm install or helm upgrade runs. A node that joins the cluster
afterwards, whether it replaces an existing node or is added by scaling up,
needs special handling to get Kata installed: nothing installs it until the
release is applied again, or a scheduled reconcile
picks it up. Until then it does not carry the katacontainers.io/kata-runtime
label, so pods asking for a Kata RuntimeClass are not scheduled onto it. See
Adding nodes in job mode.
daemonset mode has no such gap: the DaemonSet places an installer pod on
every matching node as it joins. The price is in permissions: that pod is
privileged, carries the kata-deploy ServiceAccount token, which can patch
any node in the cluster, and stays on every node Kata runs on for the life
of the release. Root on any of those nodes can read that token. In job
mode the privileged pods carry no token and exit once the node is installed;
see Where the credentials live.
The runtime prerequisites still apply in either mode: selecting a profile does not provide missing hardware, firmware, GPU passthrough setup or external services. See the RuntimeClass guide for the requirements of each profile.
Upgrading a release installed before job became the default
deploymentMode is immutable for the life of a release,
so an existing daemonset release does not silently move to per-node Jobs:
the upgrade is refused instead. Keep deploymentMode: daemonset in your
values to stay where you are, or helm uninstall first to adopt job.
Where the credentials live¶
The pods that run on your nodes hold no API credentials at all: the per-node Jobs
are rendered with automountServiceAccountToken: false and no ServiceAccount of
their own. This matters because root on a node can read the ServiceAccount token
of any pod running there.
| runs where | privileged on the host | API rights | |
|---|---|---|---|
| dispatcher (install, uninstall, and each scheduled reconcile) | one pod, only where you let it schedule (Where the dispatcher runs) | no | nodes: list, get, patch; jobs: create, get, list, delete and pods: list in the release namespace; events: create in default, which is the only namespace an Event about a Node can live in; nodes/proxy: get only when guest pull or image conversion is configured; pods: get, cronjobs: get, list, watch, delete and watch added to jobs only with job.reconcile enabled, because the uninstall hook waits out the deletions it makes and a waited delete is served from an informer |
| per-node Jobs | every node Kata is installed on | yes | none — no token is mounted |
post-delete hook (uninstall only) |
one pod, wherever it schedules | no | delete on ClusterRoles, ClusterRoleBindings, Roles, RoleBindings and ServiceAccounts |
verification Job (only if verification.pod is set) |
one pod, wherever it schedules | no | pods and pod logs (create, delete, get, list, watch), nodes/events/daemonsets/jobs: get, list |
The last two rows are the exceptions worth knowing about, and both exist in either
mode. The post-delete hook deletes the RBAC the release keeps, so its own rights
have to outlive everything else; the verification Job runs the pod you gave it and
reports what happened. Both are short-lived — one runs during helm uninstall, the
other after helm install/helm upgrade — but neither is pinned, so for those few
seconds their tokens can be on any schedulable node. The verification Job is opt-in:
leave verification.pod unset and it does not exist.
Three things need the Kubernetes API, so none of them happen inside a per-node Job:
-
Labelling the node, and everything that gates it. The dispatcher claims a node with
katacontainers.io/kata-runtime=falsebefore its Job starts. It promotes the label totrueonly once three things hold: that Job succeeded, the node reportsReadyagain (job.waitNodeReadySeconds), and the node's own.status.runtimeHandlersconfirms its CRI runtime is serving the handlers this release installed. Only then does it lift the configuredstartupTaints. A pod on the node could not offer any of that. The label cannot appear on a node whose pipeline failed half way, nor on one whose runtime quietly ignored the configuration that was written. It survives a kubelet that re-registers after the runtime restart and drops it, because the dispatcher re-applies it until it holds. On uninstall the value drops tofalse, which stops workloads being scheduled, before that node's Job starts taking the node apart — and the label itself goes only once that Job has succeeded.In
jobmode the claim is mandatory: the dispatcher refuses to start a host-mutating Job unless both labels were written atomically. Otherwise a half-failed install could modify a host that the default uninstall selector cannot discover. Indaemonsetmode the claim stays best-effort.The handler check has one limit worth knowing:
.status.runtimeHandlersis populated by kubelet from Kubernetes 1.30 on. A node that does not report the field at all cannot answer, and is labelled on the strength of its Job succeeding. A node that reports the field without any of this release's handlers in it fails, and an empty list counts the same way: that node has answered, it just has nothing this release installed. One handler is enough to pass, because the release names the handlers for every architecture it can install and a node only serves its own; the ones it does not serve are logged. A release that registers no handler names — every shim disabled and no custom runtime — has nothing to ask about, and skips the check. - The node's container runtime version. It is the one fact the install cannot work out on the host itself, so the dispatcher reads it from theNodeobjects it has already listed and passes it down as an environment variable. Everything else the install needs to know about its node, the Kubernetes flavour included, it gets by asking the host. - The cluster-scoped objects: theRuntimeClasses and theNodeFeatureRuleadvertising TEE key counts are rendered by the chart (see TEE key advertisement), so nothing on a node needs write access to them.helm uninstallremoves them for free.
How this compares to daemonset mode
Installing Kata means writing to the host and restarting the CRI runtime, so
something privileged has to run on each node in either mode. Two things differ.
The DaemonSet pod carries the kata-deploy ServiceAccount, which can patch any
node in the cluster and read any node's kubelet configuration, and it stays on
the node — token and all — for the life of the release. A per-node Job carries
no token and exits when it is done.
So in job mode a compromised worker node yields no cluster credentials, unless
the post-delete hook or the verification Job happens to be running there at that
moment. The component that does hold the node-patching token can be kept on the
control plane. In daemonset mode that token is, by construction, on every node
Kata runs on, for the life of the release.
Switching an existing daemonset release to job mode
The privileged kata-deploy ServiceAccount, ClusterRole and ClusterRoleBinding
are annotated helm.sh/resource-policy: keep, so that a DaemonSet pod being
terminated still has the API access its own cleanup needs. keep also means
Helm leaves them behind on an upgrade that no longer renders them — which is
exactly what switching to job mode does. The release stops using them, but the
credential that can patch any node in the cluster is still there.
They are cluster-scoped and un-suffixed, so a release in another namespace may
share them; the chart cannot safely delete them for you. Once no daemonset
release is left, remove them by hand:
kubectl delete clusterrolebinding kata-deploy-rb
kubectl delete clusterrole kata-deploy-role
kubectl delete serviceaccount kata-deploy-sa -n <release namespace>
With env.multiInstallSuffix set, the names carry that suffix.
Why a dispatcher instead of Helm-rendered per-node Jobs¶
Rendering one Job per node directly in the chart does not scale: Helm stores the
whole rendered release in a single (~1 MiB) Secret and runs hook resources
sequentially, so large fleets blow the size limit and/or take far too long. A
single Indexed Job or a JobSet removes those limits but cannot guarantee
one pod per node once parallelism < node-count: Kubernetes' topology-spread
and affinity scheduling ignore completed pods, so as paced pods finish, later
pods pile onto a subset of nodes and leave others uncovered.
The dispatcher sidesteps both problems: the Helm release stays O(1) (just the
dispatcher + a constant-size ConfigMap holding the per-node Job templates), node
membership is resolved at run time, and the dispatcher itself paces the rollout
(at most job.parallelism per-node Jobs in flight) while guaranteeing one Job
per node. Per-node Jobs are garbage-collected via an ownerReference to the
dispatcher and job.ttlSecondsAfterFinished.
Where the dispatcher runs¶
The dispatcher holds the one token in job mode that can enumerate every node
in the cluster and create the privileged per-node Jobs, and root on whatever
node it lands on can read it. Confine it to nodes you trust — typically the
control plane — with job.dispatcherNodeSelector and
job.dispatcherTolerations:
job:
dispatcherNodeSelector:
node-role.kubernetes.io/control-plane: ""
dispatcherTolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
The toleration is needed because control-plane nodes are normally tainted. These
settings say nothing about where Kata is installed — that remains the top-level
nodeSelector / affinity / tolerations, and the per-node Jobs do not inherit
the dispatcher's placement. When job.dispatcherTolerations is empty it falls
back to the top-level tolerations, so the dispatcher stays schedulable on a
cluster whose every node is tainted.
What pinning does and does not cover
Pinning the dispatcher keeps the release's one cluster-wide, long-lived identity — the one that can enumerate and patch every node — off your workers. The privileged pods that do run on every node mount no token at all.
It does not pin the two short-lived hooks: the post-delete RBAC cleanup and,
if you set verification.pod, the verification Job (see
Where the credentials live). Both can land on
any schedulable node for as long as they run.
That is still a boundary daemonset mode cannot draw, since the pod holding
its node-patching token has to run on every node by definition.
Adding nodes in job mode¶
The dispatcher only runs on helm install / helm upgrade / helm uninstall.
There is no dispatcher watching for new nodes, so when you add nodes later,
re-run helm upgrade; the dispatcher re-enumerates the cluster and installs the
new nodes:
Each per-node stage is idempotent (it skips when already applied), so the upgrade only does real work on the newly added nodes.
Let your cluster lifecycle tooling re-apply the release
Prefer Flux, Argo CD, or another cluster lifecycle layer to re-apply the release when nodes change, for example as part of the same change that grows a node pool or rolls nodes onto a new image. kata-deploy's installation steps are idempotent, so rerunning them is expected and safe: nodes that are already installed finish quickly with nothing to do, and only the new ones get real work.
Doing it on a schedule instead¶
On a fleet that grows on its own — an autoscaler, or nodes joining faster than
anybody notices — the upgrade above needs somebody to run it, and until they do the
new node adds no Kata capacity. Nothing lands on it wrongly (every RuntimeClass
selects katacontainers.io/kata-runtime, which is exactly what the install has not
written there yet), so a pod asking for a Kata runtime class stays Pending while a
node that could have run it takes non-Kata work instead — and if it was the pending
pod that grew the fleet in the first place, the next node changes nothing either.
job.reconcile turns that upgrade into a CronJob running the same dispatcher,
against the same selectors and the same per-node templates:
A tick installs the nodes that have nothing to show yet and leaves the rest of the fleet untouched, so a tick over a settled fleet lists nodes, finds nothing to do, and exits without creating a single pod. It also stands aside when a release rollout is in flight, rather than deleting the per-node Jobs that rollout is waiting on. Failures are visible where you would look for them: the CronJob keeps its last three failed Jobs, and a tick that failed on a node fails as a Job rather than being retried immediately — the next tick is the retry.
It only ever adds nodes
A node that has dropped out of the selection is left alone, where helm
upgrade would run the cleanup pipeline on it. That asymmetry is deliberate: a
node falling out is usually a label gone wrong somewhere, and taking a host
apart on a timer with nobody watching is the worse of the two outcomes. Removals
stay with the upgrade you run yourself.
A joining node waits for the next tick
The schedule sets how long a new node goes without Kata. With
*/15 * * * *, a node can wait up to fifteen minutes, plus its own install,
before it is labelled and Kata pods are scheduled onto it, and pods asking for
a Kata RuntimeClass stay Pending in the meantime. Choose the interval based on
how long your workloads can wait. A tick over a settled fleet only lists the
nodes and exits, so a shorter interval costs little.
It covers new nodes, not changes to nodes it has already installed
A tick decides what to do from what the install recorded on each Node object,
and it skips nodes that already show a finished install. A node replaced in
place is one of those: rebuilt, but re-registering under the same Node object
and keeping its labels, it looks installed to the tick even though the host
has nothing on it. Changing the release's values likewise needs a
helm upgrade. Re-applying the release from your lifecycle tooling covers both
cases, because every per-node stage then checks the host itself. Treat the
schedule as a safety net for nodes joining between those runs, not as a
replacement for them.
Off by default
Enabling this stands up a recurring, privileged rollout, so it is opt-in. It
also needs a job.dispatcherImage of 0.2.0 or newer; older dispatchers do
not understand the flags a scheduled run needs and every tick fails at startup.
helm uninstall takes the schedule away before it starts reverting nodes (a
pre-delete hook that runs ahead of the uninstall dispatcher and removes the
CronJob together with anything it still has in flight), so a tick cannot reinstall
a node the uninstall has just cleaned.
Recovering from a failed or deleted dispatcher¶
The dispatcher runs as a blocking post-install/post-upgrade hook Job with
restartPolicy: Never and backoffLimit: 0, so if its pod is evicted, drained,
or deleted mid-rollout the Job is marked failed and is not restarted
automatically — helm install/helm upgrade surfaces the failure rather than
leaving you silently half-installed.
What survives the dispatcher dying:
- Per-node Jobs already created keep running. They are independent,
nodeName-pinned Jobs, not children of the dispatcher pod, so installs that were already dispatched run to completion. Only nodes still queued (never dispatched) are skipped, so at worst you get partial coverage — never a half-mutated host, because each stage is idempotent. - Those nodes are not labelled, though. Labelling is the dispatcher's job (it
is what lets the per-node Jobs run without a token), so a node whose install
finished after the dispatcher died is left installed but not advertised, and no
Kata workload is scheduled there. It still carries
katacontainers.io/kata-runtime=falsefrom being claimed, sohelm uninstallreaches it, and the re-run below labels it. - Those per-node Jobs carry a (non-controller)
ownerReferenceto the dispatcher Job, so they survive pod deletion but are garbage-collected once the dispatcher Job itself is removed or itsjob.ttlSecondsAfterFinishedelapses. Keep that TTL comfortably larger than a single node's install so in-flight Jobs are not reaped early.
Recovery is the same one-liner as adding nodes — re-run helm upgrade:
The before-hook-creation delete policy first removes the stale dispatcher Job
(cascading away any leftover per-node Jobs); the fresh dispatcher then
re-enumerates nodes live, recreates the per-node Jobs (adopting any that still
exist rather than duplicating them), and because every stage is idempotent the
already-installed nodes are fast no-ops. Coverage converges on the re-run.
Finding out why a node failed¶
A rollout that fails says which nodes failed and what stopped each of them in three places, as long as the dispatcher lived long enough to say so. Helm prints its summary directly, and the node Event and annotation remain available after the command exits.
The chart asks Helm to print the dispatcher's log when the hook fails, and the dispatcher ends that log with one line per failed node, so the command that failed is also the one that explains it:
level=INFO msg="[2026-09-03T18:22:11Z INFO ] fanning out 2 per-node Job(s) with parallelism 1\n...
Error: 2 node(s) failed:\n worker-2: its job kata-deploy-install-worker-2 failed:
load-kernel-modules exited 1: this node has no usable virtualization backend: neither
/dev/kvm nor /dev/mshv is present [BackoffLimitExceeded: Job has reached the specified
backoff limit]\n worker-5: its job kata-deploy-install-worker-5 failed: cri never
started: ImagePullBackOff: Back-off pulling image \"kata-deploy:bad-tag\"\n"
Error: INSTALLATION FAILED: failed post-install: resource Job/kube-system/kata-deploy-install-dispatcher not ready. status: Failed, message: Job Failed. failed: 1/1
Helm prints hook logs as a single escaped line
Helm writes hook output through its structured logger, so the whole log
arrives as one level=INFO msg="..." record with newlines escaped to \n.
Helm's own closing Error: line says only that the hook Job failed — the
reason is inside that record. The dispatcher keeps its output short so the
record stays readable, and sed gives it back its newlines:
Nothing is lost either way: the same reason is on the node, in the two forms below, after the command has scrolled away.
The same reason is recorded as an Event against the Node, so it turns up in
kubectl describe node and in whatever already collects events from the
cluster:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning JobFailed 2m kata-deploy-job-dispatcher its job kata-deploy-install-worker-2
failed: load-kernel-modules exited 1: this node has no
usable virtualization backend
Listing them directly needs the default namespace rather than the release's:
a Node has no namespace, and that is the only one the API server accepts an
Event about a cluster-scoped object in — the same place the kubelet's own
node events go.
What makes any of that possible is that each stage writes why it is giving up to
/dev/termination-log, which lands in the pod's status rather than only in its
log. The dispatcher reads it the moment a Job is judged, and passes it on. The
dispatcher's own pod keeps the same summary in its status as a fallback for the
three places above: a dispatcher killed before it could report anything is
explained by kubectl describe pod alone.
A node that is stuck, rather than failed
Nothing marks a Job whose pod cannot be scheduled or cannot pull its image:
it stays running until job.activeDeadlineSeconds expires, which is an
hour on the defaults. Two minutes in, and every five after that, the
dispatcher says what the wait is for — as a log line and as a
Warning/JobPending Event on the node.
Where the log still helps
The summary carries one line per node. The per-node Job's own log carries
everything the stage printed on the way there, and job.ttlSecondsAfterFinished
(10 minutes by default) is how long you have to read it. Raise it on a cluster
where nobody is watching the rollout live.
Choosing which nodes get a Job¶
Node selection is not configured per deployment mode. Both modes read the
same nodeSelector / affinity and
tolerations, so one values file installs Kata on the same nodes
whichever mode you deploy with.
In daemonset mode the scheduler applies those rules when it places the
kata-deploy pods. In job mode there is no pod to place — the dispatcher pins
each per-node Job to a node by name — so the dispatcher applies the same rules
itself while enumerating nodes:
nodeSelectorandaffinity.nodeAffinityare compiled into node queries, which the dispatcher resolves live against the API server. EachnodeSelectorTermbecomes one query and their results are combined with OR, matching Kubernetes' own semantics; thenodeSelectorlabels are ANDed into every query, which is how Kubernetes combines the two.- Nodes carrying a taint your
tolerationsdo not cover are then dropped, exactly as the scheduler would refuse to place a DaemonSet pod there. job.nodesoverrides all of the above with an explicit list of node names, used verbatim.
An empty nodeSelector therefore does not mean "every node": step 2 still
applies, and that is what keeps Kata off control-plane nodes by default without
any label filtering.
On upgrade, job mode also compares that desired set with the nodes carrying
this installation's marker. Nodes in owned - desired run the cleanup pipeline
before the desired nodes are installed, so narrowing or changing a selector does
not leave the old nodes labelled and configured forever.
# Install on nodes carrying a specific label:
nodeSelector:
kata-containers: "enabled"
# Richer selection - Linux workers that are either GPU- or SNP-capable
# (a node matching either term is selected):
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- { key: kubernetes.io/os, operator: In, values: ["linux"] }
- { key: nvidia.com/gpu.present, operator: In, values: ["true"] }
- matchExpressions:
- { key: kubernetes.io/os, operator: In, values: ["linux"] }
- { key: feature.node.kubernetes.io/cpu-cpuid.SEV_SNP, operator: In, values: ["true"] }
# Pin to explicit nodes (job mode only; tainted nodes included, since naming a
# node is unambiguous):
job:
nodes: ["worker-1", "worker-2"]
The job.* node-selection keys have been removed
Earlier releases selected nodes with job.nodeSelector,
job.nodeSelectorExpressions and job.nodeAffinity, separately from the
DaemonSet's own knobs. Having two sources of truth meant a values file that
pinned Kata to a few nodes in daemonset mode could silently install it
everywhere in job mode. Rendering now fails with a migration hint if any
of those keys is still set: move the rule to the top-level nodeSelector or
affinity.nodeAffinity, and keep job.nodes for explicit node names.
Not every affinity rule can be expressed as a node query
Job mode resolves nodes with a label-selector LIST against the API server,
so matchFields and the Gt/Lt operators cannot be represented and are
rejected at render time — use job.nodes to target nodes by name.
preferredDuringSchedulingIgnoredDuringExecution is accepted but ignored: a
preference never restricts where a DaemonSet may land either, so honouring
only the required terms keeps both modes in agreement.
Use job.parallelism to pace the rollout — it caps how many per-node Jobs run
concurrently (e.g. to limit how many CRI runtimes restart at once on a big
fleet). It is effectively capped at the number of targeted nodes.
Nodes that become eligible late¶
A DaemonSet is level-triggered: it installs a node whenever that node becomes
eligible, however long that takes. The dispatcher instead resolves nodes once,
at install time, and eligibility frequently arrives seconds later — the
feature.node.kubernetes.io/* labels that affinity.nodeAffinity matches are
written by node-feature-discovery, which the chart can install in the very same
release, and a freshly joined node clears its start-up taints only once it
settles.
So the install dispatcher keeps re-resolving for job.waitForNodesSeconds
(default 120) while nothing is eligible yet, and fails if the wait expires
with no eligible node. Failing is deliberate: in job mode an empty selection
is permanent — nothing installs the node later — so a silent success would leave
the fleet without Kata and nothing to point at.
Finding one eligible node is not the end of the wait either. Eligibility arrives
per node, so the dispatcher waits until the set stays unchanged for
job.nodeSettleSeconds (default 15) or the overall wait expires. A single
unchanged poll is not enough: the next node may be labelled a second later.
job:
# Give a slow-labelling cluster longer (see the timeout warning below)...
waitForNodesSeconds: 300
# Require a 30-second quiet period after the last newly eligible node.
nodeSettleSeconds: 30
# ...or resolve once and treat "no eligible node" as a no-op, the way a
# DaemonSet with no matching node quietly installs nothing.
# waitForNodesSeconds: 0
Raise helm --timeout alongside it
The wait is spent inside the dispatcher hook, and Helm bounds that hook by
the release timeout (--timeout, 5 minutes by default) — the same budget
that has to cover extracting the artifacts and restarting the CRI runtime on
every node. Raising waitForNodesSeconds on its own only trades a
dispatcher error naming the selectors it tried for Helm's opaque
timed out waiting for the condition, which leaves the release failed and
the dispatcher Job orphaned.
The uninstall dispatcher never waits: it has to run to completion on a release that may never have labeled a node.
Bounding a node that never finishes¶
The dispatcher waits for every node it dispatched to, so one host wedged on something it cannot finish — a CRI restart that never returns, a hung mount — would otherwise hold up the whole rollout until Helm's own timeout fired. Two knobs bound that:
job:
# Kubernetes fails a per-node Job that runs longer than this, and the
# dispatcher reports that node as failed and carries on with the others.
activeDeadlineSeconds: 3600
# How long a finished per-node Job is kept. The dispatcher reads each Job's
# result by polling it, so a Job collected before its next poll leaves its node
# with no result at all - which reads as a failure. The chart refuses anything
# below 60.
ttlSecondsAfterFinished: 600
Installing on control-plane nodes¶
Control-plane nodes are normally tainted, which is what keeps Kata off them in both modes. Reaching them takes a matching toleration — and, if you want only those nodes, a selector as well:
Single-node clusters usually need nothing
Single-node distributions (k3s, k0s --single) do not taint their node, and
kubeadm clusters used for local testing are typically untainted with
kubectl taint nodes --all node-role.kubernetes.io/control-plane-. In both
cases the node is selected by default. On older clusters the label and taint
key may be node-role.kubernetes.io/master instead.
In job mode, a node skipped for a taint it does not tolerate is named in the
dispatcher log along with the taint responsible, so an unexpectedly small rollout
is easy to explain:
skipping node cp-1: it carries the taint node-role.kubernetes.io/control-plane:NoSchedule,
which this install does not tolerate (a DaemonSet would not have been scheduled there either)
If every selected node is skipped this way the dispatcher fails rather than reporting success with nothing done, and its error quotes the toleration to add.
Choosing which nodes are cleaned up on uninstall¶
Because the cleanup dispatcher resolves nodes live when it runs at
helm uninstall (the dispatcher does the lookup, not Helm at render time), the
node set is not frozen into the stored release. This means the default
cleanup selector can simply be "nodes carrying the
katacontainers.io/kata-runtime label" — i.e. exactly the nodes the install
touched, regardless of how the install selector has drifted since.
The selector matches the label's key, not the value "true", and that is
deliberate. A node is claimed with katacontainers.io/kata-runtime=false before
anything is written to it — by the dispatcher in job mode, before it even creates
that node's Job — and only promoted to "true" once the node is kata-capable.
Uninstall therefore also reaches nodes where the install failed part way through —
the ones most likely to have artifacts or CRI configuration left on them. Nothing
schedules onto a claimed node in the meantime, since the RuntimeClasses select the
exact value "true". For the same reason an uninstall demotes the label to
"false" rather than removing it, and removes it only once that node's cleanup Job
has succeeded: a cleanup that fails is still a node the next helm uninstall can
find. Where several installations share a node, neither the demotion nor the removal
happens while another one is still holding it — see
How installations keep out of each other's way on a node.
In job mode claiming is a precondition for dispatch: if the labels cannot be
written, that node fails before any host mutation begins. This keeps the default
cleanup selector complete even when the API server rejects or times out a patch.
Cleanup pods also tolerate every taint, unlike the install's, which carry the
tolerations you configured. Where an install may run is your decision; where an
uninstall must run is decided by what is already on the node. A NoExecute taint
added since the install would otherwise evict the cleanup pod from a node that has
Kata on it, and nothing would come back for it.
Override it under job.cleanup (cleanup.nodes, then cleanup.nodeSelector
ANDed with cleanup.nodeAffinity, else all nodes):
# Only uninstall from specific nodes:
job:
cleanup:
nodes: ["worker-1"]
# Use an explicit selector instead of the kata-runtime label default:
job:
cleanup:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- { key: node-role.kubernetes.io/control-plane, operator: DoesNotExist }
Uninstall is deliberately independent of install
Cleanup is not narrowed by the top-level nodeSelector/affinity, and
tainted nodes are cleaned rather than skipped. It has to be able to revert
every node the install ever touched, even one whose labels or taints have
changed since — so it targets what was installed, not what is selected now.
See the default values.yaml for the remaining job.* options
(e.g. dispatcherImage, parallelism, ttlSecondsAfterFinished,
backoffLimit).
SELinux¶
The install stages run unprivileged, which on an SELinux-enforcing node means they
run as container_t — a domain that is denied the host work they exist to do.
Writing /opt/kata, writing the CRI drop-in and driving systemd are all refused,
and the install fails with AVC denials in the node's audit log.
selinux.enabled fixes that without giving the privilege back:
This loads a Kata-owned policy module on each node and runs each stage in a least-privilege domain of its own. It works in both deployment modes.
| domain | used by | may |
|---|---|---|
kata_deploy_check_t |
host-check |
read only, plus ask systemd whether the CRI is running |
kata_deploy_artifacts_t |
artifacts, remove-artifacts |
write /opt/kata, the snapshotter unit, /etc/modules-load.d |
kata_deploy_cri_t |
cri, revert-cri |
write the CRI configuration, manage the snapshotter unit, restart the CRI |
kata_deploy_node_binaries_t |
node-binaries-install, node-binaries-remove |
write /usr/local/bin |
kata_deploy_t |
the DaemonSet's single container | all of the above except /usr/local/bin |
job mode runs each stage in its own container, so each gets the narrowest domain
its own work needs: artifacts cannot reach the CRI configuration, and neither it
nor cri can touch /usr/local/bin. cri does reach /opt/kata, because
revert-cri takes the nydus binaries back out of it. daemonset mode runs the
whole install in one container, so it needs the union — minus
kata_deploy_node_binaries_t, since nodeBinaries requires
job mode.
The containers that stage nodeBinaries get no domain of their
own. They are the only ones running images Kata does not build, and they write
nothing but a pod-local emptyDir, so plain container_t is both enough for them
and the right blast radius.
Enabling it on a cluster that is not entirely SELinux
Nodes with SELinux disabled are unaffected: the policy stage exits without doing anything, and the runtime ignores the requested domains. So the value is safe to set once for a mixed cluster rather than per node pool.
What it needs, and what it changes on the node¶
Loading a policy module is itself privileged, so the chart adds one privileged
one-shot container, selinux-policy, that runs the node's own semodule. It is
first in the pipeline and exits before any confined stage starts, which is what buys
every host-working stage after it staying unprivileged. It runs in the cleanup
pipeline too, because those stages are confined as well and a node whose module went
missing could otherwise never be uninstalled.
It is not the only privileged container in job mode: load-kernel-modules was
already one, and stays one. In daemonset mode it is the only one.
The nodes need policycoreutils (for semodule) installed, unless their policy
already defines the domains — the stage checks before it complains, so a node whose
policy is managed elsewhere needs nothing from Kata. The module is shipped as source
CIL and compiled by the node's own secilc at load time, so one artifact works
across selinux-policy versions and distributions.
Nothing is relabelled. The module defines no filecon rules, so /opt/kata keeps
the labels it has today (usr_t, inherited from /opt) and the Kata runtime sees
exactly what it saw before. The scope is the installer only.
Moving the install directory or the CRI configuration
env.installationPrefix and containerd.configDir can put either directory
anywhere on the node, where it carries a label the shipped module knows nothing
about. So the stage reads the label each one actually has and, when it is not one
the module already grants, loads a one-line module of its own —
kata-deploy-install-<type>, kata-deploy-cri-config-<type> — adding that type
to the rules. Still no relabelling: the node's labels decide, not Kata.
Several releases on one node¶
The policy store is the node's, not the release's, so every kata-deploy on a node
shares one module. It is installed at a priority carrying the policy's own revision,
and semodule keeps the highest, so an older image installing or uninstalling its
own release cannot take a newer release's rules away.
That priority is also what removal has to name:
The module is left loaded on uninstall
This matches how host kernel modules are already handled: helm uninstall
drops the modules-load.d file but never unloads a module. Remove the modules
listed above by hand once no release on the node needs them.
Off by default, because it mutates the node's policy store — something a cluster
that manages its own SELinux policy may prefer to do itself. The module is at
tools/packaging/kata-deploy/selinux/kata-deploy.cil.
Examples¶
The shims that need a specific containerd snapshotter or specific hardware are
deliberately absent from values.yaml (see shims). Each of them ships
instead as a ready-made values file — a profile — that you pass to helm with
-f/--values, and that brings along the snapshotter setup, CRI settings and
defaultShim its shims need.
All profiles default to job mode and can install their runtimes using either
deployment mode. To use a DaemonSet for a new release, pass
--set deploymentMode=daemonset after the profile's -f argument. Prepare the
required host binaries first (for EROFS, mkfs.erofs from erofs-utils 1.8.2 or
newer), and leave nodeBinaries empty. The optional nodeBinaries block in the
NVIDIA profiles requires Job mode. See nodeBinaries and
Deployment Modes for the preparation and
lifecycle differences.
The profiles are packaged with the chart, so you can get a copy to read and edit without cloning the repository:
helm pull oci://ghcr.io/kata-containers/kata-deploy-charts/kata-deploy \
--version VERSION --untar
ls kata-deploy/try-kata-*.values.yaml
Pick the profile that matches your nodes — don't stack two
A profile is a complete values file, not a fragment: it starts from
shims.disableAll: true, enables its own shims, and sets its own
snapshotter.setup. Passing two of them lets the later -f win key by key,
which leaves a mixture neither profile describes — quite possibly shims mapped
to a snapshotter that was never set up. To serve genuinely different node
pools from one cluster, install one release per pool, each with its own
nodeSelector and env.multiInstallSuffix (see
Multiple Kata installations on the Same Node).
Each is published as a release asset and -f takes a URL, so helm fetches the
file itself. Replace VERSION in both the flag and the URL, or use
/releases/latest/download/<file> for the newest release.
Releases up to and including 4.1.0
Those releases do not carry the presets as assets. Fetch the desired file from
its tag instead, replacing <file> with the preset filename:
https://raw.githubusercontent.com/kata-containers/kata-containers/refs/tags/VERSION/tools/packaging/kata-deploy/helm-chart/kata-deploy/<file>
try-kata-tee.values.yaml¶
Confidential computing: the TEE (Trusted Execution Environment) shims, with the nydus snapshotter and guest pull they need to keep the image off the host.
helm install kata-deploy oci://ghcr.io/kata-containers/kata-deploy-charts/kata-deploy \
--version VERSION \
-f https://github.com/kata-containers/kata-containers/releases/download/VERSION/try-kata-tee.values.yaml
Includes:
qemu-snpandqemu-snp-runtime-rs- AMD SEV-SNP (amd64)qemu-tdxandqemu-tdx-runtime-rs- Intel TDX (amd64)qemu-seandqemu-se-runtime-rs- IBM Secure Execution for Linux (SEL) (s390x)qemu-coco-dev- Confidential Containers development (amd64, s390x)qemu-coco-dev-runtime-rs- Confidential Containers development Rust runtime (amd64, arm64, s390x)
The TEE RuntimeClasses also take part in TEE key advertisement and get NFD-based node selectors when NFD is around, so confidential pods are only scheduled where the hardware is.
try-kata-nvidia-cpu.values.yaml¶
The NVIDIA CPU-only shims: the NVIDIA guest userspace without GPU passthrough.
They run with shared_fs=none, so the profile sets up the erofs snapshotter with
memory-backed rw layers and dm-verity, and installs using the
job deployment mode (no always-on
DaemonSet on the node).
helm install kata-deploy oci://ghcr.io/kata-containers/kata-deploy-charts/kata-deploy \
--version VERSION \
-f https://github.com/kata-containers/kata-containers/releases/download/VERSION/try-kata-nvidia-cpu.values.yaml
Includes:
qemu-nvidia-cpu- NVIDIA base image without GPU passthrough (amd64, arm64)qemu-nvidia-cpu-runtime-rs- NVIDIA base image without GPU passthrough, using the Rust runtime (amd64, arm64)
erofs needs containerd 2.2.0 or newer
The erofs snapshotter is containerd's own, and the profile configures it
through the auto-imported conf.d drop-in mechanism. Both arrived in
containerd 2.2.0, which is the minimum kata-deploy enforces for erofs.
try-kata-nvidia-gpu.values.yaml¶
The NVIDIA GPU shims, including their confidential variants. The profile defaults
to job deployment mode; all six runtimes
can also be installed with daemonset, with the node preparation described
above. It sets up both
snapshotters the shim set needs: erofs for the plain GPU handlers, nydus for the
TEE ones.
helm install kata-deploy oci://ghcr.io/kata-containers/kata-deploy-charts/kata-deploy \
--version VERSION \
-f https://github.com/kata-containers/kata-containers/releases/download/VERSION/try-kata-nvidia-gpu.values.yaml
Includes:
qemu-nvidia-gpuandqemu-nvidia-gpu-runtime-rs- GPU passthrough (amd64)qemu-nvidia-gpu-snpandqemu-nvidia-gpu-snp-runtime-rs- GPU with AMD SEV-SNP (amd64)qemu-nvidia-gpu-tdxandqemu-nvidia-gpu-tdx-runtime-rs- GPU with Intel TDX (amd64)
The RuntimeClasses select on nvidia.com/cc.ready.state, the label the NVIDIA GPU
Operator sets, so a node serves either the plain or the confidential handlers
depending on the mode its GPUs are in. See
NVIDIA GPU passthrough and Kata QEMU
for the full setup.
try-kata-fc.values.yaml¶
The Firecracker shim. Firecracker does not implement filesystem sharing, so images have to reach the guest as block devices — containerd's devmapper snapshotter.
helm install kata-deploy oci://ghcr.io/kata-containers/kata-deploy-charts/kata-deploy \
--version VERSION \
-f https://github.com/kata-containers/kata-containers/releases/download/VERSION/try-kata-fc.values.yaml
Includes:
fc- Firecracker (amd64)
devmapper is the one snapshotter kata-deploy will not set up for you
nydus and erofs it can configure; devmapper needs a thin-pool on the host,
which is a storage decision only you can make. Set it up on every node before
installing this profile — ctr plugins ls | grep devmapper must report ok —
otherwise the kata-fc RuntimeClass exists but every pod using it fails to
start. See
How to use Kata Containers with Firecracker.
try-kata-remote.values.yaml¶
The remote hypervisor shim, better known as peer pods. It starts no VM on the node: sandbox creation is handed to a provider running elsewhere — in practice Confidential Containers' cloud-api-adaptor, which creates the VM in your cloud. Images are pulled inside that pod VM, hence nydus and guest pull.
helm install kata-deploy oci://ghcr.io/kata-containers/kata-deploy-charts/kata-deploy \
--version VERSION \
-f https://github.com/kata-containers/kata-containers/releases/download/VERSION/try-kata-remote.values.yaml
Includes:
remote- remote hypervisor / peer pods (amd64, ppc64le, s390x)
The provider is not part of this chart
This profile installs the shim and its kata-remote RuntimeClass, nothing
more. The provider, its cloud credentials and the pod VM image belong to the
cloud-api-adaptor deployment and have to be in place first, or the
RuntimeClass will exist with nothing behind it.
nodeSelector¶
We can deploy Kata only to specific nodes using nodeSelector
# First, label the nodes where you want kata-containers to be installed
$ kubectl label nodes worker-node-1 kata-containers=enabled
$ kubectl label nodes worker-node-2 kata-containers=enabled
# Then install the chart with `nodeSelector`
$ helm install kata-deploy \
--set nodeSelector.kata-containers="enabled" \
"${CHART}" --version "${VERSION}"
You can also use a values file:
Applies to both deployment modes
nodeSelector selects nodes in daemonset and job mode alike — see
Choosing which nodes get a Job for how job
mode applies it. Note that leaving it empty does not mean "every node": nodes
carrying a taint you do not tolerate are still excluded, which is what keeps
Kata off control-plane nodes.
Combining it with affinity.nodeAffinity
Both may be set at once and are ANDed, exactly as they would be on any pod
spec: a node has to satisfy the nodeSelector labels and match one of the
nodeSelectorTerms. Job mode reproduces this by folding the nodeSelector
equalities into every term, so the two modes select the same nodes.
tolerations¶
Tolerations decide which tainted nodes Kata may be installed on. Selecting a
node is not enough on its own: if the node carries a NoSchedule or NoExecute
taint that your tolerations do not cover, it is excluded — in daemonset mode by
the scheduler, and in job mode by the dispatcher applying the same rule.
That default is what keeps Kata off control-plane nodes, and off nodes your platform has reserved for something else, without you having to exclude them by label.
To install on control-plane nodes, see Installing on control-plane nodes.
Cordoned and pressured nodes are still installed on
Kubernetes gives every DaemonSet pod an implicit set of tolerations so that
node conditions — unschedulable (a cordoned node), plus disk, memory and PID
pressure — never stop a node agent from running. Job mode applies the same
implicit set, so cordoning a node does not make it silently miss the install
in either mode.
podLabels¶
You can add extra labels to the kata-deploy DaemonSet pods. These are applied
in addition to the name: kata-deploy label that the chart uses internally.
The chart ignores a name key in podLabels and always sets the required
selector label itself.
Or via a values file:
podAnnotations¶
You can add annotations to the kata-deploy DaemonSet pods for whatever your
environment needs: Prometheus scrape hints, policy markers, revision metadata,
and so on. The chart does not set any annotations on the kata-deploy DaemonSet
pod template by default, so nothing is reserved or overwritten unless you set
podAnnotations yourself.
$ helm install kata-deploy \
--set-string podAnnotations.prometheus\.io/scrape=false \
"${CHART}" --version "${VERSION}"
Or via a values file:
affinity¶
Use affinity when you need more granular scheduling controls than
nodeSelector alone. nodeSelector only matches exact key/value pairs on a
node; affinity gives you matchExpressions (e.g. In, NotIn) and rules
about other pods on the same node. For example, you might want kata-deploy on
nodes reserved for your platform team but not on nodes that run the GPU
operator.
nodeAffinity selects nodes in both modes; pod affinity only in daemonset mode
affinity.nodeAffinity is the match-expression form of nodeSelector and, like
it, decides which nodes get Kata in both deployment modes. The two can be
combined, and are ANDed. podAffinity/podAntiAffinity describe how to
schedule a pod, so they only apply in daemonset mode; job mode pins each
per-node Job to a node by name and has no scheduling decision to influence. See
Choosing which nodes get a Job for which
nodeAffinity constructs job mode can and cannot express.
# First, label the nodes where kata-deploy should run
$ kubectl label nodes worker-node-1 node.cloud/reserved=platform-team
$ kubectl label nodes worker-node-2 node.cloud/reserved=platform-team
# Then install the chart with affinity
$ helm install kata-deploy -f values.yaml "${CHART}" --version "${VERSION}"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node.cloud/reserved
operator: In
values:
- platform-team
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values:
- gpu-operator
topologyKey: kubernetes.io/hostname
When node-feature-discovery.enabled=true, the chart also merges in a
nodeAffinity rule that requires hardware virtualization support.
How NFD and user nodeAffinity are combined
Within a single nodeSelectorTerm, matchExpressions and matchFields are
AND-ed (all must match). Across nodeSelectorTerms, terms are OR-ed
(any one term may match).
If you set affinity.nodeAffinity yourself, only your required
nodeSelectorTerms participate in the merge. They are combined with the
built-in virtualization terms as (NFD OR-group) AND (user OR-group):
each built-in term is AND-ed with each of your required terms. Multiple
user required terms remain OR among themselves. NFD virtualization
requirements cannot be bypassed by user affinity.
If you set nodeAffinity without requiredDuringSchedulingIgnoredDuringExecution,
the built-in NFD required terms are still applied. Other affinity fields
(podAffinity, podAntiAffinity, and preferredDuringSchedulingIgnoredDuringExecution)
are passed through unchanged.
Multiple Kata installations on the Same Node¶
For debugging, testing and other use-case it is possible to deploy multiple
versions of Kata on the very same node. All the needed artifacts are getting the
multiInstallSuffix appended to distinguish each installation. BEWARE that one
needs at least containerd-2.0 since this version has drop-in conf support
which is a prerequisite for the multiInstallSuffix to work properly.
$ helm install kata-deploy-cicd \
-n kata-deploy-cicd \
--set env.multiInstallSuffix=cicd \
--set env.debug=true \
"${CHART}" --version "${VERSION}"
Note: runtimeClasses are automatically created by Helm (via
runtimeClasses.enabled=true, which is the default).
Now verify the installation by examining the runtimeClasses:
$ kubectl get runtimeClasses
NAME HANDLER AGE
kata-clh-azure-cicd kata-clh-azure-cicd 77s
kata-clh-azure-runtime-rs-cicd kata-clh-azure-runtime-rs-cicd 77s
kata-clh-cicd kata-clh-cicd 77s
kata-clh-runtime-rs-cicd kata-clh-runtime-rs-cicd 77s
kata-dragonball-cicd kata-dragonball-cicd 77s
kata-openvmm-azure-runtime-rs-cicd kata-openvmm-azure-runtime-rs-cicd 77s
kata-qemu-cicd kata-qemu-cicd 77s
kata-qemu-runtime-rs-cicd kata-qemu-runtime-rs-cicd 77s
That is the default shim set (shims). Installing with one of the
profiles instead gives you that profile's classes: adding
-f try-kata-tee.values.yaml here would list the TEE set — kata-qemu-snp-cicd,
kata-qemu-tdx-cicd and the rest — in place of the classes above.
How installations keep out of each other's way on a node¶
One thing is deliberately not suffixed: the node label
katacontainers.io/kata-runtime, which every installation's RuntimeClasses select.
It says "this node can run Kata", not "this installation is here", so it cannot be
used to work out whether removing it is safe.
Each installation therefore also marks the nodes it holds with a label of its own,
named after its suffix — kata-deploy.katacontainers.io/cicd for the example above,
and kata-deploy.katacontainers.io/default for an installation that sets no suffix.
The value follows the shared label's: false while the installation is being put in
place, true once the node can run its workloads.
$ kubectl get node worker-1 -o jsonpath='{.metadata.labels}' | tr ',' '\n' | grep kata
"katacontainers.io/kata-runtime":"true"
"kata-deploy.katacontainers.io/default":"true"
"kata-deploy.katacontainers.io/cicd":"true"
Every installation's RuntimeClasses select both labels — the shared one and
its own mark, including kata-deploy.katacontainers.io/default when no suffix was
set. The default installation can still share a node with a suffixed release, so
it needs the same gate. Removing one mark stops that installation's workloads
being sent there immediately, even when the shared label stays for another one.
An uninstall removes its own mark from every node it reaches, and the shared label
only from the nodes where no other mark is left. So uninstalling kata-deploy-cicd
above leaves worker-1 running Kata for the other installation, while a node that
only ever had cicd on it loses the label and stops being a Kata node. Cleanup
still restarts the shared CRI runtime after removing this installation's
configuration: otherwise the live runtime could keep advertising a handler whose
binary has just been deleted. The other installation's configuration remains and
is reloaded by that restart. Host-mutating stages from different releases take
the same node-local lock, so concurrent installs or uninstalls cannot race their
configuration edits and restarts.
A mark reading false does not count as another installation serving Kata: if the
last true mark goes and only a half-finished installation is left, the shared
label is set back to false rather than removed. Nothing is scheduled on the
strength of it, and the installation that is still working on the node keeps a
label its own uninstall can find.
Upgrade every installation before uninstalling any
Nodes installed by a version that did not write marks carry none, and an
uninstall cannot attribute such a node to one release. Run helm upgrade on
every installation without changing its node selection before uninstalling or
narrowing one of them, so each release has first marked the nodes it owns.
env.multiInstallSuffix is immutable for the life of a Helm release. The chart
stores the first value under a suffix-independent ConfigMap name and rejects an
upgrade that changes it: the install directory, handlers, resource names, and
node marker all derive from the suffix, so changing it in place would create a
second installation while forgetting how to remove the first.
For containerd, a suffixed installation also requires drop-in configuration support. Older whole-file configuration has one shared backup; uninstalling one release would restore a file that predates every release and erase the surviving handlers. Installation therefore fails before modifying CRI configuration when that unsafe combination is detected.
Both containerd drop-ins carry the suffix, the generated one as
kata-deploy-<suffix>.toml and the containerd.userDropIn content as
zz-kata-deploy-user-<suffix>.toml, for the same reason: each release removes
its own files on uninstall, and a name two releases shared would be taken from
whichever of them is still deployed.
With whole-file configuration, uninstall restores the backup taken at install time. A node whose containerd had no configuration file at all gets one written for it, and uninstall deletes that file again. Any other whole-file configuration is left untouched, with a warning in the log: without a backup or a record of having written the file, kata-deploy cannot tell its own configuration from an administrator's.
On the first upgrade from a chart version that predates that state ConfigMap, the chart verifies the previous identity from the existing mode-specific resource whose name derives from the suffix. If it cannot find one, the upgrade stops and the error names the ConfigMap data that can be seeded explicitly; guessing would be the unsafe choice.
The same state makes deploymentMode immutable and rejects values other than
daemonset and job. An in-place mode switch can strand nodes owned by the old
controller, and recording a new mode before its hook succeeds can also make a
rollback reject the previous mode. Uninstall the existing mode cleanly before
installing the other one.
In job mode this means an uninstall may visit a node that only ever belonged to
another installation — the default cleanup selection is the shared label, which is
by definition not yours alone — and find nothing of its own to remove. That is
deliberately the safe direction: it reaches every node it might have touched. If you
would rather it visited only its own nodes, select on its mark:
job:
cleanup:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kata-deploy.katacontainers.io/cicd
operator: Exists
Both deployment modes keep the same books
The marks are the same labels in either mode — written by the dispatcher in
job mode and by the pod on the node in daemonset mode — so an uninstall in
one mode does see an installation running in the other. Mixing modes across
installations is not a combination we test, though: give every installation the
same deploymentMode.
RuntimeClass Node Selectors for TEE Shims¶
Manual configuration: Any nodeSelector you set under shims.<shim>.runtimeClass.nodeSelector
is always applied to that shim's RuntimeClass, whether or not NFD is present. Use this when
you want to pin TEE workloads to specific nodes (e.g. without NFD, or with custom labels).
Auto-inject when NFD is present: If you do not set a runtimeClass.nodeSelector for a
TEE shim, the chart can automatically inject NFD-based labels when NFD is detected in the
cluster (deployed by this chart with node-feature-discovery.enabled=true or found externally):
- AMD SEV-SNP shims:
amd.feature.node.kubernetes.io/snp: "true" - Intel TDX shims:
intel.feature.node.kubernetes.io/tdx: "true" - IBM Secure Execution for Linux (SEL) shims (s390x):
feature.node.kubernetes.io/cpu-security.se.enabled: "true"
The chart detects NFD once and uses that result for both these selectors and
TEE key advertisement. Detection succeeds when this
chart enables NFD, an external NFD workload is found by label, or the NFD API is
registered. Auto-injection only runs when NFD is detected and the shim has no
manual runtimeClass.nodeSelector.
Detection needs a live cluster
helm template cannot detect external NFD. Set a manual
runtimeClass.nodeSelector and nodeFeatureRules.create: true when rendering
manifests without cluster access.
TEE key advertisement¶
A confidential VM consumes a hardware key slot — an encrypted-state ID on AMD SEV-SNP, a key ID on Intel TDX — and a node has a small, fixed number of them. The chart models that as an extended resource so the scheduler stops placing confidential pods on a node whose slots are all taken, instead of letting the VM fail to start:
- a
NodeFeatureRuletells node-feature-discovery to advertise the per-node counts assev-snp.amd.com/esidsandtdx.intel.com/keys - the matching
RuntimeClasses request one of them inoverhead.podFixed, so every pod that uses the class consumes a slot
Both halves are one switch, because requesting a resource nothing advertises would
leave confidential pods Pending forever:
auto renders them when NFD is in the picture, by the same detection the
RuntimeClass node selectors use:
installed by this chart (node-feature-discovery.enabled=true), already present in
the cluster, or its CRD is registered. true and false decide outright.
false turns off both halves: no rule, and no confidential RuntimeClass asks
for a TEE key. It is the escape hatch for a cluster that wants no part of this — not
the way to keep the requests while managing the rule elsewhere. If you create an
equivalent rule yourself, leave this at auto (or set it to true) so the requests
are still rendered, and delete the chart's rule if it duplicates yours.
Naming, and a rule you may have to delete by hand
The chart's rule is named kata-tee-keys, suffixed with
env.multiInstallSuffix when that is set. A rule called amd64-tee-keys
belongs to a release that has not been upgraded yet, and can be deleted once
every release has been.