The Problem: Kubernetes Node Agents Running as Root
When I first started deploying Kubernetes clusters at scale, I inherited a fleet of nodes where kubelet's container runtime was configured with wide-open Docker socket access. The symptom was subtle: one compromised workload pod managed to write directly to /var/lib/kubelet on the host, bypassing all namespace isolation. The root cause? Every container spawned by kubelet was running as UID 0, and the Docker daemon itself was executing with unrestricted host privileges.
This isn't theoretical. A single privilege escalation in any workload container becomes a full node compromise when the runtime runs as root.
Architectural Boundary: CRI, containerd, and the Security Model
Kubernetes abstracts container execution through the Container Runtime Interface (CRI). Most production deployments use containerd as the low-level runtime, but the security model depends entirely on how you configure user namespaces and seccomp profiles.
Here's the default failure mode in many clusters:
# /etc/containerd/config.toml (INSECURE DEFAULT)
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
SystemdCgroup = true
# Missing: user namespace remapping
# Missing: seccomp profile enforcement
Without explicit user namespace configuration, every container process maps directly to host UID 0. Even with PodSecurityPolicy or PodSecurity admission, the runtime itself is still running privileged.
Hardened Configuration: Non-Root Runtime with User Namespaces
The fix requires three layers:
1. Enable User Namespace Remapping in containerd
# /etc/containerd/config.toml
version = 2
[plugins."io.containerd.grpc.v1.cri".containerd]
snapshotter = "overlayfs"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
SystemdCgroup = true
# Force user namespace remapping
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options.Linux]
UIDMappings = [{container_id = 0, host_id = 100000, size = 65536}]
GIDMappings = [{container_id = 0, host_id = 100000, size = 65536}]
This remaps container UID 0 to host UID 100000, breaking the direct root mapping. Restart containerd:
sudo systemctl restart containerd
sudo systemctl restart kubelet
2. Lock Down the kubelet Configuration
# /var/lib/kubelet/config.yaml
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
serializeImagePulls: false
containerRuntimeEndpoint: unix:///run/containerd/containerd.sock
# Enforce seccomp and AppArmor
seccompDefault: true
# Restrict kubelet API access
authentication:
webhook:
enabled: true
authorization:
mode: Webhook
# Drop legacy docker socket mounting
enableCAdvisorJSONEndpoint: false
3. Enforce Pod-Level Security Contexts
Even with runtime hardening, each pod must explicitly drop privileges:
apiVersion: v1
kind: Pod
metadata:
name: secure-workload
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10000
fsGroup: 10000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: myapp:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Operational Trade-offs and Failure Boundaries
Build cache invalidation: Multi-stage Dockerfiles with BuildKit cache mounts become critical when you can't rely on layer caching across nodes with different UID mappings. I had to rewrite every Dockerfile to use --mount=type=cache,target=/go/pkg/mod to avoid re-downloading dependencies on every build.
Persistent volume permissions: When user namespaces are enabled, any pre-existing PersistentVolumes with root-owned files will fail permission checks. You need an init container to chown recursively:
initContainers:
- name: fix-permissions
image: busybox:1.36
command: ['sh', '-c', 'chown -R 10000:10000 /data']
volumeMounts:
- name: data
mountPath: /data
securityContext:
runAsUser: 0 # Init container exception for migration
Socket mounting restrictions: If any workload legitimately needs Docker socket access (CI/CD builders, monitoring agents), you must use rootless Docker-in-Docker with sysbox or gVisor runtime classes instead.
The Unresolved Dilemma: Performance vs. Isolation
User namespaces add measurable overhead. In high-throughput network workloads, I observed a 3-7% latency increase due to UID/GID translation in the kernel. For batch processing jobs with heavy filesystem I/O, the overhead was negligible.
The real question: is the security boundary worth the operational complexity when you're already running workloads in separate network policies and resource quotas? Some teams solve this by running untrusted workloads in dedicated node pools with user namespaces enabled, while keeping trusted platform services on traditional privileged runtimes.
What's your threshold for accepting kernel-level isolation overhead in production Kubernetes nodes?
Top comments (0)