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.
This commit is contained in:
funman300
2026-08-22 21:14:23 +00:00
parent 8ae432223a
commit 1e8d46b258
11 changed files with 409 additions and 13 deletions
+28 -11
View File
@@ -6,13 +6,18 @@ persistence and no supervisor-visible logs.
| file | scope | installed? |
|---|---|---|
| `openfut-staging-core.service` | staging Core, port 18081 | **yes** — proving ground |
| `openfut-staging-netns.service` | binds the staging anchor netns | **yes** — proving ground |
| `openfut-staging-core.service` | staging Core, port 18081 | **yes** |
| `openfut-staging-host.service` | staging host, port 8299 | **yes** |
| `openfut-netns.service` | publishes the container netns to `/run/netns/openfut` | template only |
| `openfut-staging-netns-reconcile.{service,timer}` | staging netns lifecycle | **yes** |
| `openfut-netns.service` | binds the production anchor netns to `/run/netns/openfut` | template only |
| `openfut-core.service` | production Core, port 18080 | template only |
| `openfut-host.service` | production host, port 8099 | template only |
| `openfut-netns-bind.sh` | resolves the container netns by NAME, idempotently | helper |
| `openfut-netns-reconcile.{service,timer}` | production netns lifecycle | template only |
| `openfut-netns-bind.sh` | resolves the anchor netns by NAME, idempotently, drains stale mounts | helper |
| `openfut-netns-reconcile.sh` | keeps services in the anchor's CURRENT netns | helper |
| `openfut-wait-tcp.sh` | bounded readiness gate | helper |
| `openfut-supervision-install.sh` | install / start / status / uninstall per environment | helper |
Production templates are **not installed**. Deploy only via the plan in
`OpenFUT-Vault/06 Operations/OpenFUT Service Supervision (staging-proven).md`.
@@ -42,20 +47,32 @@ interfaces, silently serving nobody. `openfut-netns-bind.sh` re-resolves by name
and refreshes a stale bind mount; `NetworkNamespacePath=` then enters it
declaratively.
> **Still open:** a container restart strands *already-running* services in the
> dead namespace. Re-running `openfut-netns.service` plus restarting Core/host
> repairs it, but nothing triggers that automatically yet. See the promotion
> plan's "Residual gap".
**4. Stale namespaces are repaired automatically (this closes the old gap).**
Recreating the anchor strands already-running services in the dead namespace,
and — the dangerous part — *systemd still reports them `active`*. Measured before
the fix: anchor `net:[4026539938] → net:[4026540033]`, Core and host unchanged in
the old one, both units `active`, traffic `ConnectionResetError`.
`openfut-netns-reconcile.sh` on a 10s timer compares the namespace the services
are **actually in** against the anchor's **current** one and, only on a real
difference, performs one stop → rebind → start cycle. It is level-triggered, so
it cannot miss an event and needs no debounce: a burst of three back-to-back
recreations produced exactly **one** rebind.
## Operating
```bash
systemctl status openfut-staging-core openfut-staging-host
journalctl -u openfut-staging-core -f
sudo systemctl restart openfut-staging-core # host survives and recovers
sudo systemctl stop openfut-staging-host openfut-staging-core
sudo ./openfut-supervision-install.sh status staging # units, anchor, netns agreement, mounts
journalctl -u openfut-staging-netns-reconcile -f # silent unless it acts
sudo systemctl restart openfut-staging-core # host survives and recovers
sudo ./openfut-supervision-install.sh install staging # copy + enable (does not start)
sudo ./openfut-supervision-install.sh uninstall staging # disable, stop, remove units
```
`status` is the one command worth knowing: it prints each unit's state, the
anchor's current netns, the namespace each service is *actually* in with an
`ok`/`MISMATCH` verdict, and the mount count (`1` healthy, `>1` a leaked stack).
Config lives in `EnvironmentFile`s (`…/systemd/core.env`, `host.env`), generated
from the live process environment so supervision changed *how* the processes
start and nothing about *what* they do. Binaries are immutable copies, so a
+16 -1
View File
@@ -49,7 +49,22 @@ if mountpoint -q "$TARGET" 2>/dev/null; then
exit 0
fi
echo "openfut-netns-bind: $TARGET is STALE ($HAVE, want $WANT) — refreshing"
umount "$TARGET" || true
# DRAIN, do not just pop. `mount --bind` STACKS: binding over a busy mount
# silently leaves the old one underneath, and staging grew two nsfs entries
# on the first rebind before this loop existed. Left alone that is one
# leaked mount per container recreation, and the buried namespaces are
# exactly the dead ones we are trying to get rid of.
#
# A umount can legitimately fail while a service still holds the old
# namespace open; the caller's job is to stop dependants FIRST. If it is
# still busy we stack rather than fail — a current top-of-stack mount is
# correct, just untidy — and say so.
while mountpoint -q "$TARGET" 2>/dev/null; do
umount "$TARGET" 2>/dev/null || {
echo "openfut-netns-bind: $TARGET still busy; stacking a current mount over it" >&2
break
}
done
fi
[ -e "$TARGET" ] || touch "$TARGET"
@@ -0,0 +1,23 @@
[Unit]
Description=OpenFUT: reconcile services with the anchor container network namespace
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
After=docker.service
Wants=docker.service
# PRODUCTION TEMPLATE — NOT INSTALLED.
#
# Deliberately NO Requires=docker.service: a docker outage must be a quiet
# retry, not a failed unit. The script decides that itself and exits 0.
[Service]
Type=oneshot
# Level-triggered. It compares the namespace Core and the host are ACTUALLY in
# against the anchor's CURRENT one, so a burst of container events collapses
# into at most one rebind per tick and no separate debounce is needed.
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-netns-reconcile.sh \
openfut-fut-backend openfut \
openfut-netns.service \
openfut-core.service openfut-host.service
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-netns-reconcile
+147
View File
@@ -0,0 +1,147 @@
#!/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
@@ -0,0 +1,14 @@
[Unit]
Description=OpenFUT: periodic anchor-namespace reconcile
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
# PRODUCTION TEMPLATE — NOT INSTALLED.
[Timer]
# 10s. Anchor recreation is rare and already an outage; converging inside ~10s
# is ample. Measured cost per tick: one `docker inspect` plus two readlinks.
OnBootSec=15s
OnUnitInactiveSec=10s
AccuracySec=1s
[Install]
WantedBy=timers.target
+10 -1
View File
@@ -4,8 +4,12 @@ Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
# Staging Core is the write authority for the staging SQLite DB. Nothing else
# may hold it open, which is why there is no second instance and no oneshot
# migration unit: Core runs its own migrations at startup, before it binds.
After=network-online.target
After=network-online.target docker.service openfut-staging-netns.service
Wants=network-online.target
# Requires, not Wants: without the anchor namespace Core would bind the WRONG
# network entirely. Absent anchor must mean "do not start", never "start on
# the host network".
Requires=openfut-staging-netns.service
# StartLimit* MUST live in [Unit]: systemd 252 silently IGNORES them in
# [Service] (`systemd-analyze verify` flags it), which would have left the
@@ -19,6 +23,11 @@ User=alex
Group=alex
WorkingDirectory=/home/alex/openfut-sold-staging
# Enter the anchor container namespace declaratively. Equivalent to the
# `nsenter --net=/proc/<pid>/ns/net` production uses today, but with no pid
# baked in: the path is re-resolved by openfut-staging-netns.service.
NetworkNamespacePath=/run/netns/openfut-staging
# Config is DATA, not baked into the unit, so the same unit file promotes to
# production with a different EnvironmentFile.
EnvironmentFile=/home/alex/openfut-sold-staging/systemd/core.env
@@ -8,6 +8,10 @@ Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
# traffic: the host cannot reach "active" while Core is not listening.
Wants=openfut-staging-core.service
After=openfut-staging-core.service
# Requires on the NAMESPACE (hard) but only Wants on Core (soft) — the host
# must never bind the host network, yet must survive a Core blip.
Requires=openfut-staging-netns.service
After=openfut-staging-netns.service
# `Wants`, deliberately NOT `Requires`/`BindsTo`/`PartOf`. Those propagate a
# Core stop into a host stop, and — measured, not assumed — a later Core start
@@ -32,6 +36,8 @@ User=alex
Group=alex
WorkingDirectory=/home/alex/openfut-sold-staging
NetworkNamespacePath=/run/netns/openfut-staging
EnvironmentFile=/home/alex/openfut-sold-staging/systemd/host.env
# Readiness gate. Bounded, and FAILS rather than blocking forever: a host that
@@ -0,0 +1,22 @@
[Unit]
Description=OpenFUT STAGING: reconcile services with the anchor container network namespace
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
After=docker.service
Wants=docker.service
# Deliberately NO Requires=docker.service: a docker outage must be a quiet
# retry, not a failed unit. The script itself decides that and exits 0.
[Service]
Type=oneshot
# Level-triggered: compares the namespace the services are ACTUALLY in against
# the anchor's CURRENT one, so a burst of container events collapses into at
# most one rebind per tick and no separate debounce is needed.
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-netns-reconcile.sh \
openfut-staging-anchor openfut-staging \
openfut-staging-netns.service \
openfut-staging-core.service openfut-staging-host.service
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-staging-netns-reconcile
@@ -0,0 +1,16 @@
[Unit]
Description=OpenFUT STAGING: periodic anchor-namespace reconcile
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
[Timer]
# 10s cadence. Container recreation is rare and already an outage; converging
# within ~10s is ample, and a level-triggered check at this rate costs one
# `docker inspect` plus two readlinks.
OnBootSec=15s
OnUnitInactiveSec=10s
AccuracySec=1s
# Not Persistent=: there is nothing to catch up on. The check is stateless and
# the next tick after boot is authoritative.
[Install]
WantedBy=timers.target
@@ -0,0 +1,30 @@
[Unit]
Description=OpenFUT STAGING: publish the anchor container network namespace to /run/netns/openfut-staging
Documentation=file:///home/alex/OpenFUT/scripts/systemd/README.md
After=docker.service
Requires=docker.service
# Re-resolved on every start, so this unit is also the REPAIR action after the
# anchor container is recreated: `systemctl restart openfut-staging-netns` plus
# a restart of the dependants re-enters the current namespace.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=oneshot
RemainAfterExit=yes
# Resolves the anchor BY NAME, never by a stored pid, and exits non-zero when
# the anchor is absent — which is what makes the dependants' Requires= a real
# admission gate rather than decoration.
ExecStart=/home/alex/OpenFUT/scripts/systemd/openfut-netns-bind.sh openfut-staging-anchor openfut-staging
# No ExecStop unmount: the namespace belongs to the container's lifetime, and
# tearing the bind mount down under a live Core/host would strand them.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=openfut-staging-netns
[Install]
WantedBy=multi-user.target
+97
View File
@@ -0,0 +1,97 @@
#!/bin/sh
# Install or remove the OpenFUT systemd supervision set for one environment.
#
# Environments are symmetric on purpose: staging and production differ only in
# unit prefix, anchor container and EnvironmentFile location, so what staging
# proved is what production gets.
#
# install copy units, daemon-reload, enable (does NOT start)
# start start in dependency order and report the namespace agreement
# status one-screen health: units, anchor, netns agreement, mounts
# uninstall disable + stop + remove units (leaves binaries, DB and env alone)
#
# The install step deliberately does NOT start anything: on production the
# changeover has to be sequenced against retiring the existing detached
# processes, which is an operator decision, not a script's.
#
# usage: openfut-supervision-install.sh <install|start|status|uninstall> <staging|production>
set -eu
ACTION="${1:?install|start|status|uninstall}"
ENVNAME="${2:?staging|production}"
HERE="$(cd "$(dirname "$0")" && pwd)"
case "$ENVNAME" in
staging)
PREFIX="openfut-staging"
ANCHOR="openfut-staging-anchor"
NSNAME="openfut-staging"
UNITS="openfut-staging-netns.service openfut-staging-core.service openfut-staging-host.service openfut-staging-netns-reconcile.service openfut-staging-netns-reconcile.timer"
ENABLE="openfut-staging-netns.service openfut-staging-core.service openfut-staging-host.service openfut-staging-netns-reconcile.timer"
;;
production)
PREFIX="openfut"
ANCHOR="openfut-fut-backend"
NSNAME="openfut"
UNITS="openfut-netns.service openfut-core.service openfut-host.service openfut-netns-reconcile.service openfut-netns-reconcile.timer"
ENABLE="openfut-netns.service openfut-core.service openfut-host.service openfut-netns-reconcile.timer"
;;
*) echo "unknown environment '$ENVNAME'" >&2; exit 2 ;;
esac
case "$ACTION" in
install)
for u in $UNITS; do
[ -f "$HERE/$u" ] || { echo "missing unit $HERE/$u" >&2; exit 1; }
install -m 0644 "$HERE/$u" "/etc/systemd/system/$u"
echo "installed /etc/systemd/system/$u"
done
systemctl daemon-reload
for u in $UNITS; do systemd-analyze verify "/etc/systemd/system/$u" || true; done
# shellcheck disable=SC2086
systemctl enable $ENABLE
echo "enabled (NOT started — start explicitly once the old processes are retired)"
;;
start)
systemctl start "${PREFIX}-netns.service"
systemctl start "${PREFIX}-core.service"
systemctl start "${PREFIX}-host.service"
systemctl start "${PREFIX}-netns-reconcile.timer"
sleep 3
"$0" status "$ENVNAME"
;;
status)
printf '%-42s %s\n' "unit" "state"
for u in $UNITS; do printf ' %-40s %s\n' "$u" "$(systemctl is-active "$u" 2>/dev/null || true)"; done
cpid="$(docker inspect -f '{{.State.Pid}}' "$ANCHOR" 2>/dev/null || echo 0)"
if [ "$cpid" != "0" ] && [ -e "/proc/$cpid/ns/net" ]; then
want="$(readlink "/proc/$cpid/ns/net")"
else
want="(anchor absent)"
fi
echo " anchor $ANCHOR pid=$cpid ns=$want"
for u in "${PREFIX}-core.service" "${PREFIX}-host.service"; do
mp="$(systemctl show -p MainPID --value "$u" 2>/dev/null || echo 0)"
ns="-"; [ "$mp" != "0" ] && [ -e "/proc/$mp/ns/net" ] && ns="$(readlink "/proc/$mp/ns/net")"
match="MISMATCH"; [ "$ns" = "$want" ] && match="ok"
printf ' %-40s pid=%-8s ns=%-18s %s\n' "$u" "$mp" "$ns" "$match"
done
echo " netns mounts on /run/netns/$NSNAME: $(grep -c "run/netns/$NSNAME" /proc/mounts || true) (1 = healthy, >1 = leaked stack)"
;;
uninstall)
# shellcheck disable=SC2086
systemctl disable --now $ENABLE 2>/dev/null || true
systemctl stop "${PREFIX}-netns-reconcile.service" 2>/dev/null || true
for u in $UNITS; do rm -f "/etc/systemd/system/$u"; echo "removed /etc/systemd/system/$u"; done
systemctl daemon-reload
systemctl reset-failed 2>/dev/null || true
# The bind mount is intentionally left: the namespace belongs to the
# container, and tearing it down is not part of removing supervision.
echo "uninstalled. Binaries, EnvironmentFiles, /run/netns and the database are untouched."
;;
*) echo "unknown action '$ACTION'" >&2; exit 2 ;;
esac