Skip to content
academia.sh

Lesson 05 / 09

Container Runtime

The common six-mechanism bundle leaves 12 facts, the strict eight-mechanism bundle leaves 10: adding two mechanisms narrows the surface by only 2 facts, and the open axis count stands at 7 after the fourth mechanism.

Contents

Four lessons measured four mechanism families one by one: namespaces narrowed visibility, the cgroup narrowed consumption, capability dropping narrowed privilege, the mandatory label narrowed access. Each left something open on its own axis, and a narrowing path was written next to each. On a real system, though, these mechanisms are not turned on one by one. A program reads a definition file, applies every mechanism written in the file in the correct order, and starts the process. That program’s name is the container runtime.

This lesson treats the runtime as a bundle of mechanisms and asks a single question: how much does adding a mechanism narrow the surface. Container images, the layer model, placement, scaling, and orchestration were measured in the DevOps curriculum’s Containers and Container Orchestration courses; they are not repeated. The subject here is only the parts the kernel provides and how a runtime brings them together.

What the Runtime Does

The runtime takes as input a bundle made of a directory and a definition file. The directory is the process’s root filesystem tree; the definition file writes which mechanisms open with which settings. The runtime interprets this file, translates it into kernel calls, and starts the process. Its job is exactly that: it does not invent a new isolation type, it applies the existing ones in order.

# example runtime definition (abbreviated, field names are examples)
# — not executed
process:
  command: /usr/local/bin/metrics-collector
  uid: 10001
  capability-ceiling: [CAP_NET_BIND_SERVICE]
  no-new-privileges: yes
  access-label: system_u:system_r:metrics_t:s0
root:
  path: root-tree
  read-only: yes
mounts:
  - target: /data/metrics
    options: [rw, nosuid, nodev, noexec]
namespaces: [process, mount, network, machine, user, ipc, cgroup]
id-mapping:
  - inside: 0
    outside: 100000
    size: 65536
cgroup:
  path: /metrics.slice/metrics-collector
  cpu-quota: 20000/100000
  memory-ceiling: 536870912

Every line in the definition corresponds to one of the previous four lessons. The field names vary by specification, and the dump has not been executed; no numeric claim comes from it.

Order is binding, and the wrong order leaves a silent gap. The user namespace opens first, because it is the only type that lets an unprivileged user open the other types, and the identity mapping must be written before it closes. The cgroup is prepared before the process is started, or the first seconds pass unbounded. The root tree is changed after the process moves into its own mount namespace; changing it first pollutes the machine’s mount list. Capability dropping and the no-new-privileges flag are left for last, right before the exec; done early, the preparation steps fail, done late, the dropping is never applied.

# example dump — not executed
$ runtime create metrics-01 --bundle ./bundle
$ runtime start  metrics-01
$ runtime state  metrics-01
$ runtime kill   metrics-01 TERM
$ runtime delete metrics-01

The lifecycle has five steps, and create and start are separate. Create sets up every mechanism and holds the process before it starts; start only ends the hold. The reason for the split is operational: it allows the configuration to be verified after all isolation is set up, while the process has not yet run a single command.

The runtime itself is also a process and needs privilege to set up the mechanisms. There are two operating modes. In privileged mode, the runtime runs as a privileged service, opens every mechanism directly, and stays outside the isolation it builds. In rootless mode, the chain starts with a user namespace: the runtime first opens a user namespace, writes the identity mapping, and sets up the other mechanisms from inside that namespace. The second mode closes no additional fact in this course’s measurement, but it narrows the blast radius in the event of a flaw — because the runtime’s own capabilities are also bounded by the mapping.

A boundary that stays outside the bundle must also be written down openly. All of these mechanisms share a single kernel; the runtime separates processes from one another, it does not duplicate the kernel. If a separate kernel is wanted, what is needed is a different technology class, and it is not the subject of this course. In the measurement’s last section, why the kernel axis never closes comes from exactly this sharing.

Two Bundles

  • IM31The common bundle is six mechanisms: the process, mount, network, and UTS namespaces, the cgroup, capability dropping. The user namespace and the mandatory label are not in this bundle.
  • IM32The strict bundle is eight mechanisms: the user namespace and the mandatory label are added to the common bundle.
  • IM33 — The bundles differ only in their mechanism list; the fact set, the axes, and the facts left open are the same as in the shared definition.
  • IM34 — The model counts eight mechanisms. The system call filter a real runtime applies, and the step of masking some paths of the process file system, are not counted as mechanisms in the model; both are written below as narrowing paths.
"""Shared definition: isolation is a filter. A container runtime is a
BUNDLE of mechanisms. What is measured: REMAINING EXPOSED SURFACE."""

FACTS = [
    ("other-processes-list", "process"), ("own-process-number", "process"),
    ("ancestor-process-chain", "process"),
    ("root-filesystem-tree", "mount"), ("other-mounts", "mount"),
    ("shared-temp-dir", "mount"),
    ("machine-interfaces", "network"), ("machine-routing-table", "network"),
    ("listening-ports", "network"),
    ("user-id-mapping", "user"), ("file-ownership", "user"),
    ("machine-name", "machine"),
    ("cpu-share", "resource"), ("memory-limit", "resource"),
    ("used-memory", "resource"), ("cpu-count", "resource"),
    ("kernel-version", "kernel"), ("kernel-settings", "kernel"),
    ("system-load", "kernel"), ("clock", "kernel"),
    ("capability-set", "privilege"), ("file-permissions", "privilege"),
    ("access-label", "label"), ("policy-rules", "label"),
]
MECHANISMS = {
    "pid-namespace":     {"axis": "process", "left_open": ["own-process-number"]},
    "mount-namespace":   {"axis": "mount", "left_open": ["root-filesystem-tree"]},
    "network-namespace": {"axis": "network", "left_open": []},
    "user-namespace":    {"axis": "user", "left_open": ["file-ownership"]},
    "uts-namespace":     {"axis": "machine", "left_open": []},
    "cgroup":            {"axis": "resource", "left_open": ["cpu-count"]},
    "capability-dropping": {"axis": "privilege", "left_open": ["file-permissions"]},
    "mandatory-label":   {"axis": "label", "left_open": ["access-label"]},
}
COMMON = ["pid-namespace", "mount-namespace", "network-namespace",
          "uts-namespace", "cgroup", "capability-dropping"]
STRICT = COMMON + ["user-namespace", "mandatory-label"]


def visible(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    closed = set()
    for m in mechanisms:
        v = MECHANISMS[m]
        closed |= {o for o, e in facts if e == v["axis"]}
        closed -= set(v["left_open"])
    return [o for o, _ in facts if o not in closed]


def open_axes(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    g = set(visible(mechanisms, facts))
    return sorted({e for o, e in facts if o in g})


print("ORACLE  fact:", len(FACTS), "| axis:", len({e for _, e in FACTS}))
print()
print("bundle    mechanism  closed  visible  open axis")
for name, d in (("bare", []), ("common", COMMON), ("strict", STRICT)):
    g = visible(d)
    print(f"  {name:8s} {len(d):7d}  {len(FACTS) - len(g):7d}"
          f"  {len(g):7d}  {len(open_axes(d)):10d}")
print()
print("difference between the two bundles:", [d for d in STRICT if d not in COMMON])
print("facts it makes a difference to     :",
      sorted(set(visible(COMMON)) - set(visible(STRICT))))
print("the two mechanisms narrow the surface by",
      len(visible(COMMON)) - len(visible(STRICT)), "facts")
ORACLE  fact: 24 | axis: 9

bundle    mechanism  closed  visible  open axis
  bare           0        0       24           9
  common         6       12       12           7
  strict         8       14       10           7

difference between the two bundles: ['user-namespace', 'mandatory-label']
facts it makes a difference to     : ['policy-rules', 'user-id-mapping']
the two mechanisms narrow the surface by 2 facts

Three numbers stand side by side. Oracle: 24 facts, 9 axes. Isolation: the common bundle closes 12 facts, the strict bundle 14 facts. Remaining exposed surface: 12 facts in the common bundle, 10 facts in the strict bundle; 7 axes in both.

This is the strongest payment on the course’s second claim. Going from the common bundle to the strict bundle grows the mechanism count by a third, adds two separate mechanisms, and noticeably increases configuration and operational load — and it narrows the surface by only 2 facts. The facts that narrow are user-id-mapping and policy-rules. The open axis count does not change at all.

The result does not mean the two mechanisms are worthless, and it should not be read that way. The user namespace narrows the scope of privilege by closing identity mapping; the mandatory label, as counted in the previous lesson, binds 28 of 36 access pairs to policy. The measurement does not count these; it only counts visibility. What it says is narrow and precise: on the visibility side, the two mechanisms’ gain is two facts.

The measurement’s practical use also comes from this. When deciding whether to tighten a bundle, the question to ask should not be “how many mechanisms did I turn on” but “which fact is still visible.” The first question rewards the length of the configuration; the second shows where the gain stops. Written down, the difference between the two bundles is only two lines, and those two lines can be weighed against the cost of tightening.

As Mechanisms Are Added

Comparing the bundles end to end does not show where the gain accumulates. When the same strict bundle is built mechanism by mechanism, the curve becomes readable.

  • IM35 — The order of addition is the order in the strict bundle’s list.
  • IM36 — The fact added in the sweep, kernel-build-options, belongs to the kernel axis; the removed clock is on the same axis too.
# --- This block builds on the previous block: FACTS, MECHANISMS, COMMON, STRICT,
# visible and open_axes come from there.
print("as mechanisms are added (in strict-bundle order)")
for i in range(len(STRICT) + 1):
    added = STRICT[i - 1] if i else "-"
    print(f"  {i} mechanism ({added:20s}) visible {len(visible(STRICT[:i])):2d}"
          f"  open axis {len(open_axes(STRICT[:i]))}")
print()
g = set(visible(STRICT))
print("left open after the strict bundle:", len(g), "facts (by axis)")
for e in open_axes(STRICT):
    o = [x for x, ax in FACTS if ax == e and x in g]
    print(f"  {e:10s} {len(o)}  {', '.join(o)}")
print()
ADDED = FACTS + [("kernel-build-options", "kernel")]
MISSING = [(o, e) for o, e in FACTS if o != "clock"]
print("fact set sweep (strict bundle applied)")
print("set                             fact  axis  visible  open axis")
for name, facts in (("base", FACTS), ("+kernel-build-options", ADDED),
                     ("-clock", MISSING)):
    print(f"  {name:29s} {len(facts):4d}  {len({x for _, x in facts}):5d}"
          f"  {len(visible(STRICT, facts)):7d}  {len(open_axes(STRICT, facts)):10d}")
as mechanisms are added (in strict-bundle order)
  0 mechanism (-                   ) visible 24  open axis 9
  1 mechanism (pid-namespace       ) visible 22  open axis 9
  2 mechanism (mount-namespace     ) visible 20  open axis 9
  3 mechanism (network-namespace   ) visible 17  open axis 8
  4 mechanism (uts-namespace       ) visible 16  open axis 7
  5 mechanism (cgroup              ) visible 13  open axis 7
  6 mechanism (capability-dropping ) visible 12  open axis 7
  7 mechanism (user-namespace      ) visible 11  open axis 7
  8 mechanism (mandatory-label     ) visible 10  open axis 7

left open after the strict bundle: 10 facts (by axis)
  kernel     4  kernel-version, kernel-settings, system-load, clock
  label      1  access-label
  mount      1  root-filesystem-tree
  privilege  1  file-permissions
  process    1  own-process-number
  resource   1  cpu-count
  user       1  file-ownership

fact set sweep (strict bundle applied)
set                             fact  axis  visible  open axis
  base                            24      9       10           7
  +kernel-build-options           25      9       11           7
  -clock                          23      9        9           7

The shape of the curve is a result on its own. The first four mechanisms drop the visible fact count from 24 to 16: eight facts. The last four mechanisms drop it from 16 to 10: six facts. The open axis count, though, never changes again after the fourth mechanism — four mechanisms were added from the fifth through the eighth, and the axis count stayed at 7.

The reason is written in the last table. Seven of the 10 facts left open after the strict bundle are one apiece on seven separate axes. Because every mechanism leaves one fact open on its own axis, that axis never drops off the list. Something is always left open on seven axes, and this does not close by adding mechanisms. This is exactly the course’s second claim: adding a mechanism reduces facts but not axes.

The sweep confirms the same structure and gives a different result from the previous lessons. When the twenty-fifth fact is added to the kernel axis, the surface rises from 10 to 11: the addition maps directly onto the surface. When a fact is removed, the surface drops to 9, but the open axis count stays at 7. Where does the difference come from — in the previous lessons, the added fact fell onto a closed axis and the mechanism closed it too; here, the axis was never closed.

The Axis That Never Closes: Kernel

Four of the 10 open facts are gathered on a single axis, and that axis’s name is kernel: kernel version, kernel settings, system load, and clock. These are the single largest block of the exposed surface in the strict bundle, and the reason is not a gap in the model — there is no mechanism that closes the kernel axis. A namespace does not produce a second kernel; the machine has a single kernel, and every process talks to it. This is isolation’s most fundamental boundary, and it is not overcome by configuration.

By contrast, the axis can be narrowed, and there are three ways. First, masking or making read-only the paths that publish kernel information: in the process’s root tree, these paths are either never visible or not writable. This is what a runtime does by default, and it is one of the steps not counted as a mechanism in the model. Second, giving separately the kernel settings that can be split by namespace; not all settings can be split, but the ones that can stop showing the machine’s value. Third, narrowing the set of calls a process can make to the kernel with a system call filter: the filter does not make a fact invisible, but it reduces the operations that correspond to the visible fact. None of the three zeroes out the surface; the honest reading of the measurement is: something on this axis will always be seen, and the design is made accordingly.

For the remaining six facts, the narrowing paths were written in the previous lessons: shrinking the root tree and mounting it read-only, reading the quota and binding pool sizing to it, clearing ownership flags and mounting the file system with nosuid, normalizing ownership to the mapped range, naming the access label so it carries no hidden information. All five follow the same pattern: the fact left open is narrowed not on the process, but in the process’s world.

Summary

  • A container runtime does not invent a new isolation type; it is a program that applies the mechanisms in a definition file in the correct order. Order is binding, and the wrong order leaves a silent gap.
  • The common six-mechanism bundle leaves 12 facts open, the strict eight-mechanism bundle 10 facts: adding two mechanisms narrows the surface by only 2 facts, and the facts that narrow are user-id-mapping and policy-rules.
  • The open axis count stands at 7 after the fourth mechanism and does not change at all when four more mechanisms are added; adding a mechanism reduces facts, not axes.
  • Of the 10 facts left open after the strict bundle, seven are one apiece on seven separate axes; the remaining four are on the kernel axis, and no mechanism closes that axis.
  • The kernel axis cannot be closed, but it can be narrowed: masking the paths that publish information, giving namespace-splittable settings separately, and a system call filter. Every number in the model is a full count over the fiction, not a measurement.

Next Step

This topic counted what isolation can and cannot close; the strict bundle closed 14 of 24 facts and left 10 open. Whether the 14 closed facts have genuinely closed has not been tested yet. This course’s second topic asks exactly that question, and its answer points in the opposite direction from the first topic: tools that read a process’s conversation with the kernel, sample CPU usage, capture events inside the kernel, and inspect a crashed process’s core afterward bring back some of the facts isolation closed. The next lesson measures the first of these, system call tracing: it counts how many facts come back and the cost it adds to the run. This course’s two topics are each other’s opposite, and it is written openly — observation punctures isolation.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close