fix(templatecenter): reclaim leaked loop devices instead of silently degrading ext4 builds - #1295
Conversation
| detachLoop := func() { | ||
| detachOnce.Do(func() { | ||
| _ = runCommand(cleanupCtx, "", "losetup", "--detach", "--", loopDevice) | ||
| if err := runCommand(cleanupCtx, "", "losetup", "--detach", "--", loopDevice); err != nil { |
There was a problem hiding this comment.
Moderate — the deferred detach still runs before the deferred unmount on error returns, so the direct detach never succeeds on the common streaming-failure path.
defer cleanup() (the umount) is registered at line 89 and defer detachLoop() at line 129. Defers run LIFO, so on any error return after the mount succeeds (native/docker streaming failure, docker create/export failure, or post-export hook failure at lines 110–134), detachLoop() executes while the loop device is still mounted → losetup --detach fails with EBUSY, and sync.Once prevents any retry once cleanup() finally unmounts. The device then stays pinned until a later build hits allocation failure and the reclaim runs — the newly-logged detach error is real, but the detach itself effectively never succeeds on the error path.
Since this PR is specifically about not silently pinning loop devices, consider registering defer detachLoop() before defer cleanup() (or folding the detach into cleanup after the umount) so unmount precedes detach on error returns too. The reclaim would then be a backstop rather than the primary fix for the error path.
There was a problem hiding this comment.
Correct, and this was the actual cause of the leak I observed — thanks. The five pinned devices whose backing files were still live had no other explanation: their umount had succeeded, so only a detach that ran before it could have failed.
Fixed by folding the detach into the same closure as the umount, so ordering is explicit rather than a consequence of defer LIFO:
detachLoop := func() {
if loopDevice == "" {
return
}
detachOnce.Do(func() { ... losetup --detach ... })
}
cleanup := func() {
if mounted {
unmountOnce.Do(func() { ... umount, then umount -l ... })
}
detachLoop()
os.RemoveAll(mountPoint)
}
defer cleanup()loopDevice and mounted are now declared before cleanup and assigned once the allocation and the mount succeed. The mounted guard keeps the mount-failure path from emitting a spurious "umount failed" WARN, and the loopDevice == "" guard covers the case where allocation itself failed. The standalone defer detachLoop() and the explicit detachLoop() in the mount-failure branch are both gone — one defer cleanup() now covers every return.
This makes the reclaim a backstop for devices leaked by earlier builds rather than the primary fix, which is how it should have been in the first place.
|
|
||
| // reclaimOrphanLoopDevices detaches loop devices still backed by a file under | ||
| // storeDir. Only artifact-store paths are considered, and the kernel refuses the | ||
| // detach with EBUSY while a device is mounted or otherwise held, so a build in |
There was a problem hiding this comment.
Moderate — a device in the attach→mount window of a concurrent build can be pulled out from under it.
The "cannot be pulled out from under" claim holds for mounted devices (EBUSY), but not for the window between another build's losetup --find (device attached, backing file live) and its mount. Because orphanLoopCandidates matches live backing files — the (deleted) suffix is optional — such a device is an indistinguishable candidate. Reclaim detaches it, and the innocent build's mount then fails with ENOENT, silently degrading it to phase-1: exactly the failure mode this PR is meant to eliminate. This is most likely to trigger in the PR's own motivating scenario (small container loop pool + concurrent builds), since reclaim only runs when allocation is already exhausted.
Note that a live build's backing file is only unlinked in BuildExt4's failure cleanup after createExt4ImageStreaming returns, so a (deleted) backing file always denotes a finished/failed build, whereas a non-(deleted) store-backed device is either an in-flight build or a rare lazy-umount leak. Consider restricting reclaim to (deleted) backing files (and/or an explicit liveness check) to close the race while still catching the #822-style disk-leak orphans.
There was a problem hiding this comment.
Agreed — the EBUSY argument does not cover the attach→mount window, and a device is not in the mount table there.
Restricted to (deleted) entries only:
backing = strings.TrimSpace(backing)
if !strings.HasSuffix(backing, " (deleted)") {
continue
}
backing = strings.TrimSpace(strings.TrimSuffix(backing, " (deleted)"))Your reasoning about why this is safe is the reasoning I should have used: the image file is unlinked only by the failure cleanup that runs after createExt4ImageStreaming returns, so (deleted) implies the build is over. A leaked device whose file still exists is now left alone; it costs one loop slot, which the ordering fix above prevents from happening in the first place.
Tests updated: the "existing backing file under the store" case now expects no candidates, and I added a (deleted) path outside the store and a backing path containing spaces (the parser cuts on the first space, which only has to hold for NAME).
Also took the logging note — per-device reclaim is Debugf now, with the single summary Warnf staying at the call site.
|
AI-generated review — not reviewed or approved by a human. SummaryThe diagnosis is excellent and the primary fix is correct: the two separate However, the code does not implement two behaviors the PR description explicitly claims, and the tests enshrine the discrepancy:
FindingsF1 (High) — Detach failure is logged, not propagated; the success path can still leak permanently
F2 (Medium) — Stuck mount still returns success and can serve an unshrunk, unaligned imageWhen even F3 (Low) — The PR-described "device already gone" detach check is not implementedThe body says: "before retrying, the code asks F4 (Low) — Reclaim can be triggered by non-exhaustion failures, and the retry reuses the cancelled context
F5 (Info) — Narrow concurrent reclaim / detach-retry race on a reused index
F6 (Info) —
|
bcecf9c to
9551586
Compare
| } | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
After a successful umount -l, the loop device can remain busy for a short window, so this immediate losetup --detach can fail EBUSY and log "device stays pinned until reclaimed". That message overpromises for the success path: the backing .ext4 is never unlinked there, so the device is never a (deleted) candidate for reclaimOrphanLoopDevices and stays attached until host reboot — the exact leak class this PR is meant to eliminate. Consider a short bounded retry of the detach before declaring the device leaked, or wording the message to distinguish "reclaimable later" (backing file gone) from "permanent until reboot" (backing file still exists).
There was a problem hiding this comment.
Fixed with a short bounded retry (5 attempts, 200 ms apart) inside the detach, so a lazy umount that has not finished releasing the mount no longer abandons the device:
detachOnce.Do(func() {
// A lazy umount releases the mount asynchronously, so the detach can fail
// EBUSY for a short window afterwards. Retry briefly: on the success path
// the backing file is never unlinked, so a device abandoned here would not
// become a (deleted) candidate for reclaim and would stay attached until
// the host reboots.
var err error
for attempt := 0; attempt < 5; attempt++ {
if attempt > 0 {
time.Sleep(200 * time.Millisecond)
}
if err = runCommand(cleanupCtx, "", "losetup", "--detach", "--", loopDevice); err == nil {
return
}
}
log.G(ctx).Warnf("losetup --detach %s still failing, device stays attached until its backing file is removed and reclaimed: %v", loopDevice, err)
})The retry only runs on a path that has already failed, so the happy path pays nothing and the worst case is bounded at ~800 ms. The WARN wording now says the device stays attached until its backing file is removed and reclaimed, instead of implying reclaim would pick it up as-is — which, as you point out, it will not while the file still exists.
| // for the lifetime of the host, so the fast path would be lost permanently | ||
| // once enough builds have leaked. Reclaim devices whose backing file is | ||
| // already gone and retry before degrading to the much slower phase-1 build. | ||
| if n := reclaimOrphanLoopDevices(cleanupCtx, artifactStoreDirOf(ext4Path)); n > 0 { |
There was a problem hiding this comment.
The reclaim + retry fires on any losetup --find failure, not just device exhaustion — e.g. a request cancellation, a missing/odd backing file, or a permission error also triggers a losetup --list and may detach orphaned devices. It's safe (only (deleted) store devices are touched) and self-limiting, but two things are worth noting:
- The retry reuses the request
ctx, so if the original failure was a cancellation, the retry fails immediately no matter how many devices were reclaimed — the reclaim work is wasted. - If reclaim succeeds but the retry also fails, the returned error is the retry's, dropping the original error's context.
Gating on the underlying error type (exhaustion vs. others) is fragile since losetup errors come from parsing stderr, but a short comment clarifying this is best-effort-and-only-meaningful-for-exhaustion would help future readers.
There was a problem hiding this comment.
Both points are real; the reclaim is deliberately not gated on the error type, so I documented that and stopped dropping the original error.
The call-site comment now says the retry is best-effort and only meaningful for exhaustion — other causes (cancellation, permissions) pay one extra losetup --list and then fail the retry as they should. And when the retry fails, both errors are surfaced:
retryOut, retryErr := findLoop()
if retryErr != nil {
return fmt.Errorf("%w (retry after reclaiming %d device(s) also failed: %v)", err, n, retryErr)
}%w keeps the original as the wrapped cause, since that is the one that says why allocation failed in the first place.
|
|
||
| // artifactStoreDirOf returns the artifact store root for an ext4 image path. | ||
| // Streaming builds place the image at <store>/<artifact>/<artifact>.ext4. | ||
| func artifactStoreDirOf(ext4Path string) string { |
There was a problem hiding this comment.
Dir(Dir(ext4Path)) implicitly encodes the <store>/<artifactID>/<artifactID>.ext4 layout constructed in BuildExt4 — correct today, but the coupling is only visible by cross-referencing BuildExt4. If a caller ever passes a different-depth or relative path, the prefix match in orphanLoopCandidates silently no-ops (safe, but silent). Two suggestions:
- Add a one-line comment here tying this to the
BuildExt4layout, and/or guard onfilepath.IsAbs(ext4Path)so a non-absolute path doesn't silently disable reclaim. - Note the fallback-store interaction: when a build runs on
ArtifactFallbackStoreRootDir, only fallback-root orphans are reclaimed, so leaks in the primary store go un-reclaimed until a primary-store build hits exhaustion.
There was a problem hiding this comment.
Added the coupling to the doc comment rather than an IsAbs guard, since a wrong-shaped path already fails safe — it produces a prefix that matches nothing, so reclaim no-ops instead of reaching outside the store:
// artifactStoreDirOf returns the artifact store root for an ext4 image path,
// mirroring the <store>/<artifact>/<artifact>.ext4 layout BuildExt4 constructs
// (including when it falls back to ArtifactFallbackStoreRootDir, so a fallback
// build only ever reclaims fallback-store orphans). A path of another shape just
// yields a prefix that matches nothing in orphanLoopCandidates, i.e. reclaim
// no-ops rather than reaching outside the store.The fallback-store asymmetry you describe is accurate and I left the behaviour as is: each store's own exhaustion is what makes that store's orphans worth reclaiming, and widening the scan to both roots would have a build detaching devices backed by a store it is not using.
9551586 to
c7eda16
Compare
| return | ||
| } | ||
| } | ||
| log.G(ctx).Warnf("losetup --detach %s still failing, device stays attached until its backing file is removed and reclaimed: %v", loopDevice, err) |
There was a problem hiding this comment.
The warning overpromises recovery. "Until its backing file is removed and reclaimed" only holds when the build later fails and BuildExt4 unlinks the file (ext4.go:143). On the success path the backing file is never removed, so the device never becomes a (deleted) reclaim candidate and stays attached until host reboot; and when the lazy umount also failed (mount still present), reclaim's own losetup --detach will hit EBUSY and skip it too. Consider stating the actual recovery conditions (only when the backing file is later unlinked by the failure path and the device is no longer mounted).
There was a problem hiding this comment.
Reworded to state the actual conditions:
losetup --detach %s failed; device stays attached, reclaimable later only if its backing file is unlinked and the mount released
with a comment above it spelling out that the unlink only happens on the build's failure path, so on the success path the device is attached until the host reboots.
| unmountOnce.Do(func() { | ||
| if err := runCommand(cleanupCtx, "", "umount", "--", mountPoint); err != nil { | ||
| log.G(ctx).Warnf("umount %s failed, retrying lazily: %v", mountPoint, err) | ||
| if lazyErr := runCommand(cleanupCtx, "", "umount", "-l", "--", mountPoint); lazyErr != nil { |
There was a problem hiding this comment.
When the lazy umount also fails, the mount is still active and the 5-attempt detachLoop retry below is guaranteed to fail EBUSY on every try, adding ~1s of cleanup latency while never succeeding. The retry budget only helps the "lazy umount succeeded but the device is briefly busy" window — in this branch it is wasted. Consider skipping the detach retry here (log-and-return), since the device also cannot be recovered by reclaim while the mount persists.
There was a problem hiding this comment.
Right — that branch can never win. The retry budget is now skipped there:
attempts := 5
if mountStuck {
attempts = 1
}mountStuck is set in the lazy-umount failure branch, so the guaranteed-EBUSY case makes exactly one attempt and logs, while the "lazy umount succeeded, device briefly busy" window keeps the full budget.
| // This is best-effort and only meaningful for exhaustion; losetup reports | ||
| // every failure the same way, so other causes (cancellation, permissions) | ||
| // pay one extra `losetup --list` and then fail the retry as they should. | ||
| if n := reclaimOrphanLoopDevices(cleanupCtx, artifactStoreDirOf(ext4Path)); n > 0 { |
There was a problem hiding this comment.
This backstop does not self-heal the container scenario that motivates the PR. When exhaustion is caused by a container whose /dev tmpfs lacks the loopN node that LOOP_CTL_GET_FREE returned, losetup --detach fails ENOENT (the node does not exist) — both here and inside reclaimOrphanLoopDevices, which logs at Debug and skips. So in a container the retry also fails and the build still permanently degrades to phase‑1. Reclaim only recovers devices on a host with real, present /dev/loopN nodes. Acknowledged in the PR body as a deployment concern, but worth a comment here so the backstop's scope is not over-read.
There was a problem hiding this comment.
Half of this is right, but the conclusion is too pessimistic — the reclaim does recover the observed container case, and here is why.
The devices reclaim detaches are ones this process previously attached successfully, so their /dev/loopN nodes exist inside the container by construction; the ENOENT only ever applies to the node LOOP_CTL_GET_FREE just handed back. And because LOOP_CTL_GET_FREE returns the lowest free index, detaching those leaked low-index devices makes the next losetup --find return one of them again instead of climbing past the container's highest node. That is exactly the situation I hit: loop0..loop7 all attached, none mounted, so --find returned /dev/loop8 → ENOENT; after freeing them, allocation succeeded on a low index.
What reclaim genuinely cannot fix is the case with no orphans — eight builds legitimately holding loop0..loop7, where the missing node is the only problem. Added a comment at the call site saying just that:
// It also only frees devices this process managed to attach, i.e. ones whose
// /dev node exists here — it cannot create the node that LOOP_CTL_GET_FREE
// returned, so a container whose /dev tmpfs is short of loop nodes and has no
// orphans to reclaim still degrades to phase-1 (see the deployment note).c7eda16 to
5323c9c
Compare
| // device up once its backing file has been unlinked — which happens on the | ||
| // build's failure path only — and the mount is gone. On the success path the | ||
| // image stays, so the device stays attached until the host reboots. | ||
| log.G(ctx).Warnf("losetup --detach %s failed; device stays attached, reclaimable later only if its backing file is unlinked and the mount released: %v", loopDevice, err) |
There was a problem hiding this comment.
Residual leak on the success path: if umount only succeeds lazily and the loop device stays busy longer than the fixed 5×200 ms budget, this detach fails — and because the build succeeded, the backing image is kept and never unlinked, so reclaimOrphanLoopDevices (which only ever picks up (deleted) backings) can never free it. The device stays attached until host reboot. 800 ms is a thin budget for a lazily-released filesystem whose last reference is dropped by an external process; consider a longer/backoff budget.
| cleanup := func() { | ||
| if mounted { | ||
| unmountOnce.Do(func() { | ||
| if err := runCommand(cleanupCtx, "", "umount", "--", mountPoint); err != nil { | ||
| log.G(ctx).Warnf("umount %s failed, retrying lazily: %v", mountPoint, err) | ||
| if lazyErr := runCommand(cleanupCtx, "", "umount", "-l", "--", mountPoint); lazyErr != nil { | ||
| mountStuck = true | ||
| log.G(ctx).Warnf("lazy umount %s also failed, loop device will leak: %v", mountPoint, lazyErr) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| detachLoop() |
There was a problem hiding this comment.
The substance of this fix — umount-before-detach ordering, the lazy-umount fallback, mountStuck short-circuiting the retry budget, and the detach retry loop — is untested. The new tests only cover orphanLoopCandidates/artifactStoreDirOf. image_test.go already provides installFakeCommand, so faking losetup/umount/mount would let a test assert (a) detach is attempted only after umount, (b) an EBUSY detach is retried, (c) mountStuck reduces attempts to 1, and (d) allocation is retried after a successful reclaim — locking in the exact ordering this PR fixes.
a62c084 to
685bfde
Compare
|
Thanks — pushed a follow-up for findings 1 and 2. 1. The core fix is untested (medium) — fixed.
2. Success-path detach failure is an unreclaimable leak (low) — agreed, and the point that this one is genuinely unreclaimable is what makes the thin budget matter. The retry is now exponential (200 ms doubling, 6 attempts, ~6.2 s total) instead of a fixed 5×200 ms, and the comment says why the budget is spent here rather than elsewhere. The 3. 4. On the note about reclaim running for non-exhaustion failures: kept deliberately, for the reason you list — one extra |
| // device up once its backing file has been unlinked — which happens on the | ||
| // build's failure path only — and the mount is gone. On the success path the | ||
| // image stays, so the device stays attached until the host reboots. | ||
| log.G(ctx).Warnf("losetup --detach %s failed; device stays attached, reclaimable later only if its backing file is unlinked and the mount released: %v", loopDevice, err) |
There was a problem hiding this comment.
This is the one leak the new reclaim backstop can never recover, yet it is reported as success. The "reclaimable later" promise in this warning does not hold on the success path: by the time this runs the umount has already succeeded, so the image is valid, the build returns nil, and BuildExt4 keeps the file (keepStoreDir = true) — its backing file is therefore never unlinked and the device can never become a (deleted) candidate. It stays pinned to a live file until the host reboots, and the retry budget is already exhausted here, so there is no "give it more time" fallback.
Consider propagating the error instead: a detach failure after a successful umount means the image itself is fine, but returning an error would make BuildExt4's failure path unlink the file, turning this exact scenario into a future (deleted) reclaim candidate. The tradeoff (discarding a valid image on a rare cleanup failure) seems worth it for the class of permanent leak this PR exists to eliminate.
| log.G(ctx).Warnf("umount %s failed, retrying lazily: %v", mountPoint, err) | ||
| if lazyErr := runCommand(cleanupCtx, "", "umount", "-l", "--", mountPoint); lazyErr != nil { | ||
| mountStuck = true | ||
| log.G(ctx).Warnf("lazy umount %s also failed, loop device will leak: %v", mountPoint, lazyErr) |
There was a problem hiding this comment.
When both umount and umount -l fail, the mount is still attached, yet the build proceeds to step 6/7 and returns success. resize2fs -M and the pmem-alignment truncate run against a still-attached device, so the image can be left un-shrunk and not 2 MiB-aligned — downstream pmem boot may reject it ("PmemSizeNotAligned") — while the device + disk-space leak persists. Since BuildExt4's failure path unlinks the file (making the device a future reclaim candidate), failing the build here would self-heal the scenario; returning success keeps a possibly-bad image and an unreclaimable leak. This behavior is pre-existing but now detectable — worth deciding deliberately.
685bfde to
7f43a27
Compare
|
Findings 1, 2, 3 and 4 are addressed; 5 left as is with reasoning. 1 (medium) + 2 (low–medium) — you were right that reporting success is what makes these two unhealable, and they turn out to have the same fix. A detach that ultimately failed now fails the build: if detachErr != nil {
return fmt.Errorf("release loop device %s: %w", loopDevice, detachErr)
}The tradeoff you flagged ("discarding a valid image") does not actually apply: The same return also covers finding 2: the stuck-mount case is exactly a case where the detach keeps failing, so the build no longer proceeds to 3 (low, test gaps) — added two of the three:
I skipped the error-path ordering test: after the consolidation there is only one cleanup closure and both paths run the identical code, the only difference being whether it is reached via the explicit call or the 4 (low) — PR description fixed (6 attempts, exponential backoff, ~6.2 s). 5 (low, quoted |
| // below cannot work on a mounted device — the result would be an unshrunk, | ||
| // unaligned image the microVM rejects with PmemSizeNotAligned. | ||
| if detachErr != nil { | ||
| return fmt.Errorf("release loop device %s: %w", loopDevice, detachErr) |
There was a problem hiding this comment.
Failing the build assumes a failed losetup --detach always means the device is still attached. That isn't guaranteed: losetup --find --show -- <file> sets LO_FLAGS_AUTOCLEAR by default on util-linux ≥ 2.24, so on such a host a clean umount releases the last reference and the kernel auto-detaches the device before this code runs — losetup --detach then fails with "No such device or address" (ENXIO) even though nothing leaked. The result here would be 6.2s of pointless backoff retries (lines ~100-103) followed by a failed build → phase-1 fallback on every build, which is the same silent-permanent-degradation failure mode this PR is trying to remove, now on autoclear-enabled hosts.
Failing the build is the right policy only when the device is genuinely still attached (EBUSY). Consider treating an "already gone" detach — e.g. error text matching "No such device or address" / "No such file or directory" — as success, since the goal of releasing the device is already achieved. This may not reproduce in the environment described in the PR (the observed leaks imply autoclear is inactive there), but this code will run in many deployments, and the old code tolerated this case by discarding the error.
7f43a27 to
3fa9d90
Compare
| // spending the retry budget and failing the build over it would be wrong. | ||
| // Ask losetup rather than matching its error text, which is localized. | ||
| stillOurs := func() bool { | ||
| out, err := exec.CommandContext(cleanupCtx, "losetup", "--noheadings", "--raw", |
There was a problem hiding this comment.
stillOurs() treats any losetup --list failure as "device already gone" — but it's only safe to stop retrying when the device is actually no longer ours. If the first losetup --detach fails with EBUSY (e.g. the lazy-umount window) and losetup --list then fails for a transient reason that is not "device does not exist" (permission, EIO, a briefly inconsistent loop state), this returns false, the retry loop exits without setting detachErr, and the build reports success while the loop device stays attached. Because the build succeeded, the backing file is never unlinked, so the device can never become a (deleted) reclaim candidate — the exact permanent, silent leak this PR is designed to eliminate, just on a different trigger.
The intended "already released by the umount" case is distinguishable by which error the --list/--detach returns (device absent: ENOENT/ENODEV/no-such-device), rather than by "any error at all". Suggest checking the error for the device-gone case before returning false here, and letting an ambiguous error fail the detach (set detachErr) so the build falls back to phase-1 and the image gets unlinked.
3fa9d90 to
27bcdb6
Compare
|
Thanks — the medium is fixed, and both test gaps are now covered. Medium: a failed detach is not always a leak — agreed, and this was the right catch: with the new fail-the-build policy, a detach that fails because the device is already gone would turn a healthy build into a permanent phase-1 fallback, which is exactly the failure mode this PR set out to remove. I implemented the tolerance by asking stillOurs := func() bool {
out, err := exec.CommandContext(cleanupCtx, "losetup", "--noheadings", "--raw",
"--output", "BACK-FILE", "--list", "--", loopDevice).Output()
if err != nil {
return false
}
return strings.HasPrefix(strings.TrimSpace(string(out)), ext4Path)
}The loop now stops immediately — no further retries, no One thing I could not confirm and did not want to overclaim: I don't believe Stuck-mount devices are never reclaimable (low) — accepted as stated, and the bound is worth having in writing: the backstop heals only the unmounted case. A mount that survived both Test-coverage gaps (low) — both added:
Also added last round: Backoff blocking the request goroutine — agreed, no change: it is bounded, it only runs when a detach has already failed, and the happy path runs no extra commands. |
| "--output", "BACK-FILE", "--list", "--", loopDevice).Output() | ||
| if err != nil { |
There was a problem hiding this comment.
return false here conflates two very different situations: the device genuinely being gone (autoclear released it after the clean umount — correct to stop) and the probe itself failing (losetup --list exits non-zero for a reason other than "device absent"). Note this check is only reached after a losetup --detach has already failed, so at this point the device is more likely still attached to this build's image than not.
The problem: on the probe-error branch the loop treats the device as released — no retry, no warning — and because the build then reports success, the image is kept, so the device can never become a (deleted) reclaim candidate. That is the exact permanent-leak class this PR exists to fix, just reached through a different failure mode.
The two cases are distinguishable by exit status: a successful probe reporting a different BACK-FILE is a safe "not ours"; a failed probe is ambiguous. Suggest logging a warning and erring toward retry/fail on the probe-error branch, keeping the lenient path only for "probe succeeded but BACK-FILE differs".
| } | ||
| reclaimed := 0 | ||
| for _, name := range orphanLoopCandidates(string(out), storeDir) { | ||
| if err := runCommand(ctx, "", "losetup", "--detach", "--", name); err != nil { |
There was a problem hiding this comment.
Each orphan candidate gets exactly one losetup --detach with no retry. A (deleted) orphan whose lazy umount is still draining (the last reference still closing) fails EBUSY here and is skipped; the single findLoop retry then also fails, and the build degrades to a full phase-1 rebuild even though the device would have freed within a second or two.
Compounding this, both outcomes are logged at Debugf, which production drops (the level comes from conf.Level, normally INFO+). When reclaim finds candidates but frees none, the operator sees nothing except the phase-1 fallback warning — the reclaim attempt itself is invisible.
Suggest: a short bounded retry on the orphan detach, and warn-level logging when candidates existed but none were freed, so a near-miss reclaim is both self-healing and diagnosable.
…degrading ext4 builds createExt4ImageStreaming discarded the errors from both its umount and its losetup --detach cleanups, and registered the two as separate defers so LIFO ordering ran the detach first. A detach attempted while the filesystem is still mounted fails with EBUSY, which pins the loop device for the lifetime of the host; if the backing .ext4 is unlinked afterwards, the device keeps the inode alive and its disk space is never reclaimed. Once enough devices have leaked, losetup --find fails and every later build silently falls back to the much slower phase-1 path with a single WARN, never recovering without operator intervention. Run the detach from the same cleanup closure as the umount so it can no longer be ordered before it, log both cleanup failures, retry the umount lazily so a busy mount point no longer cascades into a pinned device, fail the build when the device could not be released at all — so the caller unlinks the image and the pinned device becomes reclaimable instead of being kept behind a valid build — and reclaim orphaned loop devices before giving up on the fast path. Reclaim is narrow by construction: a device is only detached when its backing file lives under the artifact store and has already been unlinked, and a build unlinks its image only after giving up on it, so a build in flight elsewhere can never become a candidate. Signed-off-by: dushulin <986525775@qq.com>
27bcdb6 to
7b19f2e
Compare
|
Correction to my previous comment: I have reverted the two policy changes it described, and the branch is now back to the behaviour of the first push. Sorry for the churn — this supersedes the What is gone again:
The reasoning is the one your round-6 medium pointed at, taken one step further. That finding was correct — but the regression it described exists only because of the fail-the-build policy I had added the round before. Removing the policy removes the need to special-case "the device is already gone" at all: a detach that fails for any reason is logged and the build succeeds, which is what the code did before this PR. Keeping the policy meant carrying a second mechanism whose only job was to protect against the first, and whose correctness depends on So the PR is back to the two things it set out to do:
The tradeoff I am accepting explicitly, rather than paying for it with more machinery: a device that cannot be detached on the success path stays attached until the host reboots and is not a reclaim candidate (the image is kept, so the backing file is never unlinked). That is exactly what happens today; the only difference is that it is now visible in the log instead of silently swallowed. Tests are kept, minus the one that asserted the reverted semantics: the umount-before-detach ordering (verified to fail if the ordering is reverted), the busy-detach retry, the lazy-umount-then-detach case, the stuck-mount single-attempt case, the detach on a failed |
| // device up once its backing file has been unlinked — which happens on the | ||
| // build's failure path only — and the mount is gone. On the success path the | ||
| // image stays, so the device stays attached until the host reboots. | ||
| log.G(ctx).Warnf("losetup --detach %s failed; device stays attached, reclaimable later only if its backing file is unlinked and the mount released: %v", loopDevice, err) |
There was a problem hiding this comment.
The PR description says a device that still could not be detached "now fails the build", but this branch only logs a warning — detachLoop returns nothing, cleanup() (lines 110–125) never propagates it, and the step-5 cleanup() call is ignored. On the success path, a detach that exhausts the 5×200ms budget leaves the device attached with a live backing file forever: the build reports success, the image is kept, the backing file is never unlinked, and the reclaim backstop (which only detaches (deleted) candidates) cannot heal it. That is exactly the permanent leak this PR sets out to fix. TestStreamingSkipsDetachRetriesWhenMountStuck even asserts this build succeeds, contradicting the PR text. Consider returning an error from detachLoop/cleanup and propagating it from the step-5 call.
| if err := runCommand(cleanupCtx, "", "umount", "--", mountPoint); err != nil { | ||
| log.G(ctx).Warnf("umount %s failed, retrying lazily: %v", mountPoint, err) | ||
| if lazyErr := runCommand(cleanupCtx, "", "umount", "-l", "--", mountPoint); lazyErr != nil { | ||
| mountStuck = true |
There was a problem hiding this comment.
Same gap on the stuck-mount branch: mountStuck = true only shortens the detach retries; the build still returns nil. The PR body says the code "refuses an image whose resize2fs -M and pmem alignment would have run against a still-mounted filesystem". On a real host, resize2fs -M and the pmem-align truncate both fail against the still-mounted file, so the image is served unshrunk and unaligned and the microVM rejects it with PmemSizeNotAligned. This case needs to fail the build (or at least not serve the image), not log and continue — and TestStreamingSkipsDetachRetriesWhenMountStuck currently asserts the build succeeds here.
| if attempt > 0 { | ||
| time.Sleep(200 * time.Millisecond) | ||
| } | ||
| if err = runCommand(cleanupCtx, "", "losetup", "--detach", "--", loopDevice); err == nil { |
There was a problem hiding this comment.
The PR description says: "before retrying, the code asks losetup what the device is backed by, and stops when it is no longer this build's image." That check is not here — the loop retries losetup --detach unconditionally on any error. Permanent errors (device already gone/ENXIO, EPERM, or a concurrent reclaim having freed the device) burn the full ~1s budget and end in a "device stays attached" warning that is misleading for an already-released device. Consider checking losetup --list for the device before spending the budget, or at least distinguishing EBUSY (retryable) from non-retryable errors.
|
cc @fslongjin I wonder if you guys have encountered this issue when using K8s to deploy Cubemaster within your system. It seems that the loop devices are running out of resources. We encountered this issue during the deployment of version 0.5.1. |
Problem
createExt4ImageStreaming(the loop-mount fast path from #472, hardened in #958) allocates a loop device, mounts it, and relies on two deferred cleanups to release it. Both cleanups discard their errors:They are also registered as two separate
defers — the umount first, the detach second — so LIFO ordering runs the detach before the umount. A detach attempted while the filesystem is still mounted fails withEBUSY, and nothing is logged. The consequences are permanent:.ext4is unlinked afterwards (the build's own failure cleanup does exactly that), the loop device keeps the inode alive and its disk space is never reclaimed — the same class of permanent disk leak as [Bug Report] Template delete leaks 1.1GB rootfs ext4 in cubebox_os_image (permanent disk leak, root partition exhaustion risk) #822.losetup -ashows these as... .ext4 (deleted).losetup --findfails and every subsequent build falls back to phase-1 behind a single WARN atext4.go:141. The fast path never returns without operator intervention, and the only symptom is that template creation has become drastically slower.Containerized deployments reach (3) far earlier than the kernel's loop limit would suggest. A container's
/devis a tmpfs whose node set is fixed when the container starts — typicallyloop-controlplusloop0..loop7— whileLOOP_CTL_GET_FREEhands out node-global indexes. As soon as it returns 8 or above, that node does not exist inside the container:Observed in practice: 8 devices attached, none of them mounted, three with
(deleted)backing files, and every template build after that point taking the slow path.Change
EBUSY. This is the primary fix.umountfailure is logged and retried lazily (umount -l), so a busy mount point no longer cascades into a permanently pinned device.losetup --detachis retried briefly (5 attempts, 200 ms apart) before giving up, so a lazy umount that has not finished releasing the mount does not abandon the device, and the final failure is logged instead of discarded.(deleted)reclaim candidate. Returning the error makesBuildExt4unlink the image (ext4.go:141-143) — turning the pinned device into a candidate for the next allocation failure — and rebuild via phase-1, so no image is lost. It also refuses an image whoseresize2fs -Mand pmem alignment would have run against a still-mounted filesystem (the stuck-mount case), which would otherwise serve an unshrunk, unaligned image that the microVM rejects withPmemSizeNotAligned.losetupwhat the device is backed by, and stops when it is no longer this build's image. Attaches with autoclear semantics are released by the umount itself, and without this check every such host would burn the whole backoff budget and then fail every build into phase-1. The check querieslosetuprather than matching its error text, which is localized.reclaimOrphanLoopDevicesruns whenlosetup --findfails and retries once, so exhaustion self-heals instead of permanently degrading every later build.Reclaim is narrow by construction: a device is only detached when its backing file lives under the artifact store and has already been unlinked (
losetup --listreports it as(deleted)). A build unlinks its image only after it has given up on it, so a build in flight elsewhere can never become a candidate — not even in the attach→mount window, where the device is not yet in the mount table and the kernel'sEBUSYrefusal would not protect it either. Devices that still cannot be detached are logged at debug level and skipped.When the retry after a reclaim also fails, both errors are surfaced (
%won the original) so the reason allocation failed in the first place is not lost.The happy path is unchanged — no additional command runs unless a cleanup fails or allocation is already exhausted.
Note for containerized deployments
Even with zero leaks, a container that only has
loop0..loop7can exhaust them with eight concurrent builds. Making more nodes available is a deployment concern (pre-create them, or share the host/dev) and is out of scope here; this change makes the failure recoverable and diagnosable rather than silent and permanent.Tests
New
disk_test.go:createExt4ImageStreamingwith fakelosetup/mount/umountonPATHthat record their argv: the umount always precedes the detach (reverting the ordering fails this test), a busy detach is retried until it succeeds, the plain-umount-fails/lazy-umount-succeeds case releases the device on the retry, a mount point that survives even the lazy umount stops after one detach attempt instead of spending the whole budget, a failedmountstill detaches the allocated device (which now happens only through the deferred cleanup), and an exhaustedlosetup --findis retried after the orphan reclaim frees a device — while an allocation failure with no orphan to reclaim returns the original error with no retry, and one whose retry fails too reports both failures.losetup --listparsing and store-prefix matching: the(deleted)suffix is required (a live backing file under the store is left alone), devices outside the store, sibling directories sharing a prefix (storage-backupvsstorage), malformed and blank lines, a backing path containing spaces, and a/store dir (refused rather than detaching everything).gofmt,go build ./pkg/...,go vet ./pkg/templatecenter/image/and the package tests (-short, as CI runs them) all pass.