{"schemaVersion":1,"id":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata","title":"Container Runtime Internals: runc, youki, kata-containers, and Sandbox Isolation Models","description":"Deep dive into container runtime internals, runc vs youki (Rust), kata-containers microVMs, gVisor sandboxing, cgroup v2 resource control, and namespace isolation with real syscall traces.","language":"en","datePublished":"2025-12-19","dateModified":"2026-10-21","contentFormat":"text/markdown","content":"Containers\n\n# Container Runtime Internals: runc, youki, kata-containers, and Sandbox Isolation Models\n\n2025-12-1910 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/container-runtime-internals-runc-youki-kata>)\n\n## What actually happens when you start a container\n\nWhen you run ` docker run -it ubuntu bash `, [Docker](<https://www.docker.com/>) calls containerd, which calls a \"shim\" process, which calls the OCI runtime (runc by default). The OCI runtime is where the actual container creation happens. Here's the sequence:\n\n```\ndocker run → dockerd → containerd → containerd-shim → runc create → runc start\n │\n ├── clone(CLONE_NEWNS | CLONE_NEWPID | ...)\n ├── mount rootfs\n ├── pivot_root\n ├── setup cgroups\n ├── apply seccomp filter\n ├── drop capabilities\n └── execve(\"/bin/bash\")\n```\n\nrunc itself is a ~15,000 line Go program. It does five fundamental things:\n\n1. **Creates Linux namespaces** to isolate the container's view of the system\n2. **Sets up a filesystem** (mount namespace + pivot\\_root)\n3. **Configures cgroups** to limit resource usage\n4. **Applies security policies** (seccomp, capabilities, AppArmor/SELinux)\n5. **Executes the container process**\n\nLet's dig into each one.\n\n## Namespaces: the isolation foundation\n\nLinux namespaces partition kernel resources so that one set of processes sees one set of resources, and another set sees a different set. Containers typically use 7 namespace types:\n\n```c\n// This is essentially what runc does with clone()\n#define _GNU_SOURCE\n#include <sched.h>\n#include <stdio.h>\n#include <unistd.h>\n#include <sys/wait.h>\n\nint child_fn(void *arg) {\n // Inside the container, we have our own PID namespace\n printf(\"Container PID: %d\\n\", getpid()); // Always 1\n printf(\"Container hostname: \");\n sethostname(\"container\", 9);\n\n char hostname[64];\n gethostname(hostname, sizeof(hostname));\n printf(\"%s\\n\", hostname);\n\n // Mount proc for the new PID namespace\n mount(\"proc\", \"/proc\", \"proc\", 0, NULL);\n\n execve(\"/bin/sh\", (char*[]){\"/bin/sh\", NULL}, NULL);\n return 0;\n}\n\nint main() {\n char stack[1024 * 1024];\n\n int flags = CLONE_NEWNS // Mount namespace\n | CLONE_NEWPID // PID namespace\n | CLONE_NEWUTS // Hostname namespace\n | CLONE_NEWNET // Network namespace\n | CLONE_NEWIPC // IPC namespace\n | CLONE_NEWUSER // User namespace\n | CLONE_NEWCGROUP; // Cgroup namespace\n\n pid_t pid = clone(child_fn, stack + sizeof(stack), flags | SIGCHLD, NULL);\n waitpid(pid, NULL, 0);\n return 0;\n}\n```\n\nThe user namespace is the most interesting one for security. It maps the container's UID 0 (root) to an unprivileged UID on the host:\n\n```bash\n# Check user namespace mapping\n$ cat /proc/<container-pid>/uid_map\n 0 100000 65536\n# Container UID 0 → Host UID 100000\n# Container UID 1 → Host UID 100001\n# ... up to 65536 UIDs\n```\n\nThis means even if a process escapes the container as \"root,\" it's actually UID 100000 on the host, an unprivileged user. This is rootless containers in a nutshell.\n\n## cgroup v2: resource control done right\n\ncgroup v1 was a mess. Each resource controller (cpu, memory, io, pids) had its own hierarchy, and a process could be in different cgroups for different controllers. cgroup v2 unifies everything into a single hierarchy:\n\n```bash\n# cgroup v2 unified hierarchy\n/sys/fs/cgroup/\n├── system.slice/\n│ ├── docker-abc123.scope/\n│ │ ├── cgroup.controllers # \"cpu memory io pids\"\n│ │ ├── cpu.max # \"100000 100000\" (100% of 1 CPU)\n│ │ ├── memory.max # \"536870912\" (512MB)\n│ │ ├── memory.swap.max # \"0\" (no swap)\n│ │ ├── io.max # \"8:0 rbps=104857600 wbps=52428800\"\n│ │ ├── pids.max # \"1024\"\n│ │ └── cgroup.procs # PIDs in this cgroup\n│ └── docker-def456.scope/\n│ └── ...\n```\n\nThe killer feature of cgroup v2 is the pressure stall information (PSI):\n\n```bash\n# How much time are tasks in this cgroup stalled on resources?\n$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/cpu.pressure\nsome avg10=4.52 avg60=2.18 avg300=1.05 total=182394\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n\n$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.pressure\nsome avg10=0.00 avg60=0.00 avg300=0.00 total=0\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n```\n\nPSI tells you *how much* a cgroup is affected by resource contention, not just whether it hit a limit. \"some avg10=4.52\" means that in the last 10 seconds, at least some tasks were stalled waiting for CPU 4.52% of the time. This is dramatically more useful than the boolean \"did we OOM\" signal from v1.\n\nHere's how runc sets up cgroups programmatically:\n\n```go\n// Simplified from runc's cgroup setup\nfunc setupCgroupV2(cgroupPath string, resources *specs.LinuxResources) error {\n if err := os.MkdirAll(cgroupPath, 0755); err != nil {\n return err\n }\n\n // CPU: 50% of one core (50000us out of 100000us period)\n if resources.CPU != nil {\n quota := fmt.Sprintf(\"%d %d\", *resources.CPU.Quota, *resources.CPU.Period)\n os.WriteFile(filepath.Join(cgroupPath, \"cpu.max\"), []byte(quota), 0644)\n }\n\n // Memory: hard limit\n if resources.Memory != nil && resources.Memory.Limit != nil {\n limit := strconv.FormatInt(*resources.Memory.Limit, 10)\n os.WriteFile(filepath.Join(cgroupPath, \"memory.max\"), []byte(limit), 0644)\n // Disable swap\n os.WriteFile(filepath.Join(cgroupPath, \"memory.swap.max\"), []byte(\"0\"), 0644)\n }\n\n // PID limit, prevents fork bombs\n if resources.Pids != nil {\n limit := strconv.FormatInt(resources.Pids.Limit, 10)\n os.WriteFile(filepath.Join(cgroupPath, \"pids.max\"), []byte(limit), 0644)\n }\n\n // Move the container's init process into this cgroup\n return os.WriteFile(\n filepath.Join(cgroupPath, \"cgroup.procs\"),\n []byte(strconv.Itoa(containerPid)),\n 0644,\n )\n}\n```\n\nIt's just writing to files. That's cgroups, the kernel exposes a filesystem interface, and you write configuration values to files. Beautifully simple, terrifyingly powerful.\n\n## youki: runc in Rust\n\nyouki is a rewrite of runc in Rust, and it's not just a vanity project. There are real engineering reasons to care:\n\n**Startup time**: compare runtimes with the same image, kernel, cgroup configuration and repeated runs. The [youki repository](<https://github.com/youki-dev/youki>) is the implementation reference. This guide does not include a reproducible startup benchmark.\n\n**Memory safety**: Rust can prevent categories of memory errors in safe code. It does not automatically prevent file descriptor leaks or container escapes. [CVE 2024 21626](<https://github.com/opencontainers/runc/security/advisories/GHSA-xr7r-f8xq-vfvv>) concerns leaked file descriptors and requires examining runtime behavior, not inferring safety from the implementation language.\n\n**Drop-in replacement**: youki passes the full OCI runtime-spec compliance tests. You can literally swap it in:\n\n```bash\n# Replace runc with youki in containerd\n# /etc/containerd/config.toml\n[plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.runc]\n runtime_type = \"io.containerd.runc.v2\"\n [plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.runc.options]\n BinaryName = \"/usr/local/bin/youki\"\n```\n\nRestart containerd, and every container on the machine now uses youki. I did this on a staging cluster three months ago and literally nobody noticed. That's the best kind of infrastructure change.\n\n## kata-containers: when namespaces aren't enough\n\nA fundamental limitation of standard containers is that namespaces are a *userspace* isolation mechanism. They partition the kernel's view of resources, but every container shares the same kernel. A kernel exploit in one container compromises every container on the host.\n\nkata-containers solve this by running each container inside a lightweight VM (microVM). The container sees a dedicated kernel:\n\n```\nStandard container:\n┌──────────┐ ┌──────────┐ ┌──────────┐\n│Container1│ │Container2│ │Container3│\n├──────────┴─┴──────────┴─┴──────────┤\n│ Host Kernel │ ← Shared attack surface\n├─────────────────────────────────────┤\n│ Hardware │\n└─────────────────────────────────────┘\n\nkata-container:\n┌──────────┐ ┌──────────┐ ┌──────────┐\n│Container1│ │Container2│ │Container3│\n├──────────┤ ├──────────┤ ├──────────┤\n│ Guest K │ │ Guest K │ │ Guest K │ ← Separate kernels\n├──────────┤ ├──────────┤ ├──────────┤\n│ VMM │ │ VMM │ │ VMM │ ← QEMU/Cloud-hypervisor/Firecracker\n├──────────┴─┴──────────┴─┴──────────┤\n│ Host Kernel │\n└─────────────────────────────────────┘\n```\n\nThe performance overhead is real but manageable:\n\n```bash\n# Container startup time comparison\n$ time crictl run --runtime=runc pod.json container.json\n# real 0.087s\n\n$ time crictl run --runtime=kata pod.json container.json\n# real 0.241s (~150ms overhead for VM boot)\n\n# Memory overhead per container\n# runc: ~5MB (just the container process + shim)\n# kata: ~40-80MB (guest kernel + minimal userspace + VMM)\n```\n\nkata supports three VMMs:\n\n- **QEMU**: Full-featured, supports most hardware. Heavier (~130MB memory overhead).\n- **Cloud Hypervisor**: Rust-based, minimal VMM designed for containers. ~40MB overhead.\n- **Firecracker**: Amazon's microVM monitor. ~30MB overhead, fastest boot.\n\nFor our multi-tenant platform, we use kata with Cloud Hypervisor for customer workloads and runc for our own infrastructure services. The RuntimeClass API in Kubernetes makes this trivial:\n\n```yaml\n# RuntimeClass for kata\napiVersion: node.k8s.io/v1\nkind: RuntimeClass\nmetadata:\n name: kata-clh\nhandler: kata-clh\noverhead:\n podFixed:\n memory: \"60Mi\"\n cpu: \"50m\"\nscheduling:\n nodeSelector:\n kata-runtime: \"true\"\n\n---\n# Pod using kata\napiVersion: v1\nkind: Pod\nmetadata:\n name: untrusted-workload\nspec:\n runtimeClassName: kata-clh\n containers:\n - name: user-code\n image: customer-function:latest\n resources:\n limits:\n memory: \"256Mi\"\n cpu: \"500m\"\n```\n\n## gVisor: the middle ground\n\ngVisor (runsc) takes a different approach. Instead of running a full VM, it interposes a userspace kernel that intercepts syscalls from the container process:\n\n```\ngVisor architecture:\n┌──────────────────┐\n│ Container App │\n├──────────────────┤\n│ Sentry │ ← Userspace kernel (intercepts syscalls)\n│ (implements │\n│ ~380 syscalls) │\n├──────────────────┤\n│ Gofer │ ← Filesystem proxy (sandboxed)\n├──────────────────┤\n│ Host Kernel │ ← Only Sentry talks to the host kernel\n└──────────────────┘\n```\n\nThe Sentry reimplements Linux syscalls in Go. Your container process thinks it's talking to a Linux kernel, but it's actually talking to gVisor. If a syscall triggers a kernel vulnerability, the exploit only compromises the Sentry process, not the host kernel.\n\n```bash\n# Install and configure gVisor\n$ gvisor install\n$ cat /etc/containerd/config.toml\n[plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.runsc]\n runtime_type = \"io.containerd.runsc.v1\"\n\n# Run with gVisor\n$ docker run --runtime=runsc -it ubuntu bash\n\n# Check syscalls available inside the container\n$ strace -c ls 2>&1 | head -20\n# Only ~240 of the 380+ Linux syscalls are implemented\n```\n\nThe trade-offs compared to kata:\n\n- **Startup**: gVisor adds ~50ms (vs kata's ~150ms)\n- **Memory**: gVisor uses ~15MB overhead (vs kata's ~40-80MB)\n- **Compatibility**: gVisor doesn't support all syscalls, applications that use uncommon syscalls (like io\\_uring, specific NUMA operations) may not work\n- **Performance**: Syscall-heavy workloads take a 2-10x performance hit because every syscall goes through the Sentry\n\n## seccomp: the last line of defense\n\nRegardless of which runtime you use, seccomp (secure computing mode) filters the syscalls a container can make. The default Docker seccomp profile blocks 44 syscalls out of ~380+, including dangerous ones like ` kexec_load ` (load a new kernel), ` reboot `, and ` mount `.\n\n```json\n{\n \"defaultAction\": \"SCMP_ACT_ERRNO\",\n \"defaultErrnoRet\": 1,\n \"syscalls\": [\n {\n \"names\": [\"accept\", \"bind\", \"clone\", \"close\", \"connect\",\n \"execve\", \"exit\", \"fcntl\", \"fstat\", \"futex\",\n \"getpid\", \"ioctl\", \"mmap\", \"mprotect\", \"nanosleep\",\n \"open\", \"openat\", \"poll\", \"read\", \"recvfrom\",\n \"rt_sigaction\", \"sendto\", \"socket\", \"write\"],\n \"action\": \"SCMP_ACT_ALLOW\"\n },\n {\n \"names\": [\"clone3\"],\n \"action\": \"SCMP_ACT_ALLOW\",\n \"args\": [\n {\n \"index\": 0,\n \"value\": 2114060288,\n \"op\": \"SCMP_CMP_MASKED_EQ\"\n }\n ]\n }\n ]\n}\n```\n\nHere's a practical gotcha: the default profile doesn't allow ` io_uring ` syscalls (` io_uring_setup `, ` io_uring_enter `, ` io_uring_register `). If you're running a modern application that uses io\\_uring for async I/O, it will silently fall back to slower syscalls or fail entirely. I've debugged this exact issue twice.\n\n```bash\n# Profile your actual application to find needed syscalls\n$ strace -c -f your-application 2>&1 | tail -20\n% time seconds usecs/call calls errors syscall\n------ ----------- ----------- --------- --------- ----------------\n 35.42 0.182346 12 15234 write\n 22.18 0.114218 8 14028 read\n 15.73 0.081002 27 3001 futex\n 8.91 0.045889 45 1020 io_uring_enter # Need this!\n ...\n```\n\n## The runtime decision tree\n\nAfter running all four runtimes in production, here's how I decide:\n\n**runc**: Default for trusted, internal workloads. Low overhead, maximum compatibility, well-understood security model. 90% of containers should run on runc.\n\n**youki**: Drop-in runc replacement when you want better startup time and Rust's memory safety guarantees. No reason *not* to use it if you're comfortable with newer software.\n\n**kata-containers**: Untrusted workloads that need VM-level isolation. Multi-tenant platforms, FaaS, running customer code. Accept the ~150ms startup overhead and ~40MB memory overhead.\n\n**gVisor**: When you need better isolation than runc but can't afford kata's resource overhead. Good for batch processing of untrusted data, web servers handling untrusted input. Watch out for syscall compatibility.\n\nThe container ecosystem is converging on a layered security model: namespaces for basic isolation, cgroups for resource limits, seccomp for syscall filtering, and optional VM/sandbox layers for defense in depth. Understanding these layers, and knowing which knobs to turn, is the difference between containers that are \"probably fine\" and containers you'd bet your company on.\n\n## Frequently asked questions\n\n### What's the difference between a container runtime and a container engine?\n\nThe container engine (Docker, Podman) handles image management, networking, and orchestration. The container runtime (runc, youki, kata) is the low-level component that actually creates and runs the container process using Linux kernel features like namespaces and cgroups.\n\n### Is youki production-ready?\n\nYes. youki passes the full OCI runtime-spec compliance test suite and is used in production by several organizations. It's a drop-in replacement for runc with better startup performance and memory safety guarantees from Rust.\n\n### When should I use kata-containers instead of runc?\n\nUse kata-containers when you need hardware-level isolation for untrusted workloads, multi-tenant platforms, running user-submitted code, or compliance requirements that mandate VM isolation. The trade-off is ~150ms additional startup time and higher memory overhead per container.\n\n## Tips\n\n1. Evaluate youki compatibility and startup latency in your own environment before replacing a runtime. Rust alone does not establish container isolation.\n2. **Tip:** Always enable cgroup v2 with ` systemd.unified_cgroup_hierarchy=1 `, v1's per-resource hierarchy is a nightmare for container orchestration and v2's unified tree is vastly simpler.\n3. **Tip:** kata-containers add ~150ms to container startup but give you hardware-level isolation. Use them for untrusted workloads and standard runc for trusted services.\n4. **Tip:** Check your seccomp profile with ` strace -c ` on your actual workload before deploying, the default Docker profile blocks 44 syscalls, and one of them might be the one your app needs.\n\n- containers\n- runc\n- youki\n- kata-containers\n- gvisor\n- cgroups\n- namespaces\n- oci\n- kubernetes\n- security\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Container Runtime Internals: runc, youki, kata-containers, and Sandbox Isolation Models","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#title"},{"level":2,"text":"What actually happens when you start a container","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#what-actually-happens-when-you-start-a-container"},{"level":2,"text":"Namespaces: the isolation foundation","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#namespaces-the-isolation-foundation"},{"level":2,"text":"cgroup v2: resource control done right","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#cgroup-v2-resource-control-done-right"},{"level":2,"text":"youki: runc in Rust","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#youki-runc-in-rust"},{"level":2,"text":"kata-containers: when namespaces aren't enough","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#kata-containers-when-namespaces-arent-enough"},{"level":2,"text":"gVisor: the middle ground","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#gvisor-the-middle-ground"},{"level":2,"text":"seccomp: the last line of defense","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#seccomp-the-last-line-of-defense"},{"level":2,"text":"The runtime decision tree","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#the-runtime-decision-tree"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#questions"},{"level":3,"text":"What's the difference between a container runtime and a container engine?","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#question-1"},{"level":3,"text":"Is youki production-ready?","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#question-2"},{"level":3,"text":"When should I use kata-containers instead of runc?","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#article-tips"}],"sections":[{"heading":"Container Runtime Internals: runc, youki, kata-containers, and Sandbox Isolation Models","level":1,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata","content":"Containers"},{"heading":"Container Runtime Internals: runc, youki, kata-containers, and Sandbox Isolation Models","level":1,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#title","content":"2025-12-1910 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/container-runtime-internals-runc-youki-kata>)"},{"heading":"What actually happens when you start a container","level":2,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#what-actually-happens-when-you-start-a-container","content":"When you run ` docker run -it ubuntu bash `, [Docker](<https://www.docker.com/>) calls containerd, which calls a \"shim\" process, which calls the OCI runtime (runc by default). The OCI runtime is where the actual container creation happens. Here's the sequence:\n\n```\ndocker run → dockerd → containerd → containerd-shim → runc create → runc start\n │\n ├── clone(CLONE_NEWNS | CLONE_NEWPID | ...)\n ├── mount rootfs\n ├── pivot_root\n ├── setup cgroups\n ├── apply seccomp filter\n ├── drop capabilities\n └── execve(\"/bin/bash\")\n```\n\nrunc itself is a ~15,000 line Go program. It does five fundamental things:\n\n1. **Creates Linux namespaces** to isolate the container's view of the system\n2. **Sets up a filesystem** (mount namespace + pivot\\_root)\n3. **Configures cgroups** to limit resource usage\n4. **Applies security policies** (seccomp, capabilities, AppArmor/SELinux)\n5. **Executes the container process**\n\nLet's dig into each one."},{"heading":"Namespaces: the isolation foundation","level":2,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#namespaces-the-isolation-foundation","content":"Linux namespaces partition kernel resources so that one set of processes sees one set of resources, and another set sees a different set. Containers typically use 7 namespace types:\n\n```c\n// This is essentially what runc does with clone()\n#define _GNU_SOURCE\n#include <sched.h>\n#include <stdio.h>\n#include <unistd.h>\n#include <sys/wait.h>\n\nint child_fn(void *arg) {\n // Inside the container, we have our own PID namespace\n printf(\"Container PID: %d\\n\", getpid()); // Always 1\n printf(\"Container hostname: \");\n sethostname(\"container\", 9);\n\n char hostname[64];\n gethostname(hostname, sizeof(hostname));\n printf(\"%s\\n\", hostname);\n\n // Mount proc for the new PID namespace\n mount(\"proc\", \"/proc\", \"proc\", 0, NULL);\n\n execve(\"/bin/sh\", (char*[]){\"/bin/sh\", NULL}, NULL);\n return 0;\n}\n\nint main() {\n char stack[1024 * 1024];\n\n int flags = CLONE_NEWNS // Mount namespace\n | CLONE_NEWPID // PID namespace\n | CLONE_NEWUTS // Hostname namespace\n | CLONE_NEWNET // Network namespace\n | CLONE_NEWIPC // IPC namespace\n | CLONE_NEWUSER // User namespace\n | CLONE_NEWCGROUP; // Cgroup namespace\n\n pid_t pid = clone(child_fn, stack + sizeof(stack), flags | SIGCHLD, NULL);\n waitpid(pid, NULL, 0);\n return 0;\n}\n```\n\nThe user namespace is the most interesting one for security. It maps the container's UID 0 (root) to an unprivileged UID on the host:\n\n```bash\n# Check user namespace mapping\n$ cat /proc/<container-pid>/uid_map\n 0 100000 65536\n# Container UID 0 → Host UID 100000\n# Container UID 1 → Host UID 100001\n# ... up to 65536 UIDs\n```\n\nThis means even if a process escapes the container as \"root,\" it's actually UID 100000 on the host, an unprivileged user. This is rootless containers in a nutshell."},{"heading":"cgroup v2: resource control done right","level":2,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#cgroup-v2-resource-control-done-right","content":"cgroup v1 was a mess. Each resource controller (cpu, memory, io, pids) had its own hierarchy, and a process could be in different cgroups for different controllers. cgroup v2 unifies everything into a single hierarchy:\n\n```bash\n# cgroup v2 unified hierarchy\n/sys/fs/cgroup/\n├── system.slice/\n│ ├── docker-abc123.scope/\n│ │ ├── cgroup.controllers # \"cpu memory io pids\"\n│ │ ├── cpu.max # \"100000 100000\" (100% of 1 CPU)\n│ │ ├── memory.max # \"536870912\" (512MB)\n│ │ ├── memory.swap.max # \"0\" (no swap)\n│ │ ├── io.max # \"8:0 rbps=104857600 wbps=52428800\"\n│ │ ├── pids.max # \"1024\"\n│ │ └── cgroup.procs # PIDs in this cgroup\n│ └── docker-def456.scope/\n│ └── ...\n```\n\nThe killer feature of cgroup v2 is the pressure stall information (PSI):\n\n```bash\n# How much time are tasks in this cgroup stalled on resources?\n$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/cpu.pressure\nsome avg10=4.52 avg60=2.18 avg300=1.05 total=182394\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n\n$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.pressure\nsome avg10=0.00 avg60=0.00 avg300=0.00 total=0\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n```\n\nPSI tells you *how much* a cgroup is affected by resource contention, not just whether it hit a limit. \"some avg10=4.52\" means that in the last 10 seconds, at least some tasks were stalled waiting for CPU 4.52% of the time. This is dramatically more useful than the boolean \"did we OOM\" signal from v1.\n\nHere's how runc sets up cgroups programmatically:\n\n```go\n// Simplified from runc's cgroup setup\nfunc setupCgroupV2(cgroupPath string, resources *specs.LinuxResources) error {\n if err := os.MkdirAll(cgroupPath, 0755); err != nil {\n return err\n }\n\n // CPU: 50% of one core (50000us out of 100000us period)\n if resources.CPU != nil {\n quota := fmt.Sprintf(\"%d %d\", *resources.CPU.Quota, *resources.CPU.Period)\n os.WriteFile(filepath.Join(cgroupPath, \"cpu.max\"), []byte(quota), 0644)\n }\n\n // Memory: hard limit\n if resources.Memory != nil && resources.Memory.Limit != nil {\n limit := strconv.FormatInt(*resources.Memory.Limit, 10)\n os.WriteFile(filepath.Join(cgroupPath, \"memory.max\"), []byte(limit), 0644)\n // Disable swap\n os.WriteFile(filepath.Join(cgroupPath, \"memory.swap.max\"), []byte(\"0\"), 0644)\n }\n\n // PID limit, prevents fork bombs\n if resources.Pids != nil {\n limit := strconv.FormatInt(resources.Pids.Limit, 10)\n os.WriteFile(filepath.Join(cgroupPath, \"pids.max\"), []byte(limit), 0644)\n }\n\n // Move the container's init process into this cgroup\n return os.WriteFile(\n filepath.Join(cgroupPath, \"cgroup.procs\"),\n []byte(strconv.Itoa(containerPid)),\n 0644,\n )\n}\n```\n\nIt's just writing to files. That's cgroups, the kernel exposes a filesystem interface, and you write configuration values to files. Beautifully simple, terrifyingly powerful."},{"heading":"youki: runc in Rust","level":2,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#youki-runc-in-rust","content":"youki is a rewrite of runc in Rust, and it's not just a vanity project. There are real engineering reasons to care:\n\n**Startup time**: compare runtimes with the same image, kernel, cgroup configuration and repeated runs. The [youki repository](<https://github.com/youki-dev/youki>) is the implementation reference. This guide does not include a reproducible startup benchmark.\n\n**Memory safety**: Rust can prevent categories of memory errors in safe code. It does not automatically prevent file descriptor leaks or container escapes. [CVE 2024 21626](<https://github.com/opencontainers/runc/security/advisories/GHSA-xr7r-f8xq-vfvv>) concerns leaked file descriptors and requires examining runtime behavior, not inferring safety from the implementation language.\n\n**Drop-in replacement**: youki passes the full OCI runtime-spec compliance tests. You can literally swap it in:\n\n```bash\n# Replace runc with youki in containerd\n# /etc/containerd/config.toml\n[plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.runc]\n runtime_type = \"io.containerd.runc.v2\"\n [plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.runc.options]\n BinaryName = \"/usr/local/bin/youki\"\n```\n\nRestart containerd, and every container on the machine now uses youki. I did this on a staging cluster three months ago and literally nobody noticed. That's the best kind of infrastructure change."},{"heading":"kata-containers: when namespaces aren't enough","level":2,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#kata-containers-when-namespaces-arent-enough","content":"A fundamental limitation of standard containers is that namespaces are a *userspace* isolation mechanism. They partition the kernel's view of resources, but every container shares the same kernel. A kernel exploit in one container compromises every container on the host.\n\nkata-containers solve this by running each container inside a lightweight VM (microVM). The container sees a dedicated kernel:\n\n```\nStandard container:\n┌──────────┐ ┌──────────┐ ┌──────────┐\n│Container1│ │Container2│ │Container3│\n├──────────┴─┴──────────┴─┴──────────┤\n│ Host Kernel │ ← Shared attack surface\n├─────────────────────────────────────┤\n│ Hardware │\n└─────────────────────────────────────┘\n\nkata-container:\n┌──────────┐ ┌──────────┐ ┌──────────┐\n│Container1│ │Container2│ │Container3│\n├──────────┤ ├──────────┤ ├──────────┤\n│ Guest K │ │ Guest K │ │ Guest K │ ← Separate kernels\n├──────────┤ ├──────────┤ ├──────────┤\n│ VMM │ │ VMM │ │ VMM │ ← QEMU/Cloud-hypervisor/Firecracker\n├──────────┴─┴──────────┴─┴──────────┤\n│ Host Kernel │\n└─────────────────────────────────────┘\n```\n\nThe performance overhead is real but manageable:\n\n```bash\n# Container startup time comparison\n$ time crictl run --runtime=runc pod.json container.json\n# real 0.087s\n\n$ time crictl run --runtime=kata pod.json container.json\n# real 0.241s (~150ms overhead for VM boot)\n\n# Memory overhead per container\n# runc: ~5MB (just the container process + shim)\n# kata: ~40-80MB (guest kernel + minimal userspace + VMM)\n```\n\nkata supports three VMMs:\n\n- **QEMU**: Full-featured, supports most hardware. Heavier (~130MB memory overhead).\n- **Cloud Hypervisor**: Rust-based, minimal VMM designed for containers. ~40MB overhead.\n- **Firecracker**: Amazon's microVM monitor. ~30MB overhead, fastest boot.\n\nFor our multi-tenant platform, we use kata with Cloud Hypervisor for customer workloads and runc for our own infrastructure services. The RuntimeClass API in Kubernetes makes this trivial:\n\n```yaml\n# RuntimeClass for kata\napiVersion: node.k8s.io/v1\nkind: RuntimeClass\nmetadata:\n name: kata-clh\nhandler: kata-clh\noverhead:\n podFixed:\n memory: \"60Mi\"\n cpu: \"50m\"\nscheduling:\n nodeSelector:\n kata-runtime: \"true\"\n\n---\n# Pod using kata\napiVersion: v1\nkind: Pod\nmetadata:\n name: untrusted-workload\nspec:\n runtimeClassName: kata-clh\n containers:\n - name: user-code\n image: customer-function:latest\n resources:\n limits:\n memory: \"256Mi\"\n cpu: \"500m\"\n```"},{"heading":"gVisor: the middle ground","level":2,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#gvisor-the-middle-ground","content":"gVisor (runsc) takes a different approach. Instead of running a full VM, it interposes a userspace kernel that intercepts syscalls from the container process:\n\n```\ngVisor architecture:\n┌──────────────────┐\n│ Container App │\n├──────────────────┤\n│ Sentry │ ← Userspace kernel (intercepts syscalls)\n│ (implements │\n│ ~380 syscalls) │\n├──────────────────┤\n│ Gofer │ ← Filesystem proxy (sandboxed)\n├──────────────────┤\n│ Host Kernel │ ← Only Sentry talks to the host kernel\n└──────────────────┘\n```\n\nThe Sentry reimplements Linux syscalls in Go. Your container process thinks it's talking to a Linux kernel, but it's actually talking to gVisor. If a syscall triggers a kernel vulnerability, the exploit only compromises the Sentry process, not the host kernel.\n\n```bash\n# Install and configure gVisor\n$ gvisor install\n$ cat /etc/containerd/config.toml\n[plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.runsc]\n runtime_type = \"io.containerd.runsc.v1\"\n\n# Run with gVisor\n$ docker run --runtime=runsc -it ubuntu bash\n\n# Check syscalls available inside the container\n$ strace -c ls 2>&1 | head -20\n# Only ~240 of the 380+ Linux syscalls are implemented\n```\n\nThe trade-offs compared to kata:\n\n- **Startup**: gVisor adds ~50ms (vs kata's ~150ms)\n- **Memory**: gVisor uses ~15MB overhead (vs kata's ~40-80MB)\n- **Compatibility**: gVisor doesn't support all syscalls, applications that use uncommon syscalls (like io\\_uring, specific NUMA operations) may not work\n- **Performance**: Syscall-heavy workloads take a 2-10x performance hit because every syscall goes through the Sentry"},{"heading":"seccomp: the last line of defense","level":2,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#seccomp-the-last-line-of-defense","content":"Regardless of which runtime you use, seccomp (secure computing mode) filters the syscalls a container can make. The default Docker seccomp profile blocks 44 syscalls out of ~380+, including dangerous ones like ` kexec_load ` (load a new kernel), ` reboot `, and ` mount `.\n\n```json\n{\n \"defaultAction\": \"SCMP_ACT_ERRNO\",\n \"defaultErrnoRet\": 1,\n \"syscalls\": [\n {\n \"names\": [\"accept\", \"bind\", \"clone\", \"close\", \"connect\",\n \"execve\", \"exit\", \"fcntl\", \"fstat\", \"futex\",\n \"getpid\", \"ioctl\", \"mmap\", \"mprotect\", \"nanosleep\",\n \"open\", \"openat\", \"poll\", \"read\", \"recvfrom\",\n \"rt_sigaction\", \"sendto\", \"socket\", \"write\"],\n \"action\": \"SCMP_ACT_ALLOW\"\n },\n {\n \"names\": [\"clone3\"],\n \"action\": \"SCMP_ACT_ALLOW\",\n \"args\": [\n {\n \"index\": 0,\n \"value\": 2114060288,\n \"op\": \"SCMP_CMP_MASKED_EQ\"\n }\n ]\n }\n ]\n}\n```\n\nHere's a practical gotcha: the default profile doesn't allow ` io_uring ` syscalls (` io_uring_setup `, ` io_uring_enter `, ` io_uring_register `). If you're running a modern application that uses io\\_uring for async I/O, it will silently fall back to slower syscalls or fail entirely. I've debugged this exact issue twice.\n\n```bash\n# Profile your actual application to find needed syscalls\n$ strace -c -f your-application 2>&1 | tail -20\n% time seconds usecs/call calls errors syscall\n------ ----------- ----------- --------- --------- ----------------\n 35.42 0.182346 12 15234 write\n 22.18 0.114218 8 14028 read\n 15.73 0.081002 27 3001 futex\n 8.91 0.045889 45 1020 io_uring_enter # Need this!\n ...\n```"},{"heading":"The runtime decision tree","level":2,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#the-runtime-decision-tree","content":"After running all four runtimes in production, here's how I decide:\n\n**runc**: Default for trusted, internal workloads. Low overhead, maximum compatibility, well-understood security model. 90% of containers should run on runc.\n\n**youki**: Drop-in runc replacement when you want better startup time and Rust's memory safety guarantees. No reason *not* to use it if you're comfortable with newer software.\n\n**kata-containers**: Untrusted workloads that need VM-level isolation. Multi-tenant platforms, FaaS, running customer code. Accept the ~150ms startup overhead and ~40MB memory overhead.\n\n**gVisor**: When you need better isolation than runc but can't afford kata's resource overhead. Good for batch processing of untrusted data, web servers handling untrusted input. Watch out for syscall compatibility.\n\nThe container ecosystem is converging on a layered security model: namespaces for basic isolation, cgroups for resource limits, seccomp for syscall filtering, and optional VM/sandbox layers for defense in depth. Understanding these layers, and knowing which knobs to turn, is the difference between containers that are \"probably fine\" and containers you'd bet your company on."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#questions","content":""},{"heading":"What's the difference between a container runtime and a container engine?","level":3,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#question-1","content":"The container engine (Docker, Podman) handles image management, networking, and orchestration. The container runtime (runc, youki, kata) is the low-level component that actually creates and runs the container process using Linux kernel features like namespaces and cgroups."},{"heading":"Is youki production-ready?","level":3,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#question-2","content":"Yes. youki passes the full OCI runtime-spec compliance test suite and is used in production by several organizations. It's a drop-in replacement for runc with better startup performance and memory safety guarantees from Rust."},{"heading":"When should I use kata-containers instead of runc?","level":3,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#question-3","content":"Use kata-containers when you need hardware-level isolation for untrusted workloads, multi-tenant platforms, running user-submitted code, or compliance requirements that mandate VM isolation. The trade-off is ~150ms additional startup time and higher memory overhead per container."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#article-tips","content":"1. Evaluate youki compatibility and startup latency in your own environment before replacing a runtime. Rust alone does not establish container isolation.\n2. **Tip:** Always enable cgroup v2 with ` systemd.unified_cgroup_hierarchy=1 `, v1's per-resource hierarchy is a nightmare for container orchestration and v2's unified tree is vastly simpler.\n3. **Tip:** kata-containers add ~150ms to container startup but give you hardware-level isolation. Use them for untrusted workloads and standard runc for trusted services.\n4. **Tip:** Check your seccomp profile with ` strace -c ` on your actual workload before deploying, the default Docker profile blocks 44 syscalls, and one of them might be the one your app needs.\n\n- containers\n- runc\n- youki\n- kata-containers\n- gvisor\n- cgroups\n- namespaces\n- oci\n- kubernetes\n- security\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/container-runtime-internals-runc-youki-kata"},{"text":"Docker","url":"https://www.docker.com/"},{"text":"youki repository","url":"https://github.com/youki-dev/youki"},{"text":"CVE 2024 21626","url":"https://github.com/opencontainers/runc/security/advisories/GHSA-xr7r-f8xq-vfvv"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata"},"headline":"Container Runtime Internals: runc, youki, kata-containers, and Sandbox Isolation Models","description":"Deep dive into container runtime internals, runc vs youki (Rust), kata-containers microVMs, gVisor sandboxing, cgroup v2 resource control, and namespace isolation with real syscall traces.","datePublished":"2025-12-19","dateModified":"2026-10-21","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://statics.lucioduran.com/images/lucioduran-social-card.jpg","url":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Containers","keywords":"containers, runc, youki, kata-containers, gvisor, cgroups, namespaces, oci, kubernetes, security","wordCount":1808,"timeRequired":"PT10M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/container-runtime-internals-runc-youki-kata"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Container Runtime Internals: runc, youki, kata-containers, and Sandbox Isolation Models","item":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What's the difference between a container runtime and a container engine?","acceptedAnswer":{"@type":"Answer","text":"The container engine (Docker, Podman) handles image management, networking, and orchestration. The container runtime (runc, youki, kata) is the low-level component that actually creates and runs the container process using Linux kernel features like namespaces and cgroups."}},{"@type":"Question","name":"Is youki production-ready?","acceptedAnswer":{"@type":"Answer","text":"Yes. youki passes the full OCI runtime-spec compliance test suite and is used in production by several organizations. It's a drop-in replacement for runc with better startup performance and memory safety guarantees from Rust."}},{"@type":"Question","name":"When should I use kata-containers instead of runc?","acceptedAnswer":{"@type":"Answer","text":"Use kata-containers when you need hardware-level isolation for untrusted workloads, multi-tenant platforms, running user-submitted code, or compliance requirements that mandate VM isolation. The trade-off is ~150ms additional startup time and higher memory overhead per container."}}]}]}],"contentHash":"5ae6e7a1f964b7cc70a92dcece7109c656d5ebe878fe7057cee7f8a0e923cf70","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/container-runtime-internals-runc-youki-kata","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":75953,"contentBytes":17046}}
