- OCaml 100%
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.hostresolves for the architecture the program was compiled for, without runninguname, 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:Noneturns it off andRunc.Seccomp.resolvetargets another architecture. A custom OCI profile is built withRunc.Seccomp.v,Runc.Seccomp.ruleandRunc.Seccomp.arg. - LSM labels come from the
~confinementargument ofRunc.Process.v, which is required and sets the OCIapparmorProfile.UnconfinedandProfile namename the profile that runc asks the kernel to switch to.Inheritwrites 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_labelsetsselinuxLabelandRunc.Linux.vcan setmountLabel, and neither appears unless the caller asks. - No new privileges is on (
Runc.Process.vdefaults 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 anexecve; 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
/procand/sysare masked or mounted read-only, and the default mounts usenosuid,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_tenantlimits 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/subuidand/etc/subgidranges on the host, which is why it is not on by default. Give the workload a~memory_limittoo. -
Put untrusted code behind a stronger boundary. One kernel privilege-escalation bug is enough to break out of a shared kernel, so a hardened
runccontainer 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.
Related work #
- runc, the reference OCI runtime.
- The OCI Runtime Specification.
- OBuilder, an OCaml build sandbox that uses runc.
- Albatross, which orchestrates MirageOS unikernels.
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.