fix(server): own the vuls2 db for the process instead of per request - #2628
fix(server): own the vuls2 db for the process instead of per request#2628MaineK00n wants to merge 1 commit into
Conversation
|
Do you have a built image with this I can use to test ? |
Server mode created a vuls2 db session per request, so every request decided for itself whether the db was due for a download, fetched it, and opened bolt. A pod that came up with no db on disk therefore had each arriving request start its own multi-gigabyte fetch into the same directory, and a request that did find a db opened bolt three times and threw away the read cache it had just built. Hoist the db out of the request path: - SharedDB opens the db once and hands every request a borrowed session over that one open handle. bolt allows concurrent read transactions on one handle and the vuls2 cache is a sync.Map, so sharing is safe; the shared session deliberately carries no read cache, because that cache never evicts and one outliving a request would grow until the process is OOM-killed. - Only the goroutine that opens and refreshes the db fetches, so a fetch is single-flight and never blocks a request. /health reports 503 until a db is open and /vuls refuses rather than fetching one of its own, so a request that arrives first is no longer the thing that downloads the db. Point a readiness probe at /health: a liveness probe would restart the process partway through the first fetch and start it over. - A new db is installed alongside the one it replaces and the old one is closed by whichever request releases it last, so a refresh never munmaps a db out from under a running query. A refresh that fails leaves the working db in place. - Startup opens whatever usable db is already on disk before it considers downloading one, so a process that has a db serves in the time it takes to mmap a file rather than after a full fetch. - A refresh resolves the repository manifest and skips the download when its digest matches the one recorded in the local db. Going by timestamps alone, a nightly db past the staleness window looks due on every check for as long as it lives, which re-fetched gigabytes hourly even when the tag had not moved. - Size detection workers by GOMAXPROCS rather than NumCPU, which reports the machine's CPU count even when a cgroup quota lets far fewer of them run. - Bound concurrent detections with -max-concurrency (default GOMAXPROCS). One detection holds every CVE it finds, so an unbounded number of them oversubscribes memory badly enough to stall the server. Requests queue on it rather than being rejected. Refs #2613 Refs #2615 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e0431f8 to
e84162c
Compare
|
I had a look at your That branch has this PR at the base with #2617 and #2618 rebased on top. All three rewrite This PR is meant to replace #2617 and #2618 rather than sit on top of them, so please build the branch on its own: I confirmed that from a fresh clone. I have also rebased the branch onto master, which incidentally drops the duplicate I don't have a published image to hand you — the upstream To set expectations: I haven't verified this against a real deployment yet — I don't have an environment that reproduces #2613/#2615. It builds and the unit tests pass, but that's all I can vouch for so far, which is exactly why your testing would help a lot. |
|
So, I tested your image. The database load at startup works as expected ! Hope it helps |
What did you implement:
Server mode created a vuls2 db session per request, so every request decided for itself whether the db was due for a download, fetched it, and opened bolt. This is the shared root cause behind two discussions:
shouldDownload, the metadata check innewDBConfig, and the real open) and built a read cache it threw away on the way out, so concurrent requests shared nothing and re-read the same CVEs from disk.This PR hoists the db out of the request path.
SharedDBowns the db for the processSharedDBopens the db once and hands every request a borrowed session over that one open handle. Sharing is safe: bolt allows concurrent read transactions on a single open handle, every vuls2 storage read is such a transaction, and the vuls2 cache is async.Map.The shared session deliberately carries no read cache (
WithCache: false). The vuls2 cache never evicts, so one that outlives a request would accumulate every advisory and vulnerability it ever read until the process is OOM-killed.newDBConfiggained awithCacheparameter for this; the report/detect path keeps its per-run cache, where the lifetime is bounded and the cache is what collapses the O(K²) re-readsenrichwould otherwise do.Nothing on the request path fetches
Only the goroutine that prepares and refreshes the db fetches, so a fetch is single-flight by construction. Until a db is open,
/healthanswers 503 and/vulsrefuses — a request that arrives first is no longer the thing that downloads the db.Important
Point a readiness probe at
/health. A liveness probe would restart the process partway through the first fetch and start the download over from nothing.Refreshing never disturbs a running query
A new db is installed alongside the one it replaces; the old one is closed by whichever request releases it last. Closing a db munmaps it, so swapping in place would fault a query mid-flight. A refresh that fails leaves the working db in place rather than taking it away.
Startup serves from disk before considering a download
A db on disk that is merely due for a refresh is still a db worth serving, so a process that has one comes up in the time it takes to mmap a file instead of after a full fetch. Bringing it up to date is then the refresher's job, with requests being served the whole time.
Refreshes compare the repository digest first
shouldDownloadgoes by timestamps, and the nightly db'sLastModifiedis the night it was built — so a db past the staleness window looks due on every check for as long as it lives. Combined with a periodic refresh that re-fetched gigabytes hourly even when the tag had not moved.hasNewerRemoteresolves the repository manifest and compares its digest against the onefetchrecorded in the local db's metadata, so the download only happens when the tag has actually moved.Two adjacent fixes
GOMAXPROCSrather thanruntime.NumCPU(), which reports the machine's CPU count even when a cgroup quota lets far fewer of them run. On a 2-vCPU pod scheduled on an 8-core host, every request was spawning 8 workers for CPUs it would never get.-max-concurrency(defaultGOMAXPROCS) bounds concurrent detections. One detection holds every CVE it finds, so an unbounded number of them oversubscribes memory badly enough to stall the server. Requests queue on it rather than being rejected — clients with tight HTTP timeouts may see a timeout where they previously saw a slow response.Relation to #2617 / #2618
@maxenced opened #2617 and #2618 against discussion 2613, and the analysis in both discussions is theirs — including the observation that the per-request open/close pattern is the problem, and the shape of the startup fix (fetch at startup, keep the server out of rotation until it completes). This PR arrives at the same behaviour for
/healthand the same single-flight guarantee.Where it differs is scope: #2617/#2618 keep the per-request session and coordinate the fetch, which fixes 2613 but leaves 2615 untouched — the per-request bolt opens and the discarded read cache are structural to that model. This PR replaces the ownership model instead, which is what lets both discussions be closed by one change.
I'd rather not have @maxenced's work go in and then be rewritten, so I'm opening this as a draft to compare first. @maxenced — you have an environment that reproduces both problems, which I don't; would you be willing to try a build of this branch?
Type of change
Behaviour changes
/healthand/vuls503, one fetch, retried every minute/health200 ok503until a db is openHow Has This Been Tested?
make fmt,go build ./...,go vet ./...go test -race ./detector/...New tests in
detector/vuls2/shared_test.go(all run under-race):TestSharedDB_AcquireBeforeReady— a db on disk is not a db this process has open;Acquirerefuses instead of opening oneTestSharedDB_OpenLocal/TestSharedDB_Prepare— a stale db (SkipUpdate: false,LastModifiedold enough for a refresh to be due) is opened without reaching the registry, which is what proves the staleness rule no longer blocks startupTestSharedDB_OpenLocalWithoutDB— refuses rather than downloadingTestSharedDB_PrepareCancelled— gives up on ctx rather than blocking a shutdownTestSharedDB_Reload— a borrowedClose()leaves the shared db open; a reload with nothing due reuses the installed handleTestSharedDB_InstallKeepsRetiredGenerationOpenUntilReleased— the regression test for the munmap hazard: a db swapped out while a request is reading it stays readable, and is closed when that request releases itTestSharedDB_ConcurrentAcquire— 8 readers against 4 concurrent installsTest_hasNewerRemoteindetector/vuls2/db_test.gocovers the branches reachable offline (no digest recorded, no db file, unparsable repository). The digest-match path needs a registry and is not covered.Not yet verified against a real deployment — I don't have an environment that reproduces either discussion's symptoms at scale.
Checklist:
/healthmust be a readiness probe, and-max-concurrencyqueues rather than rejects. Not written yet.make fmtmake testKnown gaps
Neither this PR nor #2617/#2618 addresses the fetch failure itself. In discussion 2613 the download dies at ~5 GB with
stream error: PROTOCOL_ERROR; received from peer, andfetch.Fetchreads the layer as one stream with no retry and no ranged resume, so it starts over from zero every time. Fixing that belongs inMaineK00n/vuls2, along with the same digest precheck sovuls2 db fetchbenefits too.Until then, the reliable deployment is
SkipUpdate = truewith the db provided by an initContainer or a PVC — with this PR that path also gives the fastest startup, sinceOpenLocalsucceeds immediately.Is this ready for review?: NO