CS644 week 10: Containers
Last week
Link: /week9
Review
- Concepts:
epoll,inotify, calling C from Python, the self-pipe trick,io_uring - Interfaces:
select.epoll,epoll.register,epoll.poll
Solutions
What are containers?
Containers are a form of virtualization. A container has its own set of resources (files, users, process tree) distinct from the host system, but unlike a full-blown virtual machine, it shares the same kernel as the host. Containers have less runtime overhead and less start-up cost than VMs, though they have less isolation and should not be used as the sole security boundary for running untrusted code. Still, containers are very useful for running complex software in a predictable environment regardless of the host system.
Mature container implementations like Docker and Podman make it convenient to create containers from prebuilt images, manage running containers, and distribute container images to other machines. But fundamentally, a container is just a process (or a collection of processes) that has been set up in a special way. Today, we are going to take a tour of three of the Linux features that containers make use of:
- cgroups, for controlling the resource usage of the container
- namespaces, for isolating the container's view of various kernel subsystem
chroot, for isolating the container's view of the filesystem
Containers are a Linux-only feature, though other Unix variants have similar functionality, like jails on FreeBSD. (There is a version of Docker for macOS, but it uses a Linux virtual machine.)
cgroups: Control resource usage
Control groups ("cgroups") are a mechanism for controlling the resource usage of a process – for instance, to cap the amount of memory the process is allowed to allocate or the amount of CPU time it is allowed to use.
cgroups work a little differently from the APIs we've seen so far. Instead of making explicit syscalls, we manipulate files under the /sys/fs/cgroup directory to create and configure control groups.
We can start by creating a new cgroup called cs644-test-group. To do this, we simply make a call to mkdir (you must be root to do this, so you won't be able to run this on the class server).
In this example, and all further examples on this page, you need to have root access to execute the syscalls, so you won't be able to run the examples on the class server.
import os
import pathlib
cgroup_d = pathlib.Path("/sys/fs/cgroup")
d = cgroup_d / "cs644-test-group"
d.mkdir(exist_ok=True)
/sys is a special filesystem whose subdirectories and files are controlled by the kernel rather than being written to and read from disk like a regular filesystem.
When you create a new directory under /sys/fs/cgroup, the kernel automatically populates it with configuration files for the cgroup. On the class server, our new cgroup has no fewer than 48 config files!
One of these files is called memory.max, which sets the maximum amount of memory a process in the cgroup can allocate.
We can change the maximum simply by writing to the file:
(d / "memory.max").write_text("10M\n")
This is not like a regular file write. We can prove it by trying to read back the file: it returns "10485760\n", which is not the data that we wrote! The kernel has interpreted our write as a request to set the max memory to 10 mebibytes (hence 10,485,760 = 10 * 1,024 * 1,024 and not 10,000,000).
Think of writing to special files like this as a kind of bespoke system call. The kernel could have created cgroup_set_memory_max and cgroup_get_memory_max syscalls, and likewise for the dozens of other configuration options, but it was more expedient to do it this way instead.
Now, let's switch our process to the new cgroup. First, we can query what cgroup we are currently in:
def get_cgroup():
return pathlib.Path("/proc/self/cgroup").read_text().rstrip("\n")
/proc is another special filesystem, like /sys, which exposes information about the processes on the system.
/proc in the real worldThe ps command is implemented by reading files under /proc.
The way to change the current process to another cgroup is to write 0\n to the cgroup.procs file under the cgroup directory:
print("cgroup before:", get_cgroup())
(d / "cgroup.procs").write_text("0\n")
print("cgroup after: ", get_cgroup())
This is the digit '0' (ASCII value 48), not the null byte!
Having set the memory limit to 10,485,760 and joined the cgroup, we then try to allocate a buffer of 20,000,000 bytes. This exceeds our memory budget, so the process is killed by the kernel, and the final line will never be printed.
b = os.urandom(20_000_000)
print("Successfully allocated bytes:", len(b))
There are many other limits that can be imposed besides memory use. See the kernel docs for a full list.
Namespaces: Isolate the process's view of the system
According to namespaces(7):
A namespace wraps a global system resource in an abstraction that makes it appear to the processes within the namespace that they have their own isolated instance of the global resource.
Among the global system resources that can be wrapped in a namespace are:
- network devices (
network_namespaces(7)) - user and group IDs (
user_namespaces(7)) - process IDs (
pid_namespaces(7)) - cgroups (
cgroup_namespaces(7))
We will take a look at PID namespaces, which restricts the view of the global process tree. PID namespaces only isolate the assignment and visibility of PIDs. They do not fully isolate a process from other processes; a process can still interact indirectly with processes outside of its namespace, such as by reading from a pipe that the other process is writing to.
However, the child process cannot, e.g., send signals (using kill(2)) to processes in other PID namespaces, because it has no way of naming them.
Let's walk through an example.
A process moves itself into a new namespace using the unshare(2) system call. There's no binding for this syscall in the standard library, so we'll create our own. unshare has the C signature:
int unshare(int flags);
which translates into Python as:
import ctypes
import os
libc = ctypes.CDLL(None, use_errno=True)
raw_unshare = libc.unshare
raw_unshare.argtypes = (ctypes.c_int,)
raw_unshare.restype = ctypes.c_int
def unshare(flags: int) -> None:
r = raw_unshare(flags)
if r < 0:
errno = ctypes.get_errno()
raise OSError(errno, os.strerror(errno))
Then, we call it with the CLONE_NEWPID flag to create a new PID namespace:
CLONE_NEWPID = 0o4000000000
unshare(CLONE_NEWPID)
You must be root to call unshare.
For namespaces other than PID namespaces, unshare moves the calling process into the new namespace. For PID namespaces, it instead arranges for any subsequently-forked children to be put in the new PID namespace. Otherwise, a process would change its PID, which is not allowed.
We can observe this:
pid = os.fork()
if pid == 0:
print("Child thinks its PID is: ", os.getpid())
os._exit(0)
else:
print("Parent thinks child PID is:", pid)
os.waitpid(pid, 0)
When I ran this on the class server, it printed:
Parent thinks child PID is: 654845
Child thinks its PID is: 1
The child process apparently has two PIDs! Within its own namespace, it has PID 1. But the parent, viewing it from the root PID namespace, see it with PID 654845.
To make things more complicated, PID namespaces can themselves be nested into a tree. Think of this as an overlay over the global process tree. The root namespace has the entire process tree. Every descendant namespace has some subtree of the global process tree, with the PIDs relabeled. If a process is in PID namespace C which is a child of B which is a child of A, the root, then that process will have 3 different PIDs, depending on which namespace you are in.
The first child in a new PID namespace has PID 1 and has some special privileges and duties analogous to the init process in the root PID namespace.
- It adopts any orphaned children whose parents have exited.
- If it exits, then every other process in the PID namespace is immediately killed.
unshare(2) is the syscall to create a new namespace. To join an existing namespace, use setns(2).
chroot: Isolate the process's view of the filesystem
The last kernel feature we will look at is also the oldest and simplest. chroot changes the root directory of the filesystem from the process's perspective. Once done, that process can no longer access files outside of the chosen directory.
Conceptually, this is very simple. (Just make sure to chdir into the target directory before you chroot.) In practice it is rather more difficult to get this working properly because doing almost anything of consequence requires reading files at some expected location that does not exist within the empty chroot directory.
For example:
- Binary executables often dynamically link in libraries from
/liband/lib64. Even minimal C programs do this. These programs will fail at start-up when the dynamic loader is unable to find the libraries. - A Python script that tries to import, e.g.,
pathlib, will fail because it expects to findpathlib.pyin some Python standard library directory.
So in practice, even to get a minimal example working, we have to do some ad hoc copying of files into the chroot.
In this example, we run a simple C program, hello.c. I ran ldd on the binary to find out what paths the dynamic loader needs. The example copies these files into the temporary directory before calling chroot:
import os
import pathlib
import shutil
import sys
import tempfile
import traceback
with tempfile.TemporaryDirectory() as tempdir:
root_directory = pathlib.Path(tempdir)
my_dir = pathlib.Path(__file__).parent
shutil.copy2(my_dir / "hello", root_directory)
lib64 = root_directory / "lib64"
lib64.mkdir()
lib = root_directory / "lib" / "x86_64-linux-gnu"
lib.mkdir(parents=True)
shutil.copy2("/lib/x86_64-linux-gnu/libc.so.6", lib)
shutil.copy2("/lib64/ld-linux-x86-64.so.2", lib64)
Then, we fork and execute the process inside the chroot:
cmd = ["/hello"]
pid = os.fork()
if pid == 0:
try:
os.chdir(root_directory)
os.chroot(root_directory)
os.execve(cmd[0], cmd, {})
except Exception:
print(traceback.format_exc(), file=sys.stderr)
finally:
os._exit(127)
else:
os.waitpid(pid, 0)
This prints (among other things):
Current directory: /
Proving that the child process is restricted to the temporary directory that we set as chroot.
Bonus material
If you have made it this far, thank you for taking CS644! I want to leave you with some resources for you to continue your learning after the course.
- LWN.net is the "the premier news and information source for the free software community". It originally stood for "Linux Weekly News" and it continues to cover Linux kernel development in-depth, but it also frequently publishes articles about other topics, including extensive coverage of Python.
- The Linux Programming Interface (Michael Kerrisk) and Advanced Programming in the Unix Environment (W. Richard Stevens and Stephen A. Rago) are two comprehensive reference books. The Linux Programming Interface is only about Linux, while APUE covers the Unix family of operating systems generally.
- I've personally learned a lot from browsing the man pages.