Operating Systems · Isolation and Namespaces

Time Namespaces and the State That No Namespace Covers

How a time namespace shifts the monotonic and boottime clocks without changing the wall clock, and which kernel resources remain global no matter how many namespaces a process joins.

Eight namespace types exist, and they cover process identity, hostnames, mounts, network stacks, IPC objects, users, cgroups, and — since Linux 5.6 — the monotonic and boot-time clocks. The list reads like a complete partition of the machine, and it is not. A container built from all eight still shares the wall clock, the kernel’s random pool, the page cache’s global limits, the load average, the set of loaded modules, and every sysctl that was never namespaced. Knowing which of those leaks matter is the difference between a container that is isolated in the ways its operator believes and one that is isolated only in the ways the kernel implements.

What the time namespace shifts

CLONE_NEWTIME creates a namespace with two offsets, written once each to /proc/<pid>/timens_offsets as a signed number of seconds and nanoseconds:

monotonic           0         0
boottime      172800         0

The monotonic offset shifts CLOCK_MONOTONIC and, with it, CLOCK_MONOTONIC_COARSE and CLOCK_MONOTONIC_RAW; the boottime offset shifts CLOCK_BOOTTIME and CLOCK_BOOTTIME_ALARM. CLOCK_REALTIME is never shifted. The design intent is checkpoint and restore: a process restored from a checkpoint taken days earlier needs its monotonic clock to continue from where it stopped, because code that computes elapsed time as the difference of two monotonic readings would otherwise observe a gap of days and conclude that a timeout had expired. Shifting monotonic is enough for that, and shifting realtime would be wrong — realtime is a property of the world, not of the process.

The offsets are set before any process enters the namespace. unshare(CLONE_NEWTIME) creates the namespace but places the caller in the parent, and the caller’s children enter the new namespace at fork; the offsets must be written before the first such fork. After that, the file is immutable, which is the same rule as uid_map and for the same reason: a clock that can be shifted under a running process would let it observe a discontinuity in a value it is entitled to treat as monotonic.

/* The namespace is created but not yet joined; write offsets, then fork. */
if (unshare(CLONE_NEWTIME) < 0)
        die("unshare");

int fd = open("/proc/self/timens_offsets", O_WRONLY);
dprintf(fd, "monotonic %lld %ld\nboottime %lld %ld\n", secs, nsecs, secs, nsecs);
close(fd);

if (fork() == 0)
        /* This child reads the shifted clocks. */
        execve("/usr/bin/workload", argv, envp);

The kernel implements the shift by storing the offsets in the namespace and adding them at the point where a clock is converted to a timestamp for a process, rather than at the point where the hardware counter is read. The vDSO carries the same offsets so that clock_gettime can apply them without entering the kernel, which is why the shift costs nothing in the common path. The placement also sets the boundary of the mechanism: only clocks with a namespaced offset are affected. Timers and timeouts are converted back to host time by subtracting the namespace offset, so a process whose monotonic clock is shifted has its timeouts expire on its own frame rather than on the host’s, which is the behaviour a restored checkpoint needs.

The wall clock is shared, and that is not a bug

CLOCK_REALTIME reads the same value in every namespace, and settimeofday requires CAP_SYS_TIME in the initial user namespace. A container cannot change the host’s wall clock, and it cannot make its own wall clock differ. The asymmetry with monotonic is deliberate: a checkpointed process restored on a different machine must have its monotonic clock continue, but its wall clock must agree with the machine it is running on, or every timestamp it writes to a log or a database would be wrong by the length of the gap.

The consequence is that a restored process observing a realtime value days ahead of its last recorded one can still conclude that a certificate expired or a token aged out. The time namespace fixes elapsed-time reasoning, not calendar reasoning, and a workload that mixes the two — computing an expiry from a stored realtime timestamp and comparing to CLOCK_MONOTONIC — will behave inconsistently after a restore.

Resources with no namespace at all

The uts namespace gives each container a hostname and domain name; it does not give it a separate kernel version, and uname reports the host’s release string to every namespace. A workload that branches on the kernel version sees the host’s answer, which is correct and is also why a container cannot be made to believe it runs on a different kernel.

The random pool is global. /dev/urandom in two containers draws from the same ChaCha20 stream, and neither can exhaust it — the pool never blocks after initialisation — but the state is shared, and a container that could observe the raw pool state would learn about the host’s. The kernel does not expose the raw state through /dev/random in a way that permits that, and the getrandom syscall is namespace-agnostic by design.

The load average is global. /proc/loadavg in a container reports the host’s run-queue length, not the container’s, because the calculation is performed over the global run queues and there is no per-namespace variant. A workload that autoscales on load average therefore scales on the host’s pressure rather than its own, which is either the intended signal or a serious misreading depending on what the operator assumed. /proc/stat similarly reports host-wide CPU totals, while /proc/<pid>/stat reports per-process values that are namespace-consistent.

Kernel modules are global. init_module and delete_module require CAP_SYS_MODULE in the initial user namespace, so no container can load one, and no container can prevent one from being loaded. The set of loaded modules, the contents of /sys/module, and the parameters under /sys/module/<name>/parameters are shared state. A container that reads a module parameter reads the host’s.

The page cache and the dentry cache are global in their limits and per-cgroup in their accounting. A container’s file reads populate the host page cache, and the memory is charged to the container’s cgroup, but the cache is not partitioned: a page brought in by one container is available to another that opens the same file, and the accounting stays with the first. The same file read by two containers is charged twice in one sense and once in another, which is why memory.stat totals across sibling cgroups can exceed the physical memory attributable to their files.

Sysctls, and the map of which ones move

The net namespace carries its own copy of most net.ipv4 and net.ipv6 parameters, so a container can set net.ipv4.ip_forward without affecting the host. The ipc namespace carries its own kernel.msgmax, kernel.msgmnb, and kernel.shmall. The uts namespace carries kernel.hostname and kernel.domainname. Everything else under /proc/sys is global: vm.swappiness, kernel.core_pattern, fs.file-max, kernel.randomize_va_space.

kernel.core_pattern deserves its own mention because it is a pipe, not just a value. It names a program that the kernel executes on a core dump, with the dumping process’s credentials. A container that could write it would gain code execution on the host, which is why the write requires CAP_SYS_ADMIN in the initial user namespace and why the value is global. A container that sets it and finds the write succeeding is a container that has escaped, not one that has configured itself.

The sysctl isolation map is not derivable from the file layout. /proc/sys/net looks namespaced and mostly is; /proc/sys/kernel looks global and mostly is, with the notable exception of the hostname and domain name, which live in the uts namespace and are reachable under kernel. for historical reasons. Reading the value from inside a container and comparing it with the host’s is the only reliable test.

Why the gaps persist

Each namespace type was added to solve a specific problem, and the ones that were never added are the ones where a per-namespace copy would either be meaningless or would break an invariant the kernel depends on. A per-namespace page cache would defeat the purpose of a cache. A per-namespace load average would require a per-namespace run queue, which is a scheduler change rather than a namespace change. A per-namespace module list would mean per-namespace kernel text.

The gaps are therefore not oversights to be closed but boundaries of the model: namespaces partition names and identifiers, and the resources that remain global are the ones that are neither named per-process nor sensibly duplicated. A container’s isolation is the union of what its namespaces partition, what its cgroup limits, what its capability set permits, and what its syscall filter allows — and the last two are what constrain the global resources it can still touch. The mechanisms in Capabilities and the Decomposition of Root Privilege and seccomp-BPF and the Filtering of System Calls are the ones that decide whether a shared resource is reachable, and they are the reason a container with an incomplete namespace set can still be safe while a container with a complete one and an unfiltered syscall table is not.

References

  1. time_namespaces(7) — Linux manual page — man7.org
  2. clock_gettime(2) — Linux manual page — man7.org
  3. namespaces(7) — Linux manual page — man7.org