mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 11:44:16 +00:00
buildOCILayout already removes its scan dir on every error path, and syft.go
defers the stereoscope generator's Cleanup. What neither can do is clean up
after a process that dies mid-scan: the deferred call never runs, and nothing
afterwards ever looks at what was left. Every restart therefore leaks the
in-flight layout and extraction permanently, and a restart is routine — a
deploy is one.
On seamark-hold that reached 8.8 GB of orphaned scan-*, syft-scan-* and
syft-cataloger-* directories under a 20 GB disk, at which point the disk was
97% full and scans began failing on it:
failed to load OCI image: unable to populate layer cache
dir="/var/lib/seamark/scanner/tmp/syft-scan-1546187834/..."
: no space left on device
failed to download layer 5: failed to write blob:
write /var/lib/seamark/scanner/tmp/scan-4160414849/blobs/sha256/...
: no space left on device
The leaked directories cluster at the scanner's restart timestamps, which is
what identifies the killed process rather than the error paths as the source.
Manual removal reclaimed 8.8 GB and took the disk from 97% to 50%.
Startup is where this belongs: it is the one moment the previous process is
known to be gone, and it is immediately after the event that caused the leak.
The sweep runs in WorkerPool.Start after TMPDIR is set and before any worker
can dequeue, so nothing it removes can be work in progress here.
Three constraints shape what it will touch:
- Only the three per-job prefixes, only as direct children, only
directories. The Grype database lives beside the tmp dir at
<parent>/vulndb and go-getter unpacks into grype-dl underneath it; both
are state the scanner needs and neither matches a prefix. The prefixes now
have one definition each, used by both the creator and the sweeper, so
renaming a directory cannot silently take it out of the sweep's scope.
- An age threshold, vuln.sweep_max_age, default 1h. A second scanner sharing
the directory has an in-flight scan-* dir that is minutes old, and
scanner.job_timeout is 8m, so an hour clears both with room to spare. 0
disables the sweep rather than removing a peer's live work.
- Nothing is fatal. A stat or removal failure is a WARN and the sweep moves
on, so a permission problem in the tmp dir cannot keep the scanner from
starting.
The sweep only runs at startup, so a scanner that is killed twice between
deploys carries the first leak until its next restart. That is the tradeoff
for never racing a live peer; a periodic sweep would be the follow-up if
processes ever live long enough for it to matter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA9D4DjaLZTvzQ7dJbu4eg
131 lines
4.3 KiB
Go
131 lines
4.3 KiB
Go
package scan
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The three directory shapes a scan leaves directly under the configured tmp
|
|
// dir. Everything created here is per-job and disposable:
|
|
//
|
|
// scan-* buildOCILayout's downloaded OCI layout
|
|
// syft-scan-* stereoscope's extraction root (syftTempDirName)
|
|
// syft-cataloger-* Syft's own cataloging scratch, created inside itself
|
|
//
|
|
// Only these are ever swept. The Grype database lives beside the tmp dir at
|
|
// <parent>/vulndb and go-getter unpacks its downloads into a grype-dl
|
|
// directory underneath it; both are state the scanner needs and neither
|
|
// matches a prefix here.
|
|
const (
|
|
scanDirPrefix = "scan-"
|
|
// syftTempDirName is the root prefix handed to stereoscope's
|
|
// TempDirGenerator in syft.go, which appends "-" and a random suffix.
|
|
syftTempDirName = "syft-scan"
|
|
// syftCatalogerPrefix is Syft's, not ours: it opens its own generator
|
|
// during cataloging. Named here because the sweep is the only thing that
|
|
// reclaims one after a crash.
|
|
syftCatalogerPrefix = "syft-cataloger-"
|
|
)
|
|
|
|
// sweepPrefixes is the full set, defined once so the creators above and the
|
|
// sweeper below cannot drift apart.
|
|
var sweepPrefixes = []string{scanDirPrefix, syftTempDirName + "-", syftCatalogerPrefix}
|
|
|
|
// sweepTmpDir removes scan leftovers older than maxAge from tmpDir.
|
|
//
|
|
// The per-job error paths already clean up after themselves; what they cannot
|
|
// do is clean up after a process that dies mid-scan. A restart — a deploy
|
|
// included — permanently orphans whatever was in flight, and on seamark-hold
|
|
// that reached 8.8 GB on a 20 GB disk before scans started failing with "no
|
|
// space left on device". Nothing else ever reclaims these, so startup does.
|
|
//
|
|
// Only direct children matching sweepPrefixes are considered, and only
|
|
// directories: the tmp dir is shared with state the scanner needs. maxAge is
|
|
// what makes this safe to run while another scanner shares the directory —
|
|
// its in-flight scan-* dir is minutes old, not hours. A maxAge of zero or less
|
|
// disables the sweep entirely rather than removing a peer's live work.
|
|
//
|
|
// Nothing here is fatal. Every failure is logged and the sweep moves on, so a
|
|
// permission problem in the tmp dir cannot stop the scanner from starting.
|
|
func sweepTmpDir(tmpDir string, maxAge time.Duration) {
|
|
if tmpDir == "" || maxAge <= 0 {
|
|
return
|
|
}
|
|
|
|
entries, err := os.ReadDir(tmpDir)
|
|
if err != nil {
|
|
// A tmp dir that does not exist yet is the normal first-boot state,
|
|
// not something to warn about.
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
slog.Warn("Failed to sweep scanner tmp dir", "dir", tmpDir, "error", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
cutoff := time.Now().Add(-maxAge)
|
|
var removed int
|
|
var reclaimed int64
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || !sweepable(entry.Name()) {
|
|
continue
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
slog.Warn("Failed to stat stale scan directory", "name", entry.Name(), "error", err)
|
|
continue
|
|
}
|
|
if info.ModTime().After(cutoff) {
|
|
continue
|
|
}
|
|
|
|
// Measured before the removal, because afterwards there is nothing
|
|
// left to measure. Best effort: a directory that cannot be walked is
|
|
// still worth removing, it just goes unaccounted for.
|
|
path := filepath.Join(tmpDir, entry.Name())
|
|
size := dirSize(path)
|
|
if err := os.RemoveAll(path); err != nil {
|
|
slog.Warn("Failed to remove stale scan directory", "dir", path, "error", err)
|
|
continue
|
|
}
|
|
removed++
|
|
reclaimed += size
|
|
}
|
|
|
|
slog.Info("Swept scanner tmp dir",
|
|
"dir", tmpDir,
|
|
"removed", removed,
|
|
"bytes", reclaimed,
|
|
"older_than", maxAge)
|
|
}
|
|
|
|
// sweepable reports whether a directory name is one of ours.
|
|
func sweepable(name string) bool {
|
|
for _, prefix := range sweepPrefixes {
|
|
if strings.HasPrefix(name, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// dirSize sums the file sizes under path, ignoring anything it cannot read.
|
|
// It exists only to put a number in the sweep's log line.
|
|
func dirSize(path string) int64 {
|
|
var total int64
|
|
_ = filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil //nolint:nilerr // an unreadable subtree is skipped, not fatal
|
|
}
|
|
if info, err := d.Info(); err == nil {
|
|
total += info.Size()
|
|
}
|
|
return nil
|
|
})
|
|
return total
|
|
}
|