#!/bin/sh # Keep supervised native services inside the CURRENT network namespace of a # Docker anchor container, and stop them when that anchor is gone. # # ── THE FAILURE THIS EXISTS FOR ──────────────────────────────────────────── # OpenFUT's Core and host are native binaries that must run inside the anchor # container's netns. Recreating (or merely restarting) that container gives it a # NEW netns; the already-running services stay in the old one. Measured on # staging 2026-08-22: # # anchor 37288a0fe816 net:[4026539938] -> 55541a03b66e net:[4026540033] # core pid 2081773 net:[4026539938] (unchanged, now orphaned) # host pid 2081791 net:[4026539938] (unchanged, now orphaned) # systemd: both "active" traffic: ConnectionResetError # # Both units report HEALTHY while serving nobody. That is the whole problem: # the failure is invisible to the supervisor. # # ── WHY A RECONCILE AND NOT AN EVENT WATCHER ─────────────────────────────── # There is no systemd-native edge signal to bind to. Containers do appear as # `docker-.scope` units (cgroup driver is systemd), but the scope NAME # embeds the container ID, and the ID changes on recreate — so there is no # stable unit for BindsTo=. `NetworkNamespacePath` is resolved at unit start # only, and a .path unit on /run/netns/ would watch the very file this # script maintains (circular). # # So the trigger is a timer, and the check is LEVEL-triggered: it compares the # namespace the services are ACTUALLY in against the anchor's CURRENT one. That # is strictly more robust than an edge-triggered watcher, which can miss events # while it is itself restarting or while dockerd is down, and it needs no # debounce logic — a burst of container events collapses into at most one # reconcile per tick, because the only question asked is "does the observed # state differ from the desired state right now?". # # The trigger is deliberately separable from the action. If detection latency # ever matters, a `docker events` unit can invoke THIS SAME script; nothing here # would change. # # ── INVARIANTS ───────────────────────────────────────────────────────────── # * A container PID is runtime state and is NEVER persisted. It is resolved from # Docker on every run. # * Identity is the netns inode of /proc//ns/net, never the # container name (same name != same namespace) and never a cached value. # * systemd stays the service authority: this script only requests start/stop/ # restart, and unit ordering does the sequencing. # * Silence when correct. It logs only when it acts or fails, so a 10s timer # does not fill the journal. # # usage: openfut-netns-reconcile.sh ... set -eu CONTAINER="${1:?container name}"; shift NSNAME="${1:?netns name}"; shift NSUNIT="${1:?netns unit}"; shift [ "$#" -ge 1 ] || { echo "reconcile: at least one dependent unit required" >&2; exit 2; } UNITS="$*" HERE="$(dirname "$0")" log() { echo "openfut-netns-reconcile: $*"; } # ---- 1. Is Docker even answering? ----------------------------------------- # A daemon outage must be a clean, quiet failure that the timer retries, never a # spin and never a destructive action taken on incomplete information. if ! docker info >/dev/null 2>&1; then log "docker daemon unavailable — taking NO action, will retry on the next tick" exit 0 fi # ---- 2. Resolve the anchor, by name, right now ---------------------------- CPID="$(docker inspect -f '{{.State.Pid}}' "$CONTAINER" 2>/dev/null || true)" if [ -z "$CPID" ] || [ "$CPID" = "0" ] || [ ! -e "/proc/$CPID/ns/net" ]; then # Anchor gone. Services must NOT keep pretending to be healthy inside a # namespace whose owner has died — and must never fall back to host # networking. Stop them; a later tick starts them again once the anchor is # back, which is what makes recovery automatic. RUNNING="" for u in $UNITS; do [ "$(systemctl is-active "$u" 2>/dev/null)" = "active" ] && RUNNING="$RUNNING $u" done if [ -n "$RUNNING" ]; then log "anchor '$CONTAINER' is ABSENT — stopping$RUNNING (no host-network fallback)" # Reverse order: dependants before the thing they depend on. # shellcheck disable=SC2086 systemctl stop $RUNNING || true systemctl stop "$NSUNIT" || true fi exit 0 fi WANT="$(readlink "/proc/$CPID/ns/net")" # ---- 3. Compare against where the services ACTUALLY are ------------------- # Observed state, not a remembered value: this self-heals no matter how the # drift happened (container recreate, restart, manual nsenter, anything). NEED_ACTION=0 REASON="" for u in $UNITS; do state="$(systemctl is-active "$u" 2>/dev/null || true)" if [ "$state" != "active" ]; then NEED_ACTION=1; REASON="$REASON $u=$state" continue fi mp="$(systemctl show -p MainPID --value "$u" 2>/dev/null || echo 0)" if [ -z "$mp" ] || [ "$mp" = "0" ] || [ ! -e "/proc/$mp/ns/net" ]; then NEED_ACTION=1; REASON="$REASON $u=nopid" continue fi have="$(readlink "/proc/$mp/ns/net")" if [ "$have" != "$WANT" ]; then NEED_ACTION=1; REASON="$REASON $u=$have" fi done if [ "$NEED_ACTION" = "0" ]; then exit 0 # correct and silent fi # ---- 4. One controlled rebind cycle --------------------------------------- log "anchor '$CONTAINER' pid=$CPID ns=$WANT; drift:$REASON" log "namespace changed or services adrift — requesting one rebind cycle" # STOP FIRST, then rebind, then start — not restart-around-a-rebind. While a # service is still running it holds the OLD namespace open, the umount fails # busy, and `mount --bind` silently STACKS a second nsfs entry over it. Measured: # the first rebind left two mounts on the path. Stopping the dependants releases # the old namespace so the drain actually succeeds. # # Reverse order on the way down (dependants before their dependency), forward on # the way up — and the way up is systemd's job: `start` honours the units' own # After=/Requires=, so Core is listening before the host's readiness gate runs. REV="" for u in $UNITS; do REV="$u $REV"; done # shellcheck disable=SC2086 systemctl stop $REV || true "$HERE/openfut-netns-bind.sh" "$CONTAINER" "$NSNAME" systemctl restart "$NSUNIT" # shellcheck disable=SC2086 systemctl start $UNITS for u in $UNITS; do mp="$(systemctl show -p MainPID --value "$u" 2>/dev/null || echo 0)" now="$( [ "$mp" != "0" ] && readlink "/proc/$mp/ns/net" || echo '-')" log "rebound $u pid=$mp ns=$now" done