Skip to content

Commit 2bfc25c

Browse files
AlexCherrypiclaude
andcommitted
feat(autostart): rebind wrap dependents on F-45 SA recreate
Closes #10. Auto-fix the dead-netns cascade that the v1.0.2 image- drift recreate (and the issue-#5 stale-netns recreate) triggers as a side effect: when anchord removes its managed SA and spawns a new one, every container declaring `network_mode: service:<sa>` was pinned to the OLD container ID at compose create-time and ends up in a destroyed netns. Looks running to Docker, no interface. Production incident (2026-05-23) hit 21 such victims across 9 SAs during a v1.0.1 → v1.0.2 rollout — Frigate end-to-end broken because authentik_server itself was orphaned. v1.1.0 added detection-only (WARN logs + doctor CLI). v1.2.0 closes the loop for the case anchord itself caused, where the recreate scope is unambiguous. Design The fix-scope is intentionally narrow: anchord only auto-rebinds dependents whose netmode points at THE SA IT JUST REMOVED. Anchord knows the old SA's container ID at the moment of removal, so there's no scope-discovery problem, no race with operator-driven changes, no need for healthy-observation state-tracking, no multi-SA ambiguity. v1.1.0's general dependents watcher keeps WARNing about the broader case (operator-driven SA recreates, OOM-killed SAs); those still need manual recovery. Mechanism internal/autostart/autostart.go centralises the recreate cycle in a new helper `recreateSAWithOrphanRebind`, used at both points where SA recreate happens: - maybeManage's stale-netns branch (issue #5) - maybeManage's image-drift branch (issue #8 follow-up, was the separate maybeRecreateStaleImageSA; now folded back in) The helper: 1. Before Remove: enumerates wrap dependents via findOrphanCandidates(all, oldSA) — matches NetworkMode against the SA's long ID, short ID (>=12), and names. 2. Removes the old SA. Failure aborts so we don't end up with no SA at all. 3. Calls createAndStartManaged (now returns the new ID). 4. For each enumerated orphan: ops.RecreateWithNetworkMode(id, "container:<newSAID>"). Failures are warn-logged per-dep and the loop continues — one broken dep doesn't justify leaving the others stranded. Inspect-then-recreate preserves Config + HostConfig from the dependent's running state and only patches NetworkMode. No compose files needed (compose's network_mode resolution is what we're replicating directly). No image bloat — pure SDK. The image-drift check moves from maybeRecreateStaleImageSA into maybeManage proper as an additional gate alongside stale-netns; the old method is deleted. backfill is the only caller that supplies the SelfInfo pointer for the drift check (per-event paths pass nil so noisy event sources don't trigger churning recreates). Config + flag New ANCHORD_AUTOFIX_DEAD_NETNS env knob (config.LoadNetworkAnchor), default true, same shape as ANCHORD_AUTOSTART_SIBLINGS. Setting false reverts to v1.1.0 behaviour: SA gets recreated as before, dependents are left orphaned for the v1.1.0 dependents watcher's WARN log to flag. Docker-socket-proxy permission delta: requires DELETE + container create/start on the dependent containers. Same surface anchord already uses for its own SA recreate — no new endpoints, just applied to wrap-dep containers in our project scope. README documents the flag and the implications. Tests 7 new tests (315 unit total, +14 over v1.1.0): - TestFindOrphanCandidates_ByAllRefForms — predicate matches long ID / short ID / name, ignores SA itself + unrelated netmodes - TestBackfill_F45_ReboundDependentsOnImageDrift — image-drift + 3 orphans → SA recreated, all 3 rebound to new SA - TestRun_F45_ReboundDependentsOnStaleNetns — issue #5 path also triggers the rebind on event - TestBackfill_F45_NoRebindWhenAutoFixDisabled — opt-out invariant: SA still recreated, no dep rebinds - TestBackfill_F45_NoRebindOnAbsentSA — create-fresh path doesn't sweep unrelated orphans - TestRun_F45_RebindContinuesAfterPerDepFailure — one bad dep doesn't abort the loop - TestLoad_AutoFixDeadNetns — config parsing happy/sad paths Verified: 315/315 unit + 74/74 e2e green, race -count=3 clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9877a82 commit 2bfc25c

6 files changed

Lines changed: 713 additions & 128 deletions

File tree

README.md

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ All via environment variables.
338338
| `ANCHORD_PROJECT` | yes¹ | `$COMPOSE_PROJECT_NAME` | Scope of containers anchord manages. Required unless `ANCHORD_LABEL_SELECTOR` is set. Ignored (with a WARN log) when both are set |
339339
| `ANCHORD_LABEL_SELECTOR` | no | | F-42: replaces the project filter with an operator-defined label set, comma-separated `key=value` AND-joined (e.g. `anchord.role=ldap-outpost,env=prod`). Use when multiple anchords share a project, or when target containers are spawned outside Compose and carry no project label (e.g. authentik outposts) |
340340
| `ANCHORD_AUTOSTART_SIBLINGS` | no | `true` | F-43: watch Docker for `container start` events and bootstrap any sibling container in `Created` state whose `network_mode: container:<X>` matches the just-started target. Needed for service-anchors whose target is spawned at runtime (e.g. authentik outposts via the Docker API). Requires `POST=1` on the docker-socket-proxy. Set to `false` to disable |
341+
| `ANCHORD_AUTOFIX_DEAD_NETNS` | no | `true` | Issue #10: when the network-anchor recreates its F-45-managed service-anchor (stale-netns or image-drift path), also re-create every wrap dependent that was pinned to the old SA's container ID. Without this, the dependents end up in a destroyed netns and look running to Docker while being invisible to the outside. Requires `DELETE=1` + container create/start on the docker-socket-proxy (same set F-45's existing SA recreate already needs). Set to `false` to keep v1.1.0 behaviour (detection-only via the dependents watcher's WARN log) |
341342
| `ANCHORD_MANAGED_SA_TARGET` | no | | F-45: stable name of a runtime-spawned target container. When set, the network-anchor not only auto-starts existing Created-state siblings (F-43) but CREATES a service-anchor on demand when this target appears. Needed when Compose cannot declare the service-anchor (target doesn't yet exist at compose-up time and Compose halts on create-then-cant-start). Empty = pure F-43 behaviour |
342343
| `ANCHORD_MANAGED_SA_NAME` | no | `<TARGET>-service-anchor` | F-45: name of the container the network-anchor creates. Only consulted when `ANCHORD_MANAGED_SA_TARGET` is set |
343344
| `ANCHORD_MANAGED_SA_IMAGE` | no | (anchord's own image) | F-45: image for the managed service-anchor. Default resolved at runtime from the network-anchor's own container inspect — keeps both containers on the same image version |
@@ -481,17 +482,22 @@ not running, a different host, post-mortem analysis).
481482
Default is `anchord`, which matches the canonical service name in the
482483
example compose. If you rename the network-anchor service, set
483484
`ANCHORD_GATEWAY_HOSTNAME` on each service-anchor to match.
484-
- **Recreating a service-anchor orphans its wrap dependents.** Any
485+
- **Recreating a service-anchor orphans its wrap dependents** —
486+
but in the common case anchord now repairs them itself. Any
485487
container declaring `network_mode: service:fe-anchor-X` is pinned
486488
to fe-anchor-X's container ID at create-time and stays pinned
487-
across recreate. After
488-
`docker compose up -d --no-deps --force-recreate fe-anchor-X` (or
489-
any `docker rm` of the SA), recreate every dependent in the same
490-
stack — typically the per-stack Traefik plus any acme/wrap services
491-
netns-mode'd to the same fe-anchor. anchord detects the situation
492-
and emits a `WARN dependent in dead netns ...` log line per victim,
493-
but it does not (yet) auto-recreate. Use
494-
`anchord doctor stale-netns` for a cluster-wide one-shot scan.
489+
across recreate. When **anchord** recreates its F-45-managed
490+
service-anchor (stale-netns or image-drift path), it enumerates
491+
every wrap dependent of the old SA and re-creates each against
492+
the new SA's ID before returning — see the `ANCHORD_AUTOFIX_DEAD_NETNS`
493+
flag (default on). When **the operator** recreates a service-anchor
494+
manually (`docker rm`, `compose up --force-recreate`), anchord's
495+
v1.1.0 dependents watcher still emits a `WARN dependent in dead netns
496+
...` log line per victim with the exact recovery command — auto-fix
497+
only fires for SA recreates anchord caused itself, because there
498+
the scope is unambiguous (no race with operator, no scope
499+
discovery). Use `anchord doctor stale-netns` for a cluster-wide
500+
one-shot scan after a manual incident.
495501
- **One network-anchor per backend identity.** Default discovery
496502
scope is the Compose project; two anchords filtering the same set
497503
of backends will fight over their DNAT entries. With
@@ -517,21 +523,21 @@ here. The release pipeline rejects any tag whose recorded hash does
517523
not match the current source, so this block is the project's
518524
release-readiness signal.
519525
520-
- **Last verified:** 2026-05-23T12:45:19Z
521-
- **Code hash:** `sha256:db048ac8786e43a2611b06337fc10d936524392fcac2b2bd0aaec97db917468e`
526+
- **Last verified:** 2026-05-23T13:44:45Z
527+
- **Code hash:** `sha256:86847f79079a156afd50c1eb7ffc422a112d3a16460627f7735635feae6a1525`
522528
- **Flood-fix flag:** `E2E_BRIDGE_FLOOD_FIX=1`
523529
524530
### Summary
525531
526532
| Suite | Pass | Fail | Skip | Total |
527533
|---|---:|---:|---:|---:|
528534
| `go vet ./...` | clean | — | — | — |
529-
| Go unit tests | 301 | 0 | 0 | 301 |
535+
| Go unit tests | 315 | 0 | 0 | 315 |
530536
| E2E (test/e2e, 5 scenarios) | 74 | 0 | — | 74 |
531-
| **All tests** | **375** | **0** | **0** | **375** |
537+
| **All tests** | **389** | **0** | **0** | **389** |
532538
533539
<details>
534-
<summary>Go unit tests &mdash; 301/301 passed</summary>
540+
<summary>Go unit tests &mdash; 315/315 passed</summary>
535541
536542
| Package | Test | Status |
537543
|---|---|:---:|
@@ -554,10 +560,14 @@ release-readiness signal.
554560
| `cmd/anchord` | `TestSelectMode/unknown_env_errors` | ✓ |
555561
| `cmd/anchord` | `TestSelectMode/unknown_subcommand_errors` | ✓ |
556562
| `internal/autostart` | `TestBackfill_F45_NoImageCheckWhenRecipePinsImage` | ✓ |
563+
| `internal/autostart` | `TestBackfill_F45_NoRebindOnAbsentSA` | ✓ |
564+
| `internal/autostart` | `TestBackfill_F45_NoRebindWhenAutoFixDisabled` | ✓ |
557565
| `internal/autostart` | `TestBackfill_F45_NoRecreateWhenImagesMatch` | ✓ |
566+
| `internal/autostart` | `TestBackfill_F45_ReboundDependentsOnImageDrift` | ✓ |
558567
| `internal/autostart` | `TestBackfill_F45_RecreatesSAOnImageDrift` | ✓ |
559568
| `internal/autostart` | `TestBackfill_NoStrandedSiblings_NoOp` | ✓ |
560569
| `internal/autostart` | `TestBackfill_StartsStrandedCreatedSibling` | ✓ |
570+
| `internal/autostart` | `TestFindOrphanCandidates_ByAllRefForms` | ✓ |
561571
| `internal/autostart` | `TestMatchSiblings_EmptyTargetReturnsNil` | ✓ |
562572
| `internal/autostart` | `TestMatchSiblings_IgnoresNonCreated` | ✓ |
563573
| `internal/autostart` | `TestMatchSiblings_IgnoresUnrelatedNetworkModes` | ✓ |
@@ -582,6 +592,8 @@ release-readiness signal.
582592
| `internal/autostart` | `TestRun_F45_NoRespawnIfTargetAlsoGone` | ✓ |
583593
| `internal/autostart` | `TestRun_F45_NoSharedNetYetSkipsCreate` | ✓ |
584594
| `internal/autostart` | `TestRun_F45_OperatorLabelsReachSpec` | ✓ |
595+
| `internal/autostart` | `TestRun_F45_RebindContinuesAfterPerDepFailure` | ✓ |
596+
| `internal/autostart` | `TestRun_F45_ReboundDependentsOnStaleNetns` | ✓ |
585597
| `internal/autostart` | `TestRun_F45_RecreatesSAOnDestroy` | ✓ |
586598
| `internal/autostart` | `TestRun_F45_RecreatesSAOnStaleNetns` | ✓ |
587599
| `internal/autostart` | `TestRun_F45_SharedNetworkLookupIsLazy` | ✓ |
@@ -619,6 +631,14 @@ release-readiness signal.
619631
| `internal/config` | `TestLoad_AddressModeOverride/bootstrap` | ✓ |
620632
| `internal/config` | `TestLoad_AddressModeOverride/dhcp-refresh` | ✓ |
621633
| `internal/config` | `TestLoad_AddressModeOverride/slaac-ra-only` | ✓ |
634+
| `internal/config` | `TestLoad_AutoFixDeadNetns/FALSE` | ✓ |
635+
| `internal/config` | `TestLoad_AutoFixDeadNetns/TRUE` | ✓ |
636+
| `internal/config` | `TestLoad_AutoFixDeadNetns/explicit_false` | ✓ |
637+
| `internal/config` | `TestLoad_AutoFixDeadNetns/explicit_true` | ✓ |
638+
| `internal/config` | `TestLoad_AutoFixDeadNetns/garbage_rejected` | ✓ |
639+
| `internal/config` | `TestLoad_AutoFixDeadNetns/shorthand_0` | ✓ |
640+
| `internal/config` | `TestLoad_AutoFixDeadNetns/shorthand_1` | ✓ |
641+
| `internal/config` | `TestLoad_AutoFixDeadNetns/unset_→_default_true` | ✓ |
622642
| `internal/config` | `TestLoad_AutostartSiblings/FALSE` | ✓ |
623643
| `internal/config` | `TestLoad_AutostartSiblings/TRUE` | ✓ |
624644
| `internal/config` | `TestLoad_AutostartSiblings/empty-string_treated_as_default` | ✓ |
@@ -859,11 +879,11 @@ release-readiness signal.
859879
| `v4-only` | S-6 logs show graceful shutdown | ✓ |
860880
| `v4-only` | S-6 nat teardown clean (no warnings) | ✓ |
861881
| `v6-only` | anchord container running | ✓ |
862-
| `v6-only` | external iface attached on vlan subnet (resolved to eth0) | ✓ |
882+
| `v6-only` | external iface attached on vlan subnet (resolved to eth1) | ✓ |
863883
| `v6-only` | anchord log confirms F-37 network-based iface resolution | ✓ |
864884
| `v6-only` | nftables anchord_v4 table installed | ✓ |
865885
| `v6-only` | nftables anchord_v6 table installed | ✓ |
866-
| `v6-only` | eth0 has IPv6 from fd99::/64 (RA or bootstrap) | ✓ |
886+
| `v6-only` | eth1 has IPv6 from fd99::/64 (RA or bootstrap) | ✓ |
867887
| `v6-only` | anchord_v6 dnat_tcp contains port 25 | ✓ |
868888
| `v6-only` | S-2 (v4) source IP preserved through DNAT | ✓ |
869889
| `v6-only` | S-2 (v6) source IP preserved through DNAT | ✓ |
@@ -873,12 +893,12 @@ release-readiness signal.
873893
| `v6-only` | S-6 logs show graceful shutdown | ✓ |
874894
| `v6-only` | S-6 nat teardown clean (no warnings) | ✓ |
875895
| `both` | anchord container running | ✓ |
876-
| `both` | external iface attached on vlan subnet (resolved to eth1) | ✓ |
896+
| `both` | external iface attached on vlan subnet (resolved to eth0) | ✓ |
877897
| `both` | anchord log confirms F-37 network-based iface resolution | ✓ |
878898
| `both` | nftables anchord_v4 table installed | ✓ |
879899
| `both` | nftables anchord_v6 table installed | ✓ |
880-
| `both` | eth1 has IPv4 from 10.99.0.0/24 | ✓ |
881-
| `both` | eth1 has IPv6 from fd99::/64 (RA or bootstrap) | ✓ |
900+
| `both` | eth0 has IPv4 from 10.99.0.0/24 | ✓ |
901+
| `both` | eth0 has IPv6 from fd99::/64 (RA or bootstrap) | ✓ |
882902
| `both` | anchord_v4 dnat_tcp contains port 25 | ✓ |
883903
| `both` | anchord_v6 dnat_tcp contains port 25 | ✓ |
884904
| `both` | S-2 (v4) source IP preserved through DNAT | ✓ |
@@ -889,12 +909,12 @@ release-readiness signal.
889909
| `both` | S-6 logs show graceful shutdown | ✓ |
890910
| `both` | S-6 nat teardown clean (no warnings) | ✓ |
891911
| `none` | anchord container running | ✓ |
892-
| `none` | external iface attached on vlan subnet (resolved to eth1) | ✓ |
912+
| `none` | external iface attached on vlan subnet (resolved to eth0) | ✓ |
893913
| `none` | anchord log confirms F-37 network-based iface resolution | ✓ |
894914
| `none` | nftables anchord_v4 table installed | ✓ |
895915
| `none` | nftables anchord_v6 table installed | ✓ |
896-
| `none` | eth1 keeps Docker-bootstrapped IPv4 | ✓ |
897-
| `none` | eth1 keeps Docker-bootstrapped IPv6 | ✓ |
916+
| `none` | eth0 keeps Docker-bootstrapped IPv4 | ✓ |
917+
| `none` | eth0 keeps Docker-bootstrapped IPv6 | ✓ |
898918
| `none` | S-2 (v4) source IP preserved through DNAT | ✓ |
899919
| `none` | S-2 (v6) source IP preserved through DNAT | ✓ |
900920
| `none` | S-3 dnat_tcp:25 reflects current transit IP within 8s | ✓ |
@@ -903,12 +923,12 @@ release-readiness signal.
903923
| `none` | S-6 logs show graceful shutdown | ✓ |
904924
| `none` | S-6 nat teardown clean (no warnings) | ✓ |
905925
| `dhcpv6-stateful` | anchord container running | ✓ |
906-
| `dhcpv6-stateful` | external iface attached on vlan subnet (resolved to eth0) | ✓ |
926+
| `dhcpv6-stateful` | external iface attached on vlan subnet (resolved to eth1) | ✓ |
907927
| `dhcpv6-stateful` | anchord log confirms F-37 network-based iface resolution | ✓ |
908928
| `dhcpv6-stateful` | nftables anchord_v4 table installed | ✓ |
909929
| `dhcpv6-stateful` | nftables anchord_v6 table installed | ✓ |
910-
| `dhcpv6-stateful` | eth0 has IPv4 from 10.99.0.0/24 | ✓ |
911-
| `dhcpv6-stateful` | eth0 has IPv6 from fd99::/64 (DHCPv6 or bootstrap) | ✓ |
930+
| `dhcpv6-stateful` | eth1 has IPv4 from 10.99.0.0/24 | ✓ |
931+
| `dhcpv6-stateful` | eth1 has IPv6 from fd99::/64 (DHCPv6 or bootstrap) | ✓ |
912932
| `dhcpv6-stateful` | anchord_v4 dnat_tcp contains port 25 | ✓ |
913933
| `dhcpv6-stateful` | anchord_v6 dnat_tcp contains port 25 | ✓ |
914934
| `dhcpv6-stateful` | S-2 (v4) source IP preserved through DNAT | ✓ |

cmd/anchord/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ func runNetworkAnchor(ctx context.Context) error {
299299
// the moment the picker settles the next create resolves
300300
// against the fresh value.
301301
watcher.SetSharedNetworkFunc(picker.Chosen)
302+
// Issue #10: rebind wrap dependents when the SA is recreated.
303+
// Opt-out via ANCHORD_AUTOFIX_DEAD_NETNS=false.
304+
watcher.SetAutoFixDeadNetns(cfg.AutoFixDeadNetns)
302305
if cfg.ManagedSA.Active() {
303306
slog.Info("managed service-anchor recipe active",
304307
"target", cfg.ManagedSA.Target,

0 commit comments

Comments
 (0)