Files
Felis/docs/troubleshooting.md
T
flyemoji ac02c69612 docs(troubleshooting): add operator failure-mode checklist
Add docs/troubleshooting.md covering the common failure modes the spec
implies, grounded in the actual control-plane code paths:

- Stuck Starting (PodNotReady / RconSecretUnavailable / RconNotReachable)
  and the deliberate absence of a Starting->Failed timeout.
- Failed reachable only via InvalidSpec on a malformed spec.storage.size,
  plus the stale status.endpoint=direct caveat after a failure.
- Routing via status.endpoint direct/fallback and the empty fallbackServer
  pitfall; wake 403/429/503 gate order.
- online-mode coupling and Velocity's offline-mode routing refusal.
- Cloudflare Access 401/403, nil-Keyfunc fail-closed, audience checks,
  the absence of an issuer check, and local-session gating.
- Internal service-token (FELIS_SERVICE_TOKEN) rejection path.
- link/claim error codes, Kaniko build denials (SA-by-absence RBAC,
  default-deny egress, internal-registry push gate), and the registry
  DNS contract.
- Reaper backup-before-delete invariant and false-delete vectors.
- Unimplemented idle auto-stop, permanently-zero players.online, the
  inert CRD fields, and the always-survives world PVC behaviour.

Each item is labelled with its evidence grade (GO-TESTED / CODE-ONLY /
INTEGRATION-ONLY / INERT) so operators know what is verified versus
asserted.
2026-06-30 13:38:37 +09:00

545 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Felis Troubleshooting Checklist
This file is the spec §28 #23 deliverable: a故障排查清单 (troubleshooting
checklist) for the failure modes the platform actually produces. Every symptom
below is traced to a concrete signal — a `status.conditions` reason, an HTTP
error code, a log string, or a manifest name — so an operator can map what they
see to the code path that emitted it.
## How to read this document
Each entry is **symptom → likely cause → where to look → fix**. Signals are
graded for how far the in-repo Go test suite proves the behaviour:
- **[GO-TESTED]** — a hermetic `*_test.go` exercises this exact path; the
string/code is asserted in CI.
- **[CODE-ONLY]** — the code path and string exist and are real, but no unit
test drives them (notably the Velocity Java plugin, which is not compiled or
tested in this repo).
- **[INTEGRATION-ONLY]** — the symptom is produced by the kubelet, kaniko,
containerd, Postgres, or the network, not by Felis Go code; you will see it in
`kubectl describe` / pod logs, never in `MinecraftServer.status`.
- **[INERT]** — the configuration field exists in the CRD but no controller
reads it. Tuning it does nothing. These are the highest-value traps and are
collected in §10.
The operator never invents the parent domain; routing identity is
`spec.subdomain` under the deployment zone. Examples below use
`<root-domain>` / `registry.<ns>.svc:<port>` placeholders rather than any
concrete host.
---
## 1. Server is stuck in `Starting` and never becomes `Running`
`MinecraftServer.status.phase` stays `Starting`. The single most important fact:
**the operator has no start timeout.** A start that never succeeds loops in
`Starting`, requeued every 5s, **forever** — it is never auto-escalated to
`Failed`. [GO-TESTED: `TestReconcileRunning_RconProbeFailureStaysStarting`
asserts the phase stays `Starting`.] Do **not** reach for
`spec.startup.timeoutSeconds` / `spec.startup.readinessTimeoutSeconds` — both are
[INERT] (§10). A hung start must be diagnosed from pod state, not from
`MinecraftServer.status`.
First, read the condition reason:
```
kubectl get minecraftserver <name> -o jsonpath='{.status.conditions}'
```
`markStarting` writes the same reason to both `Ready=False` and
`RconReached=False`. The reason is exactly one of:
| `status.conditions[].reason` | Meaning | Requeue |
|---|---|---|
| `PodNotReady` | Pod not TCP-ready yet (`status.readyReplicas < 1`) | 5s |
| `RconSecretUnavailable` | RCON secret missing or malformed | 10s |
| `RconNotReachable` | RCON dial/auth failed | 5s |
[GO-TESTED for the reason set.]
### 1a. `PodNotReady` — pod never goes ready
The operator cannot tell *why* the pod is not ready; **a broken image, an
unbound PVC, and a backend that simply has not finished booting all surface as
the identical `PodNotReady` signal.** [GO-TESTED that the reason is emitted;
[INTEGRATION-ONLY] for the underlying pod cause.] You must drop to the pod:
```
kubectl get pod -l app.kubernetes.io/name=<name>
kubectl describe pod <pod> # look at Events + container State
```
- **`ImagePullBackOff` / `ErrImagePull`** → `spec.image` is wrong, the tag does
not exist, or the registry is unreachable. `spec.image` is copied verbatim into
the container with **zero validation** by the operator. Fix the image
reference, or see §7 (registry reachability) and §6 (build push target).
- **PVC `Pending`** → `kubectl get pvc -l app.kubernetes.io/name=<name>`. A
nonexistent `spec.storage.storageClassName`, or a request larger than any class
can satisfy, leaves the PVC unbound. The operator does **not** error on this
(only a malformed *quantity* errors — §2); it waits in `Starting` indefinitely.
Fix the StorageClass name or capacity. [INTEGRATION-ONLY.]
- **Container crash-looping before the readiness port opens** → check container
logs; this is a backend/entrypoint problem, not a Felis problem.
### 1b. `RconSecretUnavailable` — RCON secret missing or malformed
The probe needs the RCON password from `spec.rcon.secretRef`. The message is the
verbatim error: [CODE-ONLY for these branches]
- `rcon.secretRef.name and .key are required when rcon is enabled` — you enabled
`spec.rcon.enabled` but left `secretRef.name` or `secretRef.key` empty.
- `secret "<name>" has no key "<key>"` — the Secret exists but lacks the named
key.
Fix: create the Secret with the referenced key, or correct `secretRef`. Verify:
```
kubectl get secret <secretRef.name> -o jsonpath='{.data.<key>}' | base64 -d | wc -c
```
### 1c. `RconNotReachable` — RCON dial or auth failed
The probe is a TCP connect **plus** RCON auth handshake, then immediate close —
**no command is ever run; a successful auth IS the entire readiness gate.** The
message is the verbatim dial error:
- `rcon: authentication failed` → the password in the Secret does not match the
backend's `rcon.password`. Reconcile the two. [GO-TESTED that this maps to
`RconNotReachable`.]
- `connection refused` / `i/o timeout` → the backend has not opened the RCON
port yet, RCON is disabled in `server.properties`, or `spec.rcon.port`
(default 25575) is wrong. [INTEGRATION-ONLY for the live handshake.]
The per-probe timeout is a fixed 5s in code — it is **not** derived from
`spec.startup.readinessTimeoutSeconds` ([INERT]).
---
## 2. Server is in `Failed`
There is exactly **one** path to `PhaseFailed`: `markFailed(server,
"InvalidSpec", err)`, reached only when the StatefulSet cannot be built. In
practice this means **a malformed `spec.storage.size`** (an unparseable resource
quantity), surfaced as:
```
invalid storage size "<value>": <parse error>
```
`status.conditions` will show `Ready=False` and `Provisioned=False`, both with
reason `InvalidSpec`. Fix the quantity (e.g. `10Gi`, not `10 GB`) and the server
leaves `Failed` on the next reconcile. [GO path is real; the specific branch is
[CODE-ONLY] — the reconciler test fixture uses a valid size.]
Two caveats when a server has been in `Failed`:
- A pod-not-ready or unreachable-RCON server is **never** `Failed`; it is
`Starting` (§1). If you see `Failed`, it is a spec problem, not a runtime one.
- `markFailed` does **not** reset `status.endpoint`. A server that fails after
having been `Running` keeps a stale `endpoint.mode=direct`. The proxy should
treat any non-`Running` phase as "do not route direct" rather than trusting a
lingering `direct` endpoint (§4).
---
## 3. Players can't join / get sent to the wrong place
Routing is driven by `status.endpoint`:
- `markRunningReady` is the **only** writer of `endpoint.mode=direct`
(`address=<game address>`), and only while `phase=Running` and RCON-ready.
- `markStarting`, `markStopping`, `markStopped` all write
`endpoint.mode=fallback`, `address=spec.fallbackServer`.
So the proxy should route `direct` **only** when `phase=Running`; otherwise it
gets a `fallback` endpoint. [GO-TESTED for the direct/fallback toggle via
`markRunningReady`/`markStopped`.]
Two pitfalls:
1. **`spec.fallbackServer` is empty** → the fallback endpoint `address` is `""`,
so during `Starting`/`Stopping`/`Stopped` the proxy has no lobby to park the
player in. Set `spec.fallbackServer` to a registered Velocity server name.
2. **Stale `direct` after `Failed`** → see §2; the proxy must not honour a
`direct` endpoint unless `phase=Running`.
### 3a. Wake-on-join is refused, slow, or rate-limited
When a player joins a stopped server, the proxy parks them in the fallback and
calls the internal wake API. The authorization gate order is **autostartPolicy →
per-server cooldown → global running cap**. Map the API result:
| HTTP | Code | Cause | Fix |
|---|---|---|---|
| `403` | `forbidden` | `autostartPolicy=allowlist` and UUID not allowlisted, or `ownerOnly` and caller is not owner | Add the UUID / claim the server / set `autostartPolicy=public` |
| `429` | (cooldown) | Wake retried within the 30s per-server `WakeCooldown` | Wait out the cooldown |
| `503` | `at_capacity` | Global `MaxRunningServers` cap reached | Stop another server or raise the cap |
[GO-TESTED: `handlers_internal_wake_test.go`, cooldown, running-cap shape.] The
operator's RCON probe — **not** the wake call — is the authoritative readiness
gate; the proxy polls `GET /api/v1/internal/servers/{name}/status` every ~2s and
teleports when `ready=true`.
The Velocity-side consumption of these codes (`403` → "You're not allowed to
start «server»"; `429` → re-queue; other → "Couldn't start … Try again
shortly.") lives in the Java plugin and is **[CODE-ONLY]** — the codes it reacts
to are produced by the Go-tested `authorizeWakeByUUID` / cooldown limiter, so
grade the two halves separately.
---
## 4. Routing is disabled even though servers are up (online-mode coupling)
Wake/claim/allowlist semantics trust **Mojang-verified online-mode UUIDs**. If
the proxy runs `online-mode=false`, those identities are spoofable, so the
Velocity plugin **refuses to activate routing**:
```
Felis routing DISABLED: the proxy is in offline mode (online-mode=false).
Domain autostart and the allowlist trust Mojang-verified UUIDs; refusing to
route on spoofable identities. /link remains available. Set online-mode=true to
enable routing.
```
[CODE-ONLY — `FelisVelocityPlugin.onProxyInitialize`.] `/link` still works
(account binding does not depend on routing), but no domain autostart happens.
The summary line prints `routing: disabled (offline mode)` or
`(no root-domain set)`. Fix: set `online-mode=true` on the proxy, or configure
the root domain if the log says `no root-domain set`.
On the server side, `spec.autostartPolicy` and `spec.onlineMode` are documented
as only meaningful when the proxy enforces `online-mode=true`. Setting them does
not by itself make an offline proxy safe — the proxy guard is the enforcement
point.
---
## 5. Web panel returns 401 / 403 (Zero-Trust / Cloudflare Access)
The external face accepts either a Cloudflare Access JWT
(`Cf-Access-Jwt-Assertion` header) **or** a local session cookie. The error
envelope is always `{"error":{"code","message","request_id"}}`. [GO-TESTED.]
- **`401 unauthorized`** — not authenticated: no/invalid Access JWT and no valid
session. [GO-TESTED.]
- **`403 forbidden`** — authenticated but not permitted (e.g. a non-admin
principal hitting an admin route; `IsAdmin()` requires `role=admin` **and**
arrival via the admin Access audience/host). [GO-TESTED.]
### 5a. Every external request 401s on a fresh deploy
The Access verifier is wired **fail-closed**: `Keyfunc` (the JWKS key function)
is `nil` until deployment wiring supplies it. With a nil Keyfunc, **every** JWT
verification fails, and startup logs:
```
felis api: external face fails closed (Access JWKS key function not configured)
```
[INTEGRATION-ONLY — the live JWKS path is a deployment point.] This is intended:
the panel rejects all callers until JWKS is configured. Fix by wiring the
Access JWKS key function for `cfg.Auth.AccessJWTAud`.
### 5b. Token rejected with audience error
```
token audience does not include "<aud>"
```
The JWT's `aud` claim does not contain the configured `cfg.Auth.AccessJWTAud`
(or the admin audience for admin routes). [GO-TESTED.] Confirm the Access
application audience matches `cfg.Auth.AccessJWTAud`.
**Trust-model note for operators:** verification is **expiration-required +
audience + signing-key (JWKS)**. There is **no `iss` (issuer) check** anywhere in
the verifier. Trust rests entirely on the audience claim plus the JWKS signing
key. When documenting or auditing the trust boundary, do not assume issuer is
validated — it is not.
### 5c. Local-password login fails or is silently rejected
Local sessions use the `felis_session` cookie (HttpOnly, Secure, SameSite=Lax,
12h TTL, host-only). They are gated by the `local_auth_enabled` row in
`platform_settings`, read live per request and **fail-closed** (missing or
unparseable → treated as disabled). Symptoms:
- Cookie present but login rejected with `local auth disabled` → the
`local_auth_enabled` setting is false/absent. A present cookie under disabled
local-auth is **rejected outright**, not fallen through to the JWT path.
- `invalid session: …` → bad/forged session hash.
Fix: set `local_auth_enabled=true` in `platform_settings` if local password auth
is intended. [GO-TESTED for the session/QR-login logic.]
---
## 6. Internal API rejects Velocity / proxy callers (service-token)
The internal face (`--internal-addr :8081`, routes under
`/api/v1/internal/...`) is **never** Zero-Trust; it authenticates a single
service token via `Authorization: Bearer <token>`, compared in constant time.
- **All internal calls 401** → the token is unset or wrong. The API reads env
`FELIS_SERVICE_TOKEN`. If unset, startup logs:
```
felis api: warning: FELIS_SERVICE_TOKEN unset — internal face will reject all callers
```
and wires an empty token, which rejects **everyone** (no bypass). [GO-TESTED
for the constant-time compare / empty-token rejection.]
In-cluster, the token's source of truth is the Secret `felis-service-token`
(key `token`), injected as `FELIS_SERVICE_TOKEN` on the API Deployment. Fix:
```
kubectl get secret felis-service-token -o jsonpath='{.data.token}' | base64 -d
```
Ensure the proxy is configured with the identical value.
---
## 7. Account-link and claim API errors
Codes from `handlers_account.go` / `handlers_internal.go`. [GO-TESTED.]
| HTTP | Code | When |
|---|---|---|
| `400` | `bad_request` | Missing `mc_uuid`, empty code, or invalid `auth_source` (must be `mojang`/`thirdparty`) |
| `400` | `invalid_code` | Link code unknown or expired (10-min TTL, 8-symbol code) |
| `409` | `already_linked` | That MC UUID is already linked to **another** user |
| `412` | `not_linked` | Claim/owner op by a caller with no verified account link |
| `403` | `quota_exceeded` | Claim would exceed the user's server quota |
| `409` | `already_claimed` | Atomic `UPDATE … WHERE owner_id IS NULL` affected 0 rows |
| `404` | `not_found` | Unknown server/resource |
Flow reminder: the **code is minted in-game** on the internal face
(`POST /api/v1/internal/account/link/code`, proves the UUID) and **verified on
the web** external face (`POST /api/v1/account/link/verify`, proves the user).
A `409 already_linked` rolls the transaction back and **preserves** the code so a
different user can still use it. The claim's race-safety is the single
conditional `UPDATE` under READ COMMITTED — `1` row → `200`, `0` rows → `409`.
The real SQL execution is [INTEGRATION-ONLY] (no sqlmock/dockertest in repo); the
handler logic is [GO-TESTED] via an in-memory fake repo.
---
## 8. Image build fails (Kaniko, spec §14/§16)
### 8a. Build push rejected at submission with `400`
Pre-build validation rejects any push target that is not the internal registry:
```
must target the internal registry "<registry.<ns>.svc:<port>>", not "<your-target>"
```
[GO-TESTED via the `validate` gate.] Fix the image reference to push to
`cfg.Registry.URL` (the internal registry — §9).
### 8b. Build Job's ServiceAccount can do nothing (RBAC "denial" by design)
The build/restore Job SAs (`felis-build`, `felis-restore`, namespace
`felis-build`) have **no Role and no RoleBinding anywhere** — isolation is the
*absence* of permissions (spec §16/§21). If you see the build SA denied a
namespaced API operation, **that is correct, not a misconfiguration.** [GO-TESTED
that the rendered manifests give these SAs no Role.] Do not "fix" it by granting
the build SA permissions.
### 8c. Build hangs then fails fetching base image / packages
The build namespace runs a default-deny egress NetworkPolicy
(`felis-build-egress`); the **only** allowed egress is the package-mirror CIDRs
from `--package-cidr`, which **defaults to none** (fail-closed, no internet).
[GO-TESTED for the netpol shape.] A kaniko run that hangs pulling a base image or
OS package from a non-allowlisted host is the egress policy doing its job — the
hang/failure text comes from kaniko/containerd and is [INTEGRATION-ONLY]. Fix:
add the mirror CIDR via `--package-cidr`, or pre-stage the base image in the
internal registry.
### 8d. Build reaches `Failed` phase
`reconcileBuilds` polls the Job; a Job reaching `Failed` is surfaced via
`writeBuildError` (JobPhase→Failed). [GO-TESTED for the mapping.] The underlying
cause — a kaniko build error or the **Trivy CRITICAL-CVE gate** failing the build
before push (spec §16) — is in the Job's pod logs and is [INTEGRATION-ONLY].
Inspect:
```
kubectl logs -n felis-build job/<build-job>
```
---
## 9. Registry push/pull failures (spec §15)
The in-cluster registry is Deployment/Service/PVC named `registry` in the
control namespace (or `--registry-namespace`):
- **Push/pull target (the exact string to match):**
`registry.<ns>.svc:<port>` (port from `--registry-port`, default `5000`;
e.g. `--felis-image registry.felis.svc:5000/felis:v1`). A *wrong* push URL is
caught at build time by the validate gate (§8a, `400`). An *unreachable*
registry at runtime (wrong DNS/port, PVC unbound, missing default StorageClass)
surfaces as kaniko push or kubelet pull errors — [INTEGRATION-ONLY], **not** a
Felis-emitted string.
- **Storage:** PVC is RWO, `10Gi`, mounted at `/var/lib/registry`, **no
`storageClassName`** → binds the cluster default class. If the cluster has no
default StorageClass the PVC stays `Pending` and the registry never starts.
- **Selector quirk worth knowing:** the registry Service selector is only
`name + component=registry` — it deliberately lacks the
`part-of=felis-control-plane` label, so the registry is *invisible* to the
RCON-peer NetworkPolicy selector. This is intended isolation, not a bug; do not
"fix" it by adding the label.
Registry manifest rendering is [GO-TESTED]; actual serving is
[CODE-ONLY/INTEGRATION-ONLY].
---
## 10. World reaper: false-deletes and skipped backups (spec §18)
The reaper is a **run-once daily CronJob batch**, not an operator controller. It
reaps a world only when `now - last_active_at > 15d` (`inactive_15d`); the 15-day
deadline is **hard-fixed in code** (only `warn_before` / `retention` /
`max_local_bytes` are configurable from `felis.toml [archive]`).
### The backup-before-delete invariant
The reap sequence (all [GO-TESTED] hermetically) preserves the world unless a
**confirmed, DB-recorded backup exists**:
1. `ensureCapacity` (only if `max_local_bytes > 0`) → store full ⇒ world
**preserved** (not deleted).
2. `Archiver.Archive` fails ⇒ world **preserved**, PVC untouched.
3. `InsertBackup` (DB) fails ⇒ the orphan archive is deleted, PVC **untouched**.
4. **Only then** `DeletePVC` → `ReleaseWorld` → `Stop` (cosmetic) → audit →
`felis_reaper_worlds_deleted_total++`.
So a missing backup never results in a deleted world. [GO-TESTED:
`TestReapArchiveFailurePreservesWorld`,
`TestReapInsertBackupFailurePreservesWorld`, `TestReapIdleWorldFullSequence`.]
### Exemptions (world never reaped)
- `spec.reaperExempt=true` → skipped entirely (system servers). [GO-TESTED
`TestReapExemptServerNeverTouched`.]
- CRD missing → logs `reaper: CRD missing, skipping`, skipped.
- Idle `≤ 15d` → not yet eligible.
### Genuine false-delete risk vectors
- **Stale `last_active_at`.** The keep-alive is `RecordJoin`, called from the
internal `join-event` handler. **If join events are not delivered to the API,
the activity clock never resets** and an actively-played world becomes
reap-eligible after 15 days. Verify join events are flowing (§3a) — this is the
most important reaper check. [INTEGRATION-ONLY for the live Postgres write.]
- **Unowned servers are still reaped.** A server with `owner_id=""` gets **no
pre-deletion warning** (`maybeWarn` skips unowned), but is still reaped at 15d.
[GO-TESTED `TestReapUnownedServerStillReaped`.] Claim or exempt servers you
want to keep.
- `DeletePVC` is idempotent (missing PVC is not an error), so a re-run will not
fail on already-reaped worlds; and `Stop` failure is only logged, so a reaped
world's `MinecraftServer` may not be flipped to `Stopped`.
Only `TarLocal` (tar+gzip) archiving is implemented; VolumeSnapshot/Longhorn
backends return `not implemented in this build`. The live PVC delete / Postgres
store paths are [INTEGRATION-ONLY].
---
## 11. Idle auto-stop never fires; player count always shows 0
**Idle auto-stop is entirely unimplemented in the operator.** `spec.idle.*`
([INERT], §12) is read by no controller, and no idle controller is registered.
An empty `Running` server stays `Running`.
Relatedly, `status.players.online` is **permanently 0**: the only writer of
`status.players` zeroes it on stop, and the RCON prober only dials + closes — it
never runs `list`. Any panel reading `status.players.online` will always show
empty. Do not build alerting on it, and do not expect "empty server" automation.
Note the reaper's `last_active_at` (§10) is a *different* subsystem (Postgres
business layer, bumped by join events) — it keeps worlds alive against the
reaper, but it does **not** auto-stop empty running servers.
---
## 12. Inert configuration fields (highest-value traps)
These CRD fields exist and validate, but **no controller reads them.** Setting
them has **no effect**. Verified by grep: each appears only in the type
definition and its deepcopy, never in a controller.
| Field | What you might expect | Reality |
|---|---|---|
| `spec.startup.timeoutSeconds` | Start budget before `Failed` | **[INERT]** — no Starting→Failed timeout exists; a hung start loops forever (§1) |
| `spec.startup.readinessTimeoutSeconds` | First-probe budget | **[INERT]** — probe timeout is a fixed 5s in code |
| `spec.idle.autoStopEnabled` | Auto-stop empty servers | **[INERT]** — idle auto-stop unimplemented (§11) |
| `spec.idle.emptySecondsBeforeStop` | Empty grace period | **[INERT]** |
| `spec.storage.retainOnDelete` | Keep/drop PVC on delete | **[INERT]** — world PVCs **always** survive server deletion; only the reaper ever deletes a world PVC (§13) |
If a runbook tells someone to "tune `startup.timeoutSeconds`" for a hung start,
it is wrong — use `kubectl describe pod` (§1a) instead.
---
## 13. World PVC survives after I deleted the MinecraftServer
This is expected. The world PVC is a StatefulSet `VolumeClaimTemplate`. There is
**no `persistentVolumeClaimRetentionPolicy` and no finalizer** anywhere in the
operator. Deleting the `MinecraftServer` garbage-collects the StatefulSet, but
StatefulSet deletion does **not** cascade to its template PVCs, and nothing else
cleans them up. So the world PVC **always survives** server deletion, regardless
of `spec.storage.retainOnDelete` ([INERT], §12). The **only** code that deletes a
world PVC is the reaper, and only after a verified backup (§10). To reclaim a
world PVC manually:
```
kubectl get pvc -l app.kubernetes.io/name=<name>
kubectl delete pvc <pvc> # irreversible — the world is gone
```
---
## 14. Metrics for diagnosis (spec §23)
All four mandated metrics have real producers; scrape them when triaging:
- `felis_servers_total` — managed server count.
- `felis_start_duration_seconds` — histogram, observed once per start when
readiness is first reached (`ReadySignalAt − StartRequestedAt`). A start that
never completes (§1) contributes **nothing** here — absence of observations is
itself the signal that starts are hanging.
- `felis_image_build_failures_total` — increments on build Job failure (§8d).
- `felis_reaper_worlds_deleted_total` — increments only after a world PVC is
actually deleted post-backup (§10); a spike here means worlds crossed the 15d
idle line — cross-check that join events are flowing (§10 risk vectors).
---
## Quick reference: symptom → section
| Symptom | Section |
|---|---|
| Stuck `Starting`, never `Running` | §1 |
| `Starting` with `PodNotReady` (image? PVC? boot?) | §1a |
| RCON secret/auth/port errors | §1b, §1c |
| Phase `Failed` | §2 |
| Players land in lobby / wrong place | §3, §4 |
| Wake refused / rate-limited (403/429/503) | §3a |
| Routing disabled, offline-mode | §4 |
| Panel 401/403; fails-closed; audience error | §5 |
| Local password login rejected | §5c |
| Internal callers 401 (service token) | §6 |
| Link/claim 400/409/412/403/404 | §7 |
| Build push 400 / SA denied / egress hang / Failed | §8 |
| Registry push/pull unreachable | §9 |
| World deleted unexpectedly / backup skipped | §10 |
| Idle auto-stop not firing; player count 0 | §11 |
| A config field seems ignored | §12 |
| PVC left behind after delete | §13 |
| Which metric to scrape | §14 |