feat(serverless): coordinate early health checks once per container - #578
justinwlin wants to merge 16 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Auto-registration failures can currently escape the hard-exit failure path, undermining the “must not hang” operational guarantee during worker startup.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Moves most serverless worker fitness checks earlier (at import runpod.serverless) to fail unhealthy workers before model load, while keeping CUDA-context-creating checks deferred to start() and adding env flags to skip/defer behavior.
Changes:
- Add an import-time startup pass (
run_startup_fitness_checks) gated by worker env, with once-per-process deduping and deferred-check support. - Introduce global skip/defer env flags and more consistent “truthy” env parsing; add
nvidia-smitimeout for CUDA detection. - Add/expand tests and docs to cover startup timing, deduping, deferred checks, and late-config warnings.
File summaries
| File | Description |
|---|---|
| tests/test_serverless/test_worker.py | Asserts worker loop still runs fitness checks. |
| tests/test_serverless/test_utils/test_cuda.py | Updates CUDA availability test expectations for timeout=5. |
| tests/test_serverless/test_modules/test_fitness/test_startup.py | New test suite validating import/start timing, deferral, dedupe, and config warnings. |
| tests/test_serverless/test_modules/test_fitness/conftest.py | Resets new startup/dedupe global state between tests. |
| runpod/serverless/utils/rp_cuda.py | Adds bounded nvidia-smi probe with timeout to avoid hangs. |
| runpod/serverless/modules/rp_system_fitness.py | Marks CUDA-init and benchmark checks as deferred-to-worker-start. |
| runpod/serverless/modules/rp_gpu_fitness.py | Uses shared truthy env flag parsing for skip behavior. |
| runpod/serverless/modules/rp_fitness.py | Implements startup pass, deduping, skip/defer env flags, and late-config warnings. |
| runpod/serverless/init.py | Triggers startup checks at import (worker-only no-op otherwise). |
| README.md | Updates high-level behavior summary for new timing model. |
| docs/serverless/worker_fitness_checks.md | Documents import-time checks, deferral, and new env flags. |
| ARCHITECTURE.md | Updates architecture docs for new timing and hard-exit behavior. |
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
deanq
left a comment
There was a problem hiding this comment.
Review from /code-review (correctness + cleanup pass). Four findings, most centered on moving os._exit(1)-capable checks to import time. Lines re-anchored to the current diff.
3779487 to
3e6e213
Compare
|
bugbot run |
Update: lock file replaced with an inherited env marker, realtime hook removedSummary of what changed since the last round of review, and how the four review points map to the current code. What the PR does now, in one paragraph
Why the lock file is goneThe shared Review points
ExamplesHealthy worker. Logs from a real RTX 4090 Serverless worker on this branch: Four checks inside the import, three at Broken worker. Same endpoint with Exit about one second into Child process. A handler that spawns a Opt-outs. Validation
|
|
@copilot review |
Built-in GPU/system fitness checks ran in run_worker, which a handler module only reaches after loading its model. Run them when runpod.serverless is imported instead, so a broken environment fails in seconds. User-registered checks still run at start(); checks that already passed are not repeated. Adds RUNPOD_SKIP_FITNESS_CHECKS to disable all checks and RUNPOD_DEFER_FITNESS_CHECKS to restore the previous start()-only timing.
_cuda_init_check and _benchmark_check import torch and allocate on the device. Running them at import would leave a CUDA context in a process the handler may later fork, which CUDA does not support and vLLM/DeepSpeed trip over. Mark them @defer_to_worker_start so only subprocess-based and non-GPU checks run early.
- run startup pass on a dedicated event loop instead of asyncio.run, which resets the loop policy and breaks asyncio.get_event_loop() in handler code on Python 3.10+ - set RUNPOD_FITNESS_CHECKS_DONE after the startup pass so children re-importing this module under multiprocessing 'spawn' skip the checks - latch check auto-registration state only on success, so a malformed RUNPOD_MIN_*/GPU timeout value re-raises loudly in run_worker instead of silently disabling all system checks - compare completed checks by identity, not equality, so distinct registrations that compare equal (bound methods) are not skipped - bound the nvidia-smi call in rp_cuda.is_available with a 5s timeout - accept 1/true/yes/on for RUNPOD_SKIP_GPU_CHECK and RUNPOD_SKIP_AUTO_SYSTEM_CHECKS, matching the new flags - tests: pin the worker.py and import-time wiring, the full defer behavior, the done marker, the real auto-registration path (guard: no torch import), and bound-method re-registration; fix an orphaned coroutine in test_unexpected_error_does_not_propagate - docs: thresholds/skip flags must be set before import runpod, realtime API mode runs only the import-time checks, refresh stale ARCHITECTURE.md execution flow
…touch-ups - regression test: malformed RUNPOD_MIN_* must re-raise in run_worker, never fail open (latch-on-success) - fix dormant called/calls typo in the done-marker test - README: checks run once per check, not once at startup - ARCHITECTURE.md: failure path is os._exit(1), not sys.exit(1) - docs: GPU benchmark default timeout is 2s, not 100ms - rp_gpu_fitness docstring: lazy registration + truthy flag values
The import-time pass consumes RUNPOD_MIN_*/RUNPOD_SKIP_*/RUNPOD_GPU_* at import; values set from the handler afterwards were silently ignored. run_fitness_checks now diffs the current env against the values snapshot at the startup pass and warns with the exact fix (set before import, or RUNPOD_DEFER_FITNESS_CHECKS=true).
The shared /tmp lock file, procfs container identity, and persisted failure state solved one problem: child processes that re-import the handler (multiprocessing spawn, subprocesses) repeating the GPU probes. An environment marker solves the same problem with no new machinery. A process whose import-time pass passes sets RUNPOD_EARLY_FITNESS_CHECKS_DONE=1. Every child inherits it and skips the pass. A failed check exits before the marker is set, so it only ever means a parent passed. Setup failures leave the registration latch unset and do not set the marker either. Dropping the shared state also removes the sticky-failure behavior under wrapper entrypoints and the bounded lock waits. The realtime lifespan hook is removed as well; realtime workers keep their main-branch behavior and can be addressed separately. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…behavior Network check: an unparseable RUNPOD_WEBHOOK_GET_JOB (no scheme, non-numeric port) no longer fails the worker at start. It logs a warning and probes the public API host, the same target used when the variable is absent. GPU detection: a hung nvidia-smi is logged at WARN in both detection paths so a broken driver cannot pass silently as "no GPU on this machine". Tests: a failing hardware check in the import pass leaves no environment marker; skip flags flipped between import and start are applied at start in both directions; URL fallback and nvidia-smi timeout paths are covered. Docs: marker inheritance wording limited to descendants; ARCHITECTURE heading points at the moved module. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9dac79b to
fa686ee
Compare
|
daveseddon-runpod
left a comment
There was a problem hiding this comment.
Nice work — the safety reasoning behind this change is the hard part, and it shows.
👍 What's good
- Safe/unsafe split is right:
@defer_to_worker_starton_cuda_init_check/_benchmark_checkkeeps a CUDA context out of the pre-fork import path (vLLM/DeepSpeed), with docstrings explaining why. Network check deferred too. os._exitvssys.exitdocumented with the real reason (live non-daemon threads blocking cooperative shutdown).- Child marker
RUNPOD_EARLY_FITNESS_CHECKS_DONEreplacing the lock-file/procfs machinery — clean simplification; "marker only ever means a parent passed" is a tidy invariant. - Security:
log.secret()now fully[REDACTED](won'tstr()the value) — closes the CodeQL clear-text finding and the old length side-channel. sys.modulesaliases keep every old import path working.- Latch-on-success registration (
_register_builtinsrolls back so malformed config re-raises loudly, not silently) — good catch, and it's tested. - Docs (README, ARCHITECTURE,
worker_fitness_checks.md) updated in step.
🔴 Discuss before merge
import runpodcan nowos._exit(1)any importer in a worker container — not just the worker (sidecar scripts, spawn re-imports, a debug shell). Can we (a) call this out in_startup.py's docstring / README gotchas, and (b) confirm the gate (RUNPOD_ENDPOINT_ID+RUNPOD_WEBHOOK_GET_JOBminus test flags) is tight enough that no standard-image tooling trips it? Biggest behavioral change here._report_unhealthydropped the shared HTTP client +WORKER_IDfor rawrequests.Session()+os.environ["RUNPOD_POD_ID"]. Intended? It loses any TLS/proxy/UA/retry defaults the shared client carries, and if$RUNPOD_POD_IDis in the URL but unset, it now sends nothing (vs aWORKER_IDfallback). Best-effort, so low impact — but lazily reusing the shared client would be safer.- Type the check metadata (ties to modern types): flags live as dynamic attrs (
_runpod_defer_to_worker_start,_runpod_recheck_at_worker_start,_runpod_builtin);mypy --check-untyped-defsflags every read. A small@dataclass/Protocolwrapping callable + flags makes the states typed and discoverable and kills the "typo in agetattrdefault silently disables a check" class of bug.
🟡 Medium
nvidia-smiprobed 3+ times on the startup path (cuda.is_available(),gpu.auto_register_gpu_check(),system._get_cuda_version()), each with a multi-second timeout. Cache detection (e.g.lru_cache) — a DRY win that also speeds the startup this PR targets.configure()reassigns 5–7 module globals viaglobal(PLW0603). With the above, a frozen config object built from env once would be more testable and type-checked.binary_errortyping (gpu.py): inferredFileNotFoundError | None, later reassignedException. Annotatebinary_error: Exception | None = None.
🟢 Low / nits
_logger.pyusesOptional[str]; project is>=3.10, sostr | Noneis idiomatic (ruff UP045) and drops thetypingimport.ruff PLR2004/PLR0912in_get_cuda_version— a named constant / small split reads better.- No
CHANGELOG.mdentry despite new env vars + import-time behavior — worth one (confirm repo convention). - Remaining
ruffhits (PLC0415,S110,S607,PERF203) are intentional/correct here — noting them only to show they were reviewed, not missed.
🔒 Security
- Redaction improvement is solid; corner cases (empty, 1-char,
None,__str__-raises) are tested. _network_probe_targetavoids logging URL creds and fails safe on an unparseable URL._report_unhealthytruncatesreasonto 256 chars to the same host as the heartbeat — low risk; note the exception message could carry paths.- No new privilege:
RUNPOD_SKIP_FITNESS_CHECKSis env-gated like existing controls.
✅ Tests
- Strong coverage: eligibility, realtime exclusion, deferred-vs-early, marker inheritance (real
multiprocessingspawn), disk-recheck-at-start, network retry/bounded-close, malformed-config re-raise, redaction corner cases. test_secret_redacts_short_empty_and_object_valuesis effectively table-driven. Two asks to match our convention: (a) give each row an explicitdescription/expectedso failures name the case; (b) fold the separate_network_check/URL-fallback tests into one@pytest.mark.parametrizetable.
Overall: strong PR. Only real gates are the two 🔴 items; the rest are improvements.
Static analysis run (commands + results)
$ ruff check --select E,F,W,B,UP,SIM,C4,ASYNC,S,PERF,RUF,PL --line-length 100 runpod/
# 36 findings, by rule:
12 PLC0415 import should be at top-level (intentional: deferred to break import cycle)
7 UP045 use `X | None` for annotations (_logger.py — modernize, drop `Optional`)
7 PLW0603 `global` update in configure() (system.py / gpu.py — see Medium)
3 S607 partial executable path ("nvidia-smi")(accepted: PATH lookup of a system tool)
2 S110 try/except/pass (accepted: best-effort before os._exit)
2 PERF203 try/except within loop (nvcc/nvidia-smi fallback — minor)
1 PLW2901 loop var `line` overwritten (gpu.py:82 — harmless)
1 PLR2004 magic value `2` (system.py:243)
1 PLR0912 too many branches (13>12) (_get_cuda_version)
$ mypy --check-untyped-defs --explicit-package-bases runpod/_health/ runpod/_logger.py runpod/_startup.py
runpod/_health/fitness.py:121 error: "Callable[..., Any]" has no attribute "_runpod_defer_to_worker_start"
runpod/_health/fitness.py:131 error: "Callable[..., Any]" has no attribute "_runpod_recheck_at_worker_start"
runpod/_health/fitness.py:261 error: "Callable[..., Any]" has no attribute "_runpod_builtin"
runpod/_health/fitness.py:289 error: "Callable[..., Any]" has no attribute "_runpod_builtin"
runpod/_health/gpu.py:88/90/106/110 error: loose dict typing in _parse_gpu_test_output ("object" ...)
runpod/_health/gpu.py:259/263 error: binary_error type too narrow (FileNotFoundError | None <- Exception)The mypy check-metadata errors are the concrete backing for 🔴 #3; UP045 backs Low nit #1; PLW0603 backs Medium; the binary_error errors back Medium. The remaining ruff hits are the intentional-and-accepted set noted under Low.
Why
Serverless workers used to run health checks at
serverless.start(), which can happen after a large model has loaded. An unhealthy worker could spend that time loading a model before it failed.This PR moves the hardware checks that are safe to run early to the first
import runpod. Checks that need the running worker stay at worker start.Normal Serverless path
Disk is checked twice because loading a model can use the space that was available at import time.
Other paths and controls
main.RUNPOD_REALTIME_PORTexcludes them from the early pass.RUNPOD_TEST,--test_input, or--rp_serve_api. Early checks also require bothRUNPOD_ENDPOINT_IDandRUNPOD_WEBHOOK_GET_JOB.RUNPOD_DEFER_FITNESS_CHECKS=true: Run the checks at worker start instead of import time.RUNPOD_SKIP_FITNESS_CHECKS=true: Disable all fitness checks.RUNPOD_EARLY_FITNESS_CHECKS_DONE=1is inherited by children. They skip the early GPU probe if they importrunpodagain. A failed pass does not set the marker.The early trigger is in
runpod/__init__.py, so it does not depend on importingrunpod.serverlesseagerly. Changes to check thresholds after import are applied at worker start; disk is rechecked there after an early pass. The network check also runs at worker start, targets the worker API host, and retries within a bounded time. Registration and worker-start setup failures use the same unhealthy-exit path.This PR also fixes logger redaction and moves health modules to
runpod/_health/, with aliases at their old import paths. Whenfeat/apps-sdkis merged intomain, its lazy__init__will need to carry therun_import_checks()call.Validation
After rebasing on
main, the full local suite passed on Python 3.11: 787 tests, 6 subtests, 93.97% coverage. A realmultiprocessingspawn test confirms that a child inherits the marker and runs no early probes.🤖 Generated with Claude Code