Skip to content

Architecture

Process model

Creating a container involves three cooperating processes.

flowchart TD
    CLI["kontainer-runtime create<br/>(user shell)"]
    CLI --> Main["main<br/>Kotlin, multi-threaded"]
    Main -- "fork + exec self with<br/>_KONTAINER_IS_BOOTSTRAP=1" --> Stage1["stage-1<br/>bootstrap.c constructor<br/>single-threaded C"]
    Stage1 -- "setns paths<br/>unshare remaining ns<br/>clone(CLONE_PARENT)" --> Stage2["stage-2 (init, PID 1)<br/>Kotlin runInitProcess"]
    Stage2 -- "execve" --> Container["container process<br/>(spec.process.args)"]
    Stage1 -. "exits after clone" .-> X((·))
    Main -. "waits for init-ready,<br/>saves state.json, exits" .-> Y((·))

Main process

The CLI you invoke. Loads the OCI spec, sets up cgroup/rlimits on stage-1, handles the UID/GID mapping handshake, forwards the seccomp notify FD if configured, waits for stage-2 to reach "init ready", saves state.json, exits. Also runs prestart / createRuntime / poststart / poststop hooks from its own namespace.

Stage-1

A short-lived C bootstrap living in src/nativeInterop/cinterop/bootstrap/bootstrap.c. It calls setns for every spec.linux.namespaces[].path entry. The kernel accepts mount-ns setns only from a single-threaded process, and PID-ns joining must happen before the fork of stage-2. Stage-1 then runs unshare for the remaining namespaces and clones stage-2 with CLONE_PARENT, which makes stage-2 a sibling of main. Stage-1 exits right after.

Stage-2 (init)

PID 1 in the container. Runs runInitProcess() in Kotlin. Does prepareRootfs, pivot_root, applySysctls, applyMaskedPaths, the capabilities/setuid dance, the seccomp filter install, the createContainer hook, waits for the start signal, runs the startContainer hook, then execves into the user's program.

Container lifecycle sequence

The full create + start flow. UID/GID map handshake, seccomp notify FD forwarding, and hook points all live inside it as alt or opt blocks. Everything below the "start" divider only runs when the user invokes kontainer-runtime start #lt;id#gt; in a separate process.

sequenceDiagram
    autonumber
    actor User as User shell
    participant Main as main<br/>(Kotlin, multi-threaded)
    participant S1 as stage-1<br/>(bootstrap.c, single-threaded)
    participant S2 as stage-2 / init<br/>(Kotlin, PID 1 in container)
    participant Listener as seccomp<br/>listenerPath

    User->>Main: kontainer-runtime create #lt;id#gt;
    Note over Main: loadSpec, resolveCgroupPath,<br/>SocketNotifyListener bind
    Main->>S1: fork + execve self<br/>(env: _KONTAINER_IS_BOOTSTRAP=1,<br/>clone flags, ns paths, FDs)

    opt spec.linux.namespaces contains "user"
        S1->>S1: unshare(CLONE_NEWUSER)
        S1->>S1: prctl(PR_SET_DUMPABLE, 1)
        S1->>Main: SYNC_USERMAP_PLS (0x40) + stage-1 pid
        Note over Main: write /proc/#lt;s1#gt;/setgroups (deny if unprivileged),<br/>/proc/#lt;s1#gt;/uid_map, /proc/#lt;s1#gt;/gid_map
        Main->>S1: SYNC_USERMAP_ACK (0x41)
        S1->>S1: prctl(PR_SET_DUMPABLE, 0)
        S1->>S1: setuid(0), setgid(0)
    end

    S1->>S1: setns for spec.linux.namespaces[].path entries
    S1->>S1: unshare(remaining flags: mount, net, uts, ipc, pid, cgroup)
    S1->>S2: clone(CLONE_PARENT | SIGCHLD)
    S1->>Main: stage-2 pid (int32) over sync socket
    S1--)Main: exit

    Note over Main: cgroup.setup(stage2Pid, resolvedPath, resources),<br/>applyRlimits(stage2Pid)

    Note over S2: setLoopbackUp,<br/>prepareRootfs (mount /proc, /dev, /sys, devices, symlinks),<br/>applySpecMounts
    opt spec.hooks.createContainer
        S2->>S2: exec each hook with state JSON on stdin
    end
    Note over S2: pivot_root(rootfsPath, rootfsPath),<br/>applyRootfsPropagation,<br/>chdir(cwd), sethostname,<br/>applyLinuxDevices, applySysctls,<br/>applyMaskedPaths, applyReadonlyPaths,<br/>finalizeRootfs, applyRlimits(0),<br/>setNoNewPrivileges if requested

    S2->>S2: seccomp(SET_MODE_FILTER, ..., NEW_LISTENER)
    opt any syscall action is SCMP_ACT_NOTIFY
        S2->>Main: notify FD via SCM_RIGHTS
        Main->>Listener: connect(listenerPath)
        Main->>Listener: forward FD + container state JSON
        Main->>S2: seccompNotifyDone
    end

    Note over S2: applyBoundingSet, setKeepCaps,<br/>setgid, setuid, clearKeepCaps,<br/>applyCapabilities (capset),<br/>write AppArmor / SELinux exec label to /proc/self/attr/*

    S2->>Main: Init Ready
    Note over Main: save state.json (status=created),<br/>save internal config
    opt spec.hooks.prestart / createRuntime
        Main->>Main: exec each hook with state JSON on stdin
    end
    Main--)User: exit 0
    S2->>S2: closeRange (fallback via /proc/self/fd if seccomp blocks it)
    S2->>S2: listen on /tmp/kontainer-#lt;id#gt;.sock

    Note over User,S2: A separate process runs kontainer-runtime start #lt;id#gt; below.

    User->>Main: kontainer-runtime start #lt;id#gt;
    Main->>Main: loadState, check status == created
    Main->>S2: notifyContainerStart via /tmp/kontainer-#lt;id#gt;.sock
    Main->>Main: save state.json (status=running)
    opt spec.hooks.poststart
        Main->>Main: exec each hook with state JSON on stdin
    end
    Main--)User: exit 0

    opt spec.hooks.startContainer
        S2->>S2: exec each hook with state JSON on stdin
    end
    S2->>S2: execve(spec.process.args)
    Note over S2: The process image is now the container's program.<br/>Kontainer-runtime code is gone from this PID.

The opt blocks fire when the corresponding OCI feature is present in the spec. On a bare-bones spec (no user namespace, no SCMP_ACT_NOTIFY, no hooks) the flow collapses to the linear main-path: fork stage-1, stage-1 unshares and clones stage-2, stage-2 does rootfs + capability + seccomp setup, main saves state and exits, then start wakes stage-2 for execve.

Why the C bootstrap

Kotlin/Native spawns GC and runtime worker threads at main. Several kernel operations reject multi-threaded callers. setns(fd, CLONE_NEWNS) returns EINVAL, and PID-ns joining requires the caller to fork afterwards. The bootstrap runs before any Kotlin code, so it can join a mount namespace by path, join a PID namespace before forking stage-2, and unshare the rest of the namespaces.

Stage-2 starts as a fresh single-thread process in the new namespaces. The Kotlin runtime takes over from there.

Modules

For the directory tree that maps each component onto the source layout, see Contributing → Repo layout.