OCI runtime spec types and runc command wrapper

README.md

runc #

Build OCI runtime configurations and drive runc from OCaml.

On Linux, a container is a process started inside a set of namespaces and cgroups, with a set of capabilities and a seccomp filter. runc is the reference tool that starts one from an OCI bundle, which is a root filesystem plus a config.json that spells all of this out. That file is easy to get wrong by hand, and its insecure defaults are easy to leave in. This library builds the configuration from typed OCaml values with hardened defaults and reads it back. It also runs the runc command-line tool to create, start, signal, inspect and delete containers, and sets up veth networking for them (Runc.Network).

Installation #

$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam install runc

Usage #

Run a command in a rootfs #

Given an OCI bundle, a directory holding a config.json and a root filesystem, Runc.Command.run starts it:

let run () =
  Eio_main.run @@ fun env ->
  Eio.Switch.run @@ fun _sw ->
  let runc = Runc.Command.v ~env () in
  match Runc.Command.run ~id:"hello" ~bundle:"./bundle" runc with
  | Error e -> Fmt.epr "run: %a@." Runc.Command.pp_error e
  | Ok container -> (
      match Runc.Command.state runc container with
      | `Stopped -> Fmt.pr "stopped@."
      | `Running | `Created -> (
          (match Runc.Command.kill runc container Sys.sigterm with
          | Ok () -> ()
          | Error m -> Fmt.epr "kill: %s@." m);
          match Runc.Command.delete ~force:true runc container with
          | Ok () -> ()
          | Error m -> Fmt.epr "delete: %s@." m))

Build the bundle #

The config.json in the bundle is an OCI runtime configuration. Runc.Config builds it from typed parts and serialises it:

(* AppArmor confinement is declared per launch, never inherited by default.
   [Inherit] keeps the label of whatever supervises runc, which is what this
   example asks for; a real deployment declares
   [Runc.Confinement.Profile name] for a profile its kernel has loaded. *)
let confinement = Runc.Confinement.Inherit
let process = Runc.Process.v ~confinement [ "/bin/echo"; "hello" ]
let root = Runc.Root.v "rootfs"
let config = Runc.Config.v ~process ~root ()
let json = Runc.Config.to_json config
(* write `json` to `./bundle/config.json` and extract a rootfs tar into
   `./bundle/rootfs` before calling Runc.Command.run *)

Lifecycle and configuration #

Runc.Command.v wraps the runc binary. Runc.Command.run creates and starts a container in one call, while start, kill, delete, state and list act on containers that were created without being started. Each part of the OCI runtime specification has its own module in the configuration: User, Mount, Namespace, Capability and Capabilities, Rlimit, Process (with its Confinement), Root, Seccomp, Id_map, Device and Device_rule, Resources, Linux, and at the top Config, which converts to and from JSON.

Security #

runc isolates containers with Linux namespaces and cgroups, so the boundary between a container and the host is the kernel's system call interface. Runc.Config.v and Runc.Linux.v build a configuration that narrows that interface as far as a shared kernel allows. By default:

  • Seccomp uses moby's default profile (Apache-2.0, shipped unchanged), which Runc.Seccomp.host resolves for the architecture the program was compiled for, without running uname, and for its capabilities. The profile denies by default and allows the system calls that ordinary workloads need; mount, kexec, module loading, BPF and the like stay denied unless the matching capability is granted. Runc.Linux.v ~seccomp:None turns it off and Runc.Seccomp.resolve targets another architecture. A custom OCI profile is built with Runc.Seccomp.v, Runc.Seccomp.rule and Runc.Seccomp.arg.
  • LSM labels come from the ~confinement argument of Runc.Process.v, which is required and sets the OCI apparmorProfile. Unconfined and Profile name name the profile that runc asks the kernel to switch to. Inherit writes nothing, so no switch happens and the supervisor's label stays in force; the choice is still written down where a reader can see it. Runc.Process.v ~selinux_label sets selinuxLabel and Runc.Linux.v can set mountLabel, and neither appears unless the caller asks.
  • No new privileges is on (Runc.Process.v defaults to ~no_new_privileges:true), so a process cannot gain privileges through a setuid binary.
  • Capabilities (Runc.Capabilities.default) have a small bounding set and empty inheritable and ambient sets. Linux keeps five capability sets per process (capabilities(7)). The bounding set limits what the process can ever gain, and the inheritable and ambient sets decide which capabilities survive an execve; with both empty, a new program starts with no capability beyond those its own file grants.
  • Namespaces for pid, network, ipc, uts and mount are unshared.
  • Sensitive paths under /proc and /sys are masked or mounted read-only, and the default mounts use nosuid,nodev,noexec.
  • Networking names each veth from a SHA-256 of the container id, the bridge and an index, so two containers never pick the same name and never tear down each other's interfaces.

Multi-tenant workloads #

Namespaces alone do not keep apart tenants that do not trust each other.

  • Run in a user namespace, so that root in the container maps to an unprivileged uid on the host and an escape ends up as a user with no power. Passing id mappings adds the user namespace, and Runc.Resources.multi_tenant limits the number of processes so that one tenant cannot fork-bomb the others:

    let shared_host_linux ~fs =
      let map = Runc.Id_map.v ~container_id:0 ~host_id:100000 ~size:65536 in
      Runc.Linux.v ~uid_mappings:[ map ] ~gid_mappings:[ map ]
        ~resources:(Runc.Resources.multi_tenant ~fs) ()
    

    A user namespace needs matching /etc/subuid and /etc/subgid ranges on the host, which is why it is not on by default. Give the workload a ~memory_limit too.

  • Put untrusted code behind a stronger boundary. One kernel privilege-escalation bug is enough to break out of a shared kernel, so a hardened runc container adds a layer of defence but does not separate tenants by itself. Run untrusted tenants' containers inside a microVM, such as Firecracker or Kata, where the boundary is a hypervisor and not the host kernel.

Licence #

ISC. See LICENSE.md.

The seccomp profile in lib/seccomp_default.json is moby's default profile, included unchanged under the Apache License 2.0; its copyright belongs to the Moby project contributors.