Files
OpenFUT/scripts/systemd/openfut-netns-reconcile.sh
T
funman300 1e8d46b258 ops(systemd): follow the anchor container's netns across recreation
Recreating the Docker anchor left the supervised Core and host stranded in
the dead namespace while systemd still reported them active — serving
nobody, invisible to any monitoring that trusts unit state. Reproduced on
staging: netns 4026539938 -> 4026540033, both pids unchanged in the old
one, both units "active", traffic ConnectionResetError.

There is no systemd-native edge signal to bind to. Containers do appear as
units, but the scope name embeds the container ID (docker-<id>.scope), which
changes on every recreate, so BindsTo= has no stable target;
NetworkNamespacePath= resolves once at start; a .path unit on /run/netns
would watch the file this tooling maintains. So: a level-triggered reconcile
on a 10s timer, comparing the namespace the services are ACTUALLY in against
the anchor's CURRENT one, acting only on a real difference. That cannot miss
an event while the watcher restarts or dockerd is down, and needs no
debounce — a burst of three recreations produced exactly one rebind. The
trigger stays separable: a docker-events unit could invoke the same script.

Anchor absent stops the dependants rather than falling back to host
networking; docker unavailable logs once and retries on the next tick.

Two defects found while testing and fixed here:
- mount --bind STACKS when the old mount is busy, silently leaking nsfs
  entries; the bind helper now drains stale mounts in a loop.
- reconcile must stop -> rebind -> start, not rebind -> restart: a running
  service holds the old namespace open and makes the umount fail busy.

Staging also gained a faithful anchor container so the reproduction is
structural rather than mocked. Economy state was byte-identical across every
lifecycle test. Production units are templates only and remain uninstalled.
2026-08-22 21:14:23 +00:00

148 lines
6.7 KiB
Bash
Executable File

#!/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-<id>.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/<name> 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/<current-anchor-pid>/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 <container> <nsname> <netns-unit> <unit>...
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