Linux containers are a fundamental technology for modern software deployment, and while Docker has become synonymous with them, it’s far from the only way to build and run them. In fact, Docker itself relies on lower-level tools that you can use directly: runc for running containers and buildah for building container images. Understanding these tools gives you a deeper insight into how containers actually work and offers more flexibility in your workflows.

Let’s see buildah and runc in action. First, we’ll build a simple container image using buildah, then run it with runc.

# Build a minimal image
buildah from scratch
buildah config --label maintainer="Your Name" --label version="1.0"
buildah run -- /bin/bash -c 'echo "Hello from my container!" > /hello.txt'
buildah config --cmd '["/bin/bash", "-c", "cat /hello.txt"]'
container_id=$(buildah from scratch)
buildah config --label maintainer="Your Name" --label version="1.0" $container_id
buildah run --container $container_id -- /bin/bash -c 'echo "Hello from my container!" > /hello.txt'
buildah config --container $container_id --cmd '["/bin/bash", "-c", "cat /hello.txt"]'
image_name="my-hello-image:latest"
buildah commit $container_id $image_name

# Inspect the image manifest (optional)
skopeo inspect docker://$image_name

# Run the container with runc
# First, export the image to a tarball
buildah push $image_name docker-archive:container.tar

# Create a OCI runtime bundle from the tarball
mkdir bundle
tar -xf container.tar -C bundle
# We need to ensure the config.json is correctly placed for runc
# In a real scenario, buildah would create this structure.
# For simplicity here, we'll assume a basic config.json exists or is generated.
# A more robust approach involves using tools like oci-image-tool or manually creating config.json
# For this example, let's assume a minimal config.json is present in the bundle.

# If you don't have a config.json, you'd typically generate one.
# For a quick demo, we can create a very basic one.
# In a real buildah workflow, this would be handled by the commit/push process.
cat <<EOF > bundle/config.json
{
  "ociVersion": "1.0.0",
  "process": {
    "terminal": true,
    "user": {
      "uid": 0,
      "gid": 0
    },
    "args": [
      "cat",
      "/hello.txt"
    ],
    "env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "cwd": "/"
  },
  "root": {
    "path": "rootfs",
    "readonly": true
  },
  "hostname": "container",
  "mounts": [
    {
      "destination": "/proc",
      "type": "proc",
      "source": "proc"
    },
    {
      "destination": "/dev",
      "type": "tmpfs",
      "source": "tmpfs",
      "options": ["nosuid", "strictatime", "mode=755", "size=65536k"]
    },
    {
      "destination": "/dev/pts",
      "type": "devpts",
      "source": "devpts",
      "options": ["nosuid", "newinstance", "ptmxmode=0666", "gcd=5"]
    },
    {
      "destination": "/dev/shm",
      "type": "tmpfs",
      "source": "shm",
      "options": ["nosuid", "strictatime", "mode=1777", "size=65536k"]
    }
  ],
  "hooks": {}
}
EOF

# Ensure the rootfs directory exists and contains the unpacked image layers
# The tarball extraction above should have created the rootfs.
# If not, you'd need to unpack the layers into a rootfs directory.
# For this example, we'll assume the 'bundle' directory contains a 'rootfs'
# subdirectory with the unpacked image content.

# Let's simulate unpacking if it wasn't done by the tar command directly
# In a real scenario, you'd layer the image contents.
# For this simple case, we'll assume 'bundle' has the necessary structure after tar.
# If tar created 'bundle/rootfs', then we are good. Otherwise, manual setup needed.
# For this demo, let's assume the tarball extraction placed content directly in 'bundle'
# and we need to move it to 'bundle/rootfs'.
mkdir bundle/rootfs
mv bundle/* bundle/rootfs/
# This is a simplification; actual image unpacking involves handling layers.

# Now run with runc
runc --root bundle run mycontainer

The problem buildah and runc solve is the need for a standardized, low-level way to build and run containers, independent of any single orchestrator or runtime like Docker. buildah allows you to construct container images from scratch or existing base images, layer by layer, without requiring a daemon. It manipulates the filesystem and creates an OCI-compliant image manifest. runc is a lightweight, low-level container runtime that can take an OCI-compliant bundle (a directory containing the container’s filesystem and a configuration file) and execute it. It interacts directly with Linux kernel features like namespaces and cgroups to isolate the container.

Internally, buildah uses libcontainer (or similar libraries) to interact with the operating system’s capabilities for creating the container filesystem. When you run buildah run, it essentially executes commands within a temporary container environment. buildah commit then takes the state of this environment and packages it into an OCI image format, which is essentially a tarball containing the filesystem layers and a config.json file describing the container’s runtime configuration.

runc is even more fundamental. It reads the config.json file, sets up the necessary namespaces (like PID, network, mount, user, UTS, IPC) and cgroups for resource control, mounts the container’s root filesystem (which is a directory specified in the config.json), and then executes the command specified in the config.json within that isolated environment. It’s the OCI standard runtime that most higher-level tools, including Docker’s containerd, ultimately delegate to.

The config.json is the heart of an OCI container bundle. It’s a JSON file that describes everything about how the container should run: the command to execute, environment variables, working directory, user ID, process limits, mounts, and how the root filesystem is structured. runc reads this file and translates its specifications into actual Linux kernel system calls. For instance, the "process" section dictates the entrypoint and arguments, while the "root" section points to the container’s filesystem. The "mounts" array defines how host and container directories are made available, including special filesystems like /proc, /dev, and /dev/shm.

A common point of confusion is how the container’s root filesystem is presented to runc. The OCI specification defines a "root" field in config.json which typically includes a "path" pointing to a directory that contains the container’s filesystem. This directory is often structured with a rootfs subdirectory, where the actual unpacked filesystem layers reside. runc then mounts this rootfs as the container’s root directory, often with a readonly flag unless specified otherwise. The buildah commit command, when pushing to a format like docker-archive, essentially creates this structure, and runc expects it when it receives the bundle.

The next step after mastering buildah and runc is to explore tools that build upon them, such as Podman, which provides a Docker-compatible CLI but uses buildah and runc (or crun) under the hood, allowing for daemonless container management.

Want structured learning?

Take the full Linux & Systems Programming course →