Running existing containers in urunc
with Linux๐
While Linux is not a unikernel framework, it remains the most widely used kernel in cloud infrastructure. As a result, the majority of applications and services are built to run on Linux. At the same time, Linux has a very highly configurable build system, and as proven by Lupine, we can build tailored Linux kernels optimized for running a single application.
With this goal in mind, this guide walks through the steps required to take an existing container image and execute it on top of urunc
as a Linux virtual machine (VM).
Overall, we need to do the followings:
- Build or reuse a Linux kernel.
- (Optional) Build or fetch an init process.
- Prepare the final image by appending the Linux kernel (and init) and set up
urunc
annotations.
Linux kernel๐
The main requirement for running existing containers on top of urunc
is a Linux kernel. From urunc
's side there are no specific kernel configuration options required, but since Linux will run on virtual machine monitors like Qemu or Firecracker, the kernel should be configured with the necessary drivers (e.g., virtio devices).
To simplify this, you can find here a sample x86 kernel configuration based on Linux v6.14, which builds a minimal kernel around 13โฏMiB in size. Note that this configuration excludes features like cgroups and certain system calls, so additional customization may be required depending on your application.
Alternatively, prebuilt kernels are available via the following container images:
harbor.nbfc.io/nubificus/urunc/linux-kernel-qemu:v6.14
harbor.nbfc.io/nubificus/urunc/linux-kernel-firecracker:v6.14
Each image contains the Linux kernel binary at /kernel
.
Init process๐
After booting, the Linux kernel hands control to the init process, the first user-space program. This process acts as the root of the process tree and must remain running. If it exits, the kernel will panic.
In single-application environments, the application itself can serve as init. However, this is not always reliable:
- If the application exits, the system halts.
- CLI argument handling may be incorrect: Linux does not natively support multi-word arguments via kernel boot parameters. Each space-separated word is treated as a separate argument.
- A few necessary operations (such as mount /proc, set default route) might be required before executing the application.
Especially, for CLI argument handing, urunc
follows a simple convention. All multi-word CLI arguments are wrapped in single quotes and the init process (or application) is expected to reconstruct them properly.
For all the above reasons, we recommend using a dedicated init process. We provide urunit; a lightweight init designed specifically for urunc
. It performs the following actions:
- Sets default route through eth0. This is necessary when we deploy the container in a Kubernetes cluster, where there is a high chance that the gateway of the container might be in a different subnet than the IP. As a result, Linux kernel will fail to set the gateway.
- Groups multi-word arguments correctly.
- Acts as a reaper, cleaning up zombie processes.
You can obtain urunit in two ways:
- Fetch a static binary from urunit's release page. Via the container image:
harbor.nbfc.io/nubificus/urunit:latest
, with the binary located at/urunit
.
Preparing the image๐
To differentiate traditional containers from unikernels, urunc
uses specific annotations. Therefore, to run a container with a Linux kernel on urunc
, these annotations must be configured, and the Linux kernel must be included in the container imageโs root filesystem. To simplify this process, we will use bunny.
Another important aspect is preparing the root filesystem (rootfs). Since we're booting a full Linux virtual machine, a proper rootfs must be provided. There are three main ways to do this:
- Using directly the rootfs of the container's image (requires devmapper or 9pfs).
- Creating a block image out of a container's image rootfs.
- Creating a initrd.
Using directly the container's rootfs๐
The simplest way to boot an existing container with a Linux kernel on urunc
is to reuse the containerโs rootfs. This is possible either through shared-fs between the host and the Linux VM or by using devmapper as the snapshotter. In the latter case containerd's devmapper snapshotter will create a snapshot of the container;s image in the form of a block image and urunc
can then directly attach this block image to the VM.
To set up devmapper as a snapshotter please refer to the installation guide.
Preparing the container image.๐
In this case preparing the container image involves two key steps:
- Append the Linux kernel binary to the container image.
- Set the appropriate
urunc
annotations.
These tasks can be easily automated with bunny.
Let's use as an example the redis:alpine
container image using the Linux kernel from harbor.nbfc.io/nubificus/urunc/linux-kernel-qemu:v6.14
. The respective bunnyfile
would look like:
#syntax=harbor.nbfc.io/nubificus/bunny:latest
version: v0.1
platforms:
framework: linux
monitor: qemu
architecture: x86
rootfs:
from: redis:alpine
type: raw
kernel:
from: harbor.nbfc.io/nubificus/urunc/linux-kernel-qemu:v6.14
path: /kernel
cmdline: "/usr/local/bin/redis-server"
We can build the container with:
Alternatively, if the Linux kernel was built locally, we can update the kernel section of the bunnyfile to reference the local binary:
By default, this setup will run redis-server as the init process. To include urunit in the redis:alpine image, we can use the following Containerfile:
FROM harbor.nbfc.io/nubificus/urunit:latest AS init
FROM redis:alpine
COPY --from=init /urunit /urunit
NOTE: We are working towards enabling the addition of extra files from the
bunnyfile
. We will update this page once this feature is supported.
After building the above container, make sure to specify it in the from
field of rootfs in bunnyfile
:
At last we need to modify the cmdline
section of bunnyfile
to execute urunit:
Running the container๐
Unfortunately, Docker requires additional setup to work with the devmapper snapshotter. Therefore, in order to run the container using Docker, we can only use shared-fs.
Let's find the IP of the container:
$ docker inspect <CONTAINER ID> | grep IPAddress
"SecondaryIPAddresses": null,
"IPAddress": "172.17.0.2",
"IPAddress": "172.17.0.2",
and we should be able to ping it:
ALternatively, if we want to use devmapper as the snapshotter, we can use nerdctl, which integrates seamlessly with containerd and supports devmapper out of the box.
First, transfer the container image from Dockerโs image store to containerd:
With the image now available in containerd, weโre ready to run the container using urunc and the devmapper snapshotter:
nerdctl run --rm -it --snapshotter devmapper --runtime "io.containerd.urunc.v2" redis/apline/linux/qemu:latest
Let's find the IP of the container:
$ nerdctl inspect <CONTAINER ID> | grep IPAddress
"IPAddress": "10.4.0.2",
"IPAddress": "10.4.0.2",
"IPAddress": "172.16.1.2",
and we should be able to ping it:
Using a block image๐
If we have a block image that can be used as a rootfs, we can instruct urunc
to pass this block image as a rootfs.
Preparing the container image.๐
To prepare the container image we will need to first create block image. For that purpose, we will use nginx:alpine
image and we will choose to run it on top of Firecracker. We can create the block image with the following steps:
dd if=/dev/zero of=rootfs.ext2 bs=1 count=0 seek=60M
mkfs.ext2 rootfs.ext2
mkdir tmp_mnt
mount rootfs.ext2 tmp_mnt
docker export $(docker create nginx:alpine) -o nginx_alpine.tar
tar -xf nginx_alpine.tar -C tmp_mnt
wget -O tmp_mnt/urunit https://github.com/nubificus/urunit/releases/download/v0.1.0/urunit_x86_64 # If we want urunit as init
chmod +x tmp_mnt/urunit # If we want urunit as init
umount tmp_mnt
Now we have a block image, rootfs.ext2
, generated from the nginx:alpine
container and including urunit latest release. To package everything together, we will use a file with Containerfile-like syntax, just to demonstrate how to manually define the required annotations for urunc
:
#syntax=harbor.nbfc.io/nubificus/bunny:latest
FROM scratch
COPY vmlinux /kernel
COPY nginx_rootfs.ext2 /rootfs.ext2
LABEL "com.urunc.unikernel.binary"="/kernel"
LABEL "com.urunc.unikernel.cmdline"="/urunit /usr/sbin/nginx -g 'daemon off;error_log stderr debug;"
LABEL "com.urunc.unikernel.unikernelType"="linux"
LABEL "com.urunc.unikernel.block"="/rootfs.ext2"
LABEL "com.urunc.unikernel.blkMntPoint"="/"
LABEL "com.urunc.unikernel.hypervisor"="firecracker"
We can build the container with:
Running the container๐
We can run the container with the following command:
Let's find the IP of the container:
$ docker inspect <CONTAINER ID> | grep IPAddress
"SecondaryIPAddresses": null,
"IPAddress": "172.17.0.2",
"IPAddress": "172.17.0.2",
and we should be able to curl it:
$ curl 172.17.0.2
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
Using initrd as a rootfs๐
Similarly to the previous approach, we can create an initrd instead of a block image to use as the root filesystem. To demonstrate this, we will use the traefik/whoami
container image as an example.
Preparing the container image.๐
First let's create the initrd:
mkdir tmp_rootfs
docker export $(docker create traefik/whoami) | tar -C tmp_rootfs/ -xvf -
wget -O tmp_rootfs/urunit https://github.com/nubificus/urunit/releases/download/v0.1.0/urunit_x86_64 # If we want urunit as init
chmod +x tmp_rootfs/urunit
cd tmp_rootfs
find . | cpio -H newc -o > ../rootfs.initrd
NOTE: We are working towards enabling the creation of the initrd directly from bunny. We will update this page once this feature is supported.
Now we have an initrd rootfs.initrd
generated from traefik/whoami
and with urunit that we got from its latest release. In order to pack everything together, we can use the following bunnyfile
:
#syntax=harbor.nbfc.io/nubificus/bunny:latest
version: v0.1
platforms:
framework: linux
monitor: firecracker
architecture: x86
rootfs:
from: local
type: initrd
path: rootfs.initrd
kernel:
from: harbor.nbfc.io/nubificus/urunc/linux-kernel-firecracker:v6.14
path: /kernel
cmdline: "/urunit /whoami"
We can build the container with:
Running the container๐
We can run the container with the following command:
Let's find the IP of the container:
$ docker inspect <CONTAINER ID> | grep IPAddress
"SecondaryIPAddresses": null,
"IPAddress": "172.17.0.2",
"IPAddress": "172.17.0.2",
and we should be able to curl it: