Skip to content

fix(tunnel): supervise cloudflared and report its state on /healthz - #152

Closed
Fl0p wants to merge 1 commit into
mainfrom
flo-1068-tunnel-supervisor
Closed

Fl0p wants to merge 1 commit into
mainfrom
flo-1068-tunnel-supervisor

Conversation

@Fl0p

@Fl0p Fl0p commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

What was broken

cloudflared is a second process in the container and cotel is the main one, so restart: unless-stopped watches the wrong one. When the deploy host rebooted on 2026-10-14 and cloudflared's edge discovery hit a resolver that was not up yet, it logged Initiating shutdown, exited for good, and the container went on reporting Up (healthy) with RestartCount 0 for 11 h 39 min while every agent exporting to the public ingest URL lost its spans. /healthz answered 200 ok:true the whole time, and nothing in its body could have said otherwise.

What changed

  • scripts/supervise-cloudflared.sh (new) owns cloudflared and restarts it on every exit for as long as the container lives: 1s, doubling to a COTEL_TUNNEL_RESTART_BACKOFF_MAX (60s) cap, reset after a run that stayed up at least that long. A stop signal reaches whatever it is waiting on, so a container stop is not held for the length of a backoff. The entrypoint starts it instead of starting cloudflared directly; the token still never reaches cloudflared's environment.
    • The alternative — make the tunnel's death fatal to the container and let Docker's restart policy handle it — is rejected deliberately: it couples a working data plane to an unreachable edge, and dropping LAN ingest on :4318 over a DNS hiccup is the worse of the two failures.
  • /healthz gains a tunnel object (ok, ready_connections, error), read from cloudflared's own /ready route rather than from process liveness — a cloudflared that is up with no registered edge connection drops traffic exactly like one that is gone. It is an added field: a dead tunnel changes neither ok nor the status code, so the container HEALTHCHECK, the deploy gate and ~/ops/cotel-healthz.sh's 503/stale/empty classifications are untouched. The key is absent when no tunnel is configured, so a tokenless local or CI instance says nothing about a tunnel rather than reporting a dead one.
  • Docs: README (/healthz contract + two env vars), docs/operations/cloudflare-tunnel-remote.md (a "Keeping the tunnel up" section with the failure and the rejected alternative), docs/operations/health-probe.md (why the LAN probe reports the field but does not yet classify it), CHANGELOG.

How it was verified

End to end against a real throwaway Cloudflare tunnel (created in the isolated rzeg account for this test and deleted afterwards), in a container built from this branch and started with a refusing resolver:

step observation
start, --dns 127.0.0.1 ERR Initiating shutdown error="Couldn't resolve SRV record … region1.v2.argotunnel.com … connection refused" — the production shape
supervisor cloudflared exited with status 1 after 1s; restarting in 1s, then 2s, 4s, 5s (cap)
/healthz, tunnel down {"ok":true,…,"tunnel":{"ok":false,"ready_connections":null,"error":"cloudflared metrics listener 127.0.0.1:2000 unreachable: … connection refused"}}
/healthz, cloudflared up but unconnected "tunnel":{"ok":false,"ready_connections":0,"error":"cloudflared has no registered edge connection"}
resolver fixed, nothing else touched INF Registered tunnel connection … location=waw06 protocol=quic ×4, with no intervention
/healthz after recovery "tunnel":{"ok":true,"ready_connections":4} — HTTP 200 and ok:true throughout, RestartCount 0, container healthy
docker stop cotel checkpointed (8 ms), supervisor stopped its cloudflared, no restart, exit 0, 1s
token in docker logs 0 matches

Automated: go vet + go test ./internal/tunnel/ ./internal/dashboard/ green (in golang:1.24-bookworm, no Go toolchain on this host), bash scripts/supervise-cloudflared_test.sh 17/17 against a stub cloudflared (now wired into CI's Build & Test job), npm run build in docs/ green. The smoke job gains an assertion that a tokenless /healthz has no tunnel key.

Deploying this

Merging to main deploys and recreates the container, and the tunnel token exists only in the GitHub secret and in the live container's environment — so this must go out through the Deploy workflow (gh workflow run deploy.yml), which substitutes the secret. A hand-run docker compose up -d on the host would blank the token and drop the public path. After the deploy, check the four Registered tunnel connection lines, tunnel.ok: true on /healthz, and a 401 from https://otlp.aignite.pl/v1/traces.

🤖 Generated with Claude Code

cloudflared is a second process in the container and cotel is the main one,
so when cloudflared's edge discovery hit a resolver that was not up yet it
logged "Initiating shutdown", exited for good, and `restart: unless-stopped`
never fired. The container reported Up (healthy) with RestartCount 0 while
the public ingest path was dark and every span exported to it was dropped.

supervise-cloudflared.sh now owns the process and restarts it on every exit
for as long as the container lives, waiting 1s and doubling to a 60s cap
(COTEL_TUNNEL_RESTART_BACKOFF_MAX), resetting after a run that stayed up at
least that long; a stop signal reaches the child it is waiting on, so a
container stop is not held for a backoff. Making the tunnel's death fatal to
the container instead was the alternative and is rejected deliberately: it
couples a working data plane to an unreachable edge, and dropping LAN ingest
on :4318 over a DNS hiccup is the worse of the two failures.

/healthz gains a `tunnel` object - ok, ready_connections, error - read from
cloudflared's own /ready route rather than from process liveness, because a
cloudflared that is up with no registered edge connection drops traffic
exactly like one that is gone. It is an added field: a dead tunnel changes
neither `ok` nor the status code, so the container HEALTHCHECK and the deploy
gate keep their meaning. The key is absent when no tunnel is configured, so a
tokenless local or CI instance says nothing about a tunnel rather than
reporting a dead one.

Verified end to end against a real tunnel, in a container started with a
refusing resolver: cloudflared exited on the unresolvable edge SRV record,
was restarted on the 1/2/4/5s backoff, and registered four edge connections
on its own once the resolver was fixed. /healthz went ok:false
(metrics listener unreachable) to ok:false (ready_connections 0) to ok:true
(ready_connections 4), answering HTTP 200 with ok:true throughout, and the
token appeared nowhere in the container log.

Co-Authored-By: Wayland <wayland@agents.flopbut.local>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 50 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d5ee59c7-6438-45a7-bc7f-106a8e3abe1b
📥 Commits

Reviewing files that changed from the base of the PR and between f10624a and 645f987.

📒 Files selected for processing: 14
  • .github/workflows/ci.yml
  • CHANGELOG.md
  • Dockerfile
  • README.md
  • cmd/cotel/main.go
  • docs/operations/cloudflare-tunnel-remote.md
  • docs/operations/health-probe.md
  • internal/dashboard/handler.go
  • internal/dashboard/healthz_test.go
  • internal/tunnel/tunnel.go
  • internal/tunnel/tunnel_test.go
  • scripts/entrypoint.sh
  • scripts/supervise-cloudflared.sh
  • scripts/supervise-cloudflared_test.sh
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Fl0p

Fl0p commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Closing unmerged: the scope behind this PR was cancelled by the board (the scheduled watcher was removed entirely; any external monitoring will be proposed and reviewed before implementation). Branch deleted; restorable from this PR page if ever needed.

@Fl0p Fl0p closed this Oct 14, 2026
@Fl0p
Fl0p deleted the flo-1068-tunnel-supervisor branch October 14, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant