Files
serverAppConfigs/serverAppConfigs-docs/Operations/Container Host Reachability.md
T
Frank 327e78b950 docs(vault): destroy the dead weight — 53 pages and the change diary
The vault was 155 pages / 31,210 lines, most of it measurement ledgers for
payloads that no longer run. A throughput figure for a runtime nobody operates is
not a record: AI moves faster than the vault, so the number is wrong before
anyone reads it, and a wrong number in a runbook gets believed. Per the new
purpose test in `AGENTS.md` → *Documentation Policy*, it is destroyed.

Gone (`.AGENTTRASH/20261019-destruction/`, KILL-LIST.txt is the manifest):
- the `frank-medium` endpoint and its five era pages, `frank-npu`, FastFlowLM,
  the ikllama/kingjones/dealign/frank-redo eras, the ling/swift15/freetoken/ubatch
  campaign pages, qwen3.8 spec-matrix and strix-optimizations, the two
  "Running Qwen3.6 …" pages;
- the whole `Pipeline Parallelism/` folder (no live script passes a pipeline flag)
  and the multi-device/x4-pair/hybrid/offload experiment pages;
- every vLLM page — no vLLM endpoint exists on this fleet;
- `LLM/deployment.md` (`Architecture/Deployment Flow.md` is the test-referenced
  owner) and the external surveys (SGLang, Lemonade, llama.cpp forks/diff,
  self-hosted app census, the TTS survey) that were obsolete by construction;
- `log.md`, 2,380 lines. Git holds the chronology; a causal law belongs to the
  page that owns the thing it governs, never to a diary.

Survivors: 101 pages, 17,542 lines, 0 OKF violations, 0 unresolved links. Eras
tables keep the window, payload, runtime, reason and rollback pointer; the rate
columns are blanked. The live shapes stay verbatim — those numbers are what the
deployer and `models.yml` consume.
2026-10-19 10:56:37 -07:00

5.7 KiB

type, title, description, tags, timestamp
type title description tags timestamp
reference Container Host Reachability Two reachability quirks: containers cannot dial unpublished host ports, and netns membership decides reachability.
podman
netavark
networking
rootless
reachability
caddy
2026-10-05T00:00:00-07:00

Container Host Reachability

Two failure families on rootless podman + netavark hosts (Dankclaw), both reading as "service down" when the service is up and the path is wrong. Fleet source of truth: serverAppConfigs-docs/Architecture/Container Network Namespaces.md (commit 2e179b0).

Quirk 1 — LAN-IP dialing of an unpublished host port refuses instantly

A container cannot reach a Network=host service at the host's LAN IP. netavark DNATs published ports only: from a bridge container, 192.168.0.143:<published> answers while 192.168.0.143:<host bind> and the gateway 10.89.1.1 are both refused. This is what 502'd hermes.franksdank.cloud until the Caddyfile upstream became host.containers.internal on 2026-09-16 — its vhost comment had claimed the LAN IP was open from that bridge; it never was.

Probe that proves it in one command:

ssh <host> "podman run --rm --network <net> --entrypoint sh \
  docker.io/library/busybox:latest -c \
  'for p in <target-port> <published-port>; do
     nc -w2 -z -v <host-lan-ip> \$p >/dev/null 2>&1 && echo port-\$p-OK || echo port-\$p-BAD
   done'"
podman exec <svc> bash -c 'exec 3<>/dev/tcp/host.containers.internal/<port> && echo ALIAS-OK'

Published OK + target BAD ⇒ quirk confirmed. Note busybox nc -z reports any failure as failure — use it for bulk sweeps; use /dev/tcp (or nc -v) when refusal-vs-timeout matters.

Fix: point the service's host-facing URL at http://host.containers.internal:<port> in compose/quadlet env, redeploy git (deploy.sh <target> --service <svc>), verify podman exec <svc> printenv <URL_VAR> and the connect log line (~40 s). Do not publish the daemon port to make the LAN IP work — publishing is exactly what makes other ports refuse.

Quirk 2 — addressability is per netns, not per host

  • NetworkMode=host → the container is the host netns: dials all of 127.0.0.1, binds any free port.
  • bridge/named net → own netns; only published ports are reachable, via the network's gateway IP. Unpublished host listeners refuse instantly (RST) — no firewall rule, no stale DNAT, nft/iptables -t nat empty. Do not hunt for REJECT rules; probe.

Probe sequence (read-only):

podman inspect <name> --format '{{.HostConfig.NetworkMode}}'
sudo ss -ltnp 'sport = :<port>'        # host netns only — why 0.0.0.0 can still be unreachable
podman exec <name> cat /proc/net/route  # default route = the gateway IP to dial
podman exec <name> sh -c 'timeout 2 bash -c "exec 3<>/dev/tcp/<gw-ip>/<port>"; echo rc=$?'

Interpretation without blurring:

  • instant refusal = nothing listening in a reachable namespace (or rootlessport rejected an unpublished port map).
  • timeout = no route to that address at all. host.containers.internal (169.254.1.2) is an alias on the default podman net only; containers on a named network time out against it.

Tool gotchas (podman 5.x, verified live):

  • podman network inspect <net> --format '{{json .containers}}' errors (can't evaluate field containers in type interface {}) — list membership from the container side: podman ps -a --format '{{.Names}} {{.Networks}}'.
  • An empty Networks field = host network or shared namespace (--network=container:<other>), not "no network".
  • Quadlet Network=<name>.network yields the runtime name systemd-<name> — probe with podman network ls, never the .network filename.

Fix options when a bridge-net container must reach a host-netns listener: (a) move it to Network=host, or (b) publish the port — (b) makes the port reachable from every namespace and the LAN, an exposure change needing explicit operator approval, never a silent fix.

On hosts publishing ports through pasta, the port is bound in userspace at container CREATE, not when the app binds. A predecessor stopped moments earlier can still hold listener/TIME_WAIT state, so the replacement dies pasta: ... bind: Address already in use (conmon exit 126) — then self-heals seconds later under Restart=on-failure. The failure looks like it failed, then looks fine, and the missing gate stays invisible because the retry masks it. First seen on a frank-dragoon unit cutover; which supervision shape that era was is in frank-dragoon's era table.

Gate procedure: stop the old unit AND the new one (a redeploy's running replacement is itself the port-holder — without that stop the gate deadlocks against itself), then wait for the port to be empty across all socket states:

wait_port_free() {
    local port="$1" limit="${2:-90}" i
    for ((i = 1; i <= limit; i++)); do
        if [ -z "$(ss -Htan "sport = :$port" 2>/dev/null)" ]; then return 0; fi
        sleep 1
    done
    echo "FAIL: port $port still held after ${limit}s:" >&2
    ss -tan "sport = :$port" >&2 || true
    return 1
}

sport catches accepted-connection server sockets and TIME_WAIT residue that ss -ltn misses; fail loudly with the socket table attached. Verify the gate by re-running the same deploy against a running service (idempotency) and assert it in seam tests (wait_port_free present in the deploy handler).