Project: gVisor (Runtime / Container Runtime)
File: 2023-05-08-rootfs-overlay.md

Root filesystem overlay is now the default in runsc. This improves performance for filesystem-heavy workloads by overlaying the container root filesystem with a tmpfs filesystem. Learn more about this feature in the following blog that was on . -- gVisor uses a trusted filesystem proxy process (gofer) to access the filesystem on behalf of the sandbox. The sandbox process is considered untrusted in gVisors . As a result, it is not given direct access to the container filesystem and do not allow filesystem syscalls. In gVisor, the container rootfs and are configured to be served by a gofer. {:width="100%"} When the container needs to perform a filesystem operation, it makes an RPC to the gofer which makes host system calls and services the RPC. This is quite expensive due to: RPC cost: This is the cost of communicating with the gofer process, including process scheduling, message serialization and system calls. To ameliorate this, gVisor recently developed a purpose-built protocol called which is much more efficient than its predecessor. gVisor is also with giving the sandbox direct access to the container filesystem in a secure manner. This would essentially nullify RPC costs as it avoids the gofer being in the critical path of filesystem operations. Syscall cost: This is the cost of making the host syscall which actually accesses/modifies the container filesystem. Syscalls are expensive, because they perform context switches into the kernel and back into userspace. To help with this, gVisor heavily caches the filesystem tree in memory. So operations like on cached files are serviced quickly. But other operations like or still need to make host syscalls. In Docker and Kubernetes, the containers root filesystem (rootfs) is based on the filesystem packaged with the image. The images filesystem is immutable. Any change a container makes to the rootfs is stored separately and is destroyed with the container. This way, the images filesystem can be shared efficiently with all containers running the same image. This is different from bind mounts, which allow containers to access the bound host filesystem tree. Changes to bind mounts are always propagated to the host and persist after the container exits. Docker and Kubernetes both use the by default to configure container rootfs. Overlayfs mounts are composed of one upper layer and multiple lower layers. The overlay filesystem presents a merged view of all these filesystem layers at its mount location and ensures that lower layers are read-only while all changes are held in the upper layer. The lower layer(s) constitute the image layer and the upper layer is the container

When the container is destroyed, the upper layer mount is destroyed as well, discarding the root filesystem changes the container may have made. Dockers has a good explanation. Lets consider an example where the image has files `foo` and `baz`. The container overwrites `foo` and creates a new file `bar`. The diagram below shows how the root filesystem used to be configured in gVisor earlier. We used to go through the gofer and access/mutate the overlaid directory on the host. It also shows the state of the host overlay filesystem. {:width="100%"} Given that the upper layer is destroyed with the container and that it is expensive to access/mutate a host filesystem from the sandbox, why keep the upper layer on the host at all? Instead we can move the upper layer into the sandbox. The idea is to overlay the rootfs using a sandbox-internal overlay mount. We can use a tmpfs upper (container) layer and a read-only lower layer served by the gofer client. Any changes to rootfs would be held in tmpfs (in-memory). Accessing/mutating the upper layer would not require any gofer RPCs or syscalls to the host. This really speeds up filesystem operations on the upper layer, which contains newly created or copied-up files and directories. Using the same example as above, the following diagram shows what the rootfs configuration would look like using a sandbox-internal overlay. {:width="100%"} The tmpfs mount by default will use the sandbox processs memory to back all the file data in the mount. This can cause sandbox memory usage to blow up and exhaust the containers memory limits, so its important to store all file data from tmpfs upper layer on disk. We need to have a tmpfs-backing filestore on the host filesystem. Using the example from above, this filestore on the host will store file data for `foo` and `bar`. This would essentially flatten all regular files in tmpfs into one host file. The sandbox can the filestore into its address space. This allows it to access and mutate the filestore very efficiently, without incurring gofer RPCs or syscalls overheads. In Kubernetes, you can set . The upper layer of the rootfs overlay (writeable container layer) on the host . The kubelet enforces this limit by the entire , `stat(2)`-ing all files and their `stat.stblocks*blocksize`. If we move the upper layer into the sandbox, then the host upper layer is empty and the kubelet will not be able to enforce these limits. To address this issue, we , which create the filestore in the host upper

This way, when the kubelet scans the host upper layer, the filestore will be detected and its `stat.st_blocks` should be representative of the total file usage in the sandbox-internal upper layer. It is also important to hide this filestore from the containerized application to avoid confusing it. We do so by in the sandbox-internal upper layer, which blocks this file from appearing in the merged directory. The following diagram shows what rootfs configuration would finally look like today in gVisor. {:width="100%"} Lets look at some filesystem-intensive workloads to see how rootfs overlay impacts performance. These benchmarks were run on a gLinux desktop with . provides a . This program performs a large number of filesystem operations concurrently, creating and modifying a large filesystem tree of all sorts of files. We ran this program on the container's root filesystem. The exact usage was: &nbsp;&nbsp;&nbsp;&nbsp;`sh -c "mkdir /test && time fsstress -d /test -n 500 -p 20 -s 1680153482 -X -l 10"` You can use the -v flag (verbose mode) to see what filesystem operations are being performed. The results were astounding! Rootfs overlay reduced the time to run this fsstress program from 262.79 seconds to 3.18 seconds! However, note that such microbenchmarks are not representative of real-world applications and we should not extrapolate these results to real-world performance. Build jobs are very filesystem intensive workloads. They read a lot of source files, compile and write out binaries and object files. Lets consider building the with . Bazel performs a lot of filesystem operations in rootfs; in bazels cache located at `~/.cache/bazel/`. This is representative of the real-world because many other applications also use the container root filesystem as scratch space due to the handy property that it disappears on container exit. To make this more realistic, the abseil-cpp repo was attached to the container using a bind mount, which does not have an overlay. When measuring performance, we care about reducing the sandboxing overhead and bringing gVisor performance as close as possible to unsandboxed performance. Sandboxing overhead can be calculated using the formula overhead = (s-n)/n where `s` is the amount of time taken to run a workload inside gVisor sandbox and `n` is the time taken to run the same workload natively (unsandboxed). The following graph shows that rootfs overlay halved the sandboxing overhead for abseil build! {:width="100%"} Rootfs overlay in gVisor substantially improves performance for many filesystem-intensive workloads, so that developers no longer have to make large tradeoffs between performance and security. We recently made this optimization in runsc. This is part of our ongoing efforts to improve gVisor performance. You can use gVisor in GKE with GKE Sandbox. Happy sandboxing!