Guides

Caching & performance

On this page

Two knobs keep Draugr fast at scale: a content-hash cache that skips re-scanning unchanged targets, and a parallelism setting that matches the run to the machine.

Content-hash caching

Each scan job has a cache key derived from its inputs (scanner, version, target identity, config). Enable a cache and an unchanged target is never re-scanned:

draugr scan draugr.saga.yaml --cache-dir .draugr/cache
draugr scan draugr.saga.yaml --cache-dir .draugr/cache --cache-ttl 12h
FlagDefaultDescription
--cache-dir,Enable content-hash caching in this directory
--cache-ttl24hCache entry lifetime (0 = no expiry)

Every scanner folds its own version into the key, and where the answer depends on data the tool downloads rather than on the tool itself, that data's version too:

ScannerWhat the key follows
trivy, trivy-fs, trivy-config, trivy-licenseTrivy's version and its vulnerability-database timestamp
nucleithe template set version. Republished daily, so this is the one that moves
semgrep, gitleaks, gosec, kube-benchthe tool's version
kube-bench-jobthe pinned image digest

So the thing that would change the answer changes the key, and a cached result is invalidated by the update that made it wrong rather than by waiting out the clock. The cache architecture explains how the key is built and why identity has to mean content. A new Nuclei template set, a Trivy database refresh or a Semgrep upgrade re-scans what it affects.

Every key also carries the Draugr version (the commit for a development build), so the first run after an upgrade re-scans everything.

Entries still expire on a TTL, as a backstop for what a version cannot express, most notably a Semgrep ruleset fetched from the registry, whose contents can change without the tool's version moving. For container images, pin a digest: in the Saga so caching is content-addressed, a rebuilt image re-scans immediately instead of serving the old result under a mutable tag. See caching in depth.

Entries are compressed. A cached entry is a whole SARIF report, which is repetitive by construction, a measured one went from 375 KB to 60 KB. That matters most where the cache is persisted between CI runs, because an uncompressed cache spends on restore what it saves on scanning.

Images without a pinned digest. If a component's images: entry gives only a mutable tag (e.g. python:3.8-slim) with no digest:, the cache keys on that tag. An image rebuilt and re-pushed under the same tag produces the same key, so the cached result is reused until it goes stale. Either the --cache-ttl expires (default 24h) or you force a re-scan (delete the cache entry, or run once without --cache-dir). Draugr does not reach out to the registry to resolve a tag to its current digest. To make a rebuild invalidate the cache immediately, pin the immutable digest: in the Saga, a discovery surveyor records the running digest for you, so the key becomes content-addressed.

Configure it once, per machine

The --cache-* flags all have a home in draugr.config.yaml, which is usually the better place for them: a cache directory describes a runner image, not an application, and every pipeline on that runner wants the same one.

# draugr.config.yaml
cache:
  dir: /var/cache/draugr
  ttl: 24h
  requireDigest: true     # do not cache an image named only by a tag

A flag you type always wins. readOnly and requireDigest only turn on across files, so a project file's false does not undo a machine that declared its results untrustworthy, and draugr config show lists that false as overridden. Typing --cache-read-only=false still overrides it, because you typed it. ttl: 0s in the file means no expiry, as --cache-ttl 0 does.

Persisting the cache between CI runs

A runner is fresh every time, so a pipeline pays full price for scanning artifacts that have not changed since the last run. Two different things are worth persisting, and they are not equally risky.

The snippets below are GitHub Actions. On GitLab and Azure they are a cache: key and a Cache@2 task over the same directories; the reasoning does not change, only the syntax does. None of the three Draugr templates wires a cache itself, because where one is kept is a property of the runner rather than of the scan.

Cache the scanner's data first

This is the bigger win and it carries no trust question at all. Trivy's databases are up to 2.6 GB, 1.2 GB of vulnerability data, plus 1.4 GB of Java index if you scan Java artifacts, which Trivy fetches only when something needs it. A cold runner downloads all of what it needs before scanning anything, and for many projects that is larger than the scanning.

- uses: actions/cache@v4
  with:
    path: ~/.cache/trivy
    key: trivy-db-${{ github.run_id }}
    restore-keys: trivy-db-
- uses: draugr-dev/draugr@v0
  with: { saga: draugr.saga.yaml, tools: true }

A stale copy here cannot fabricate a result. The worst it does is make a scan fail, or report data Draugr already folds into the cache key, so nothing downstream is deceived.

Nuclei's template set (~/.local/nuclei-templates by default) is worth the same treatment if you run dast.

Or run one Trivy server for the whole fleet

If you have enough runners that persisting a database on each is its own problem, Trivy's client/server mode holds the databases once and serves them. Draugr needs no configuration for this. It passes its environment to the tools it runs, so the variable Trivy already understands is enough:

env:
  TRIVY_SERVER: http://trivy.internal:8080
  # TRIVY_TOKEN: ...   if the server was started with --token

Measured against a server with an empty local cache: the sca control returned its usual findings and downloaded nothing, no 1.2 GB, no cache directory created at all.

It does not cover everything, and the gap is easy to miss. Trivy's client/server mode supports image, filesystem and repository scanning, and deliberately does not support trivy config, so the iac control still runs locally and still wants its policy bundle. Misconfiguration and secret scanning stay client-side by design, so that files which may hold sensitive data are never sent to the server. And Draugr runs more than Trivy: Gitleaks, Semgrep and Nuclei keep their own data regardless.

So a server is worth it when a platform team already runs services and the fleet is large enough to care. One or a few runners are better served by the directory cache above, which needs nobody to operate it.

Then, if it earns its place, the result cache

--cache-dir stores scan results, so a persisted copy skips whole jobs rather than re-fetching data. It is the smaller saving and it introduces a real question, because an entry is a pass:

- uses: actions/cache@v4
  with:
    path: .draugr/cache
    key: draugr-${{ github.sha }}
    restore-keys: draugr-
- uses: draugr-dev/draugr@v0
  with: { saga: draugr.saga.yaml, cache-dir: .draugr/cache }

Anyone who can write the cache can make a scan report a pass it never earned. That is an attack on the gate itself, and it is worth being plain about what does and does not protect you:

  • actions/cache scopes writes by branch, so a pull request from a fork cannot write the default branch's cache. The first-party action also passes --cache-read-only automatically on a fork pull request, so the guarantee does not depend on which transport you chose.
  • A shared volume, a bucket or a self-hosted runner has no such scoping. If untrusted code can write there, pass --cache-read-only yourself on those jobs.
  • Nothing here is signed. Draugr does not verify who wrote an entry. If that matters for your threat model, do not share a cache across trust boundaries.

On GitLab and Azure

The same two directories, cached the way each system caches anything.

GitLab keys a cache: on the job. A cache is per branch by default, and pull: true on a merge-request job with push: false is the equivalent of --cache-read-only: a branch anybody can open cannot then decide what the default branch reads.

draugr:
  variables:
    DRAUGR_CACHE_DIR: .draugr/cache
  cache:
    - key: trivy-db
      paths: [.cache/trivy]
    - key: "draugr-$CI_COMMIT_REF_SLUG"
      paths: [.draugr/cache]
      policy: pull-push        # pull on a merge request, pull-push on the default branch
  variables:
    TRIVY_CACHE_DIR: .cache/trivy

GitLab caches paths inside the project directory only, which is why TRIVY_CACHE_DIR moves the database there rather than leaving it in $HOME.

Azure uses the Cache@2 task, which has to run before the template:

- task: Cache@2
  inputs:
    key: 'trivy-db | "$(Agent.OS)"'
    path: $(Pipeline.Workspace)/.cache/trivy
  displayName: Trivy database

- task: Cache@2
  inputs:
    key: 'draugr | "$(Agent.OS)" | draugr.saga.yaml'
    path: $(Pipeline.Workspace)/.draugr/cache
  displayName: Draugr results

- template: azure-pipelines/draugr.yml@draugr

Cache@2 restores on a key match and saves at the end of a successful job, so a failing job leaves the previous entry in place. It has no branch scoping of its own: a repository where anybody can run a pull-request pipeline shares one cache, so pass --cache-read-only on that path if untrusted code can reach it.

What a hit does and does not promise

The key covers the scanner, its data version, the target's identity, the effective config and the Draugr version, so a Trivy database refresh or a Nuclei template update invalidates what it affects. Two things it cannot cover:

  • A container image named only by a tag. Rebuild and re-push acme/api:latest and the key is unchanged, so the previous image's findings are served. Pin a digest:, a discovery surveyor records the running one for you, or pass --cache-require-digest to refuse to cache tag-only images at all.

    The report says when this happened, so it is a caveat you are given rather than one you have to remember. The line names the image it reused, says a tag can be rebuilt so the findings may describe an earlier one, and gives the two ways out: pin a digest, or re-scan with --cache-require-digest.

    It appears only when a result was actually reused: a fresh scan of a tag scanned whatever that tag points at now, which is the right answer whether or not it moved. --format json carries the same list as stats.cache.unpinned.

    Draugr does not resolve the digest for you. That is a registry request and a credential the run may not have, and a scan that fails because it could not reach a registry it never needed to reach would be a worse trade than the caveat.

  • A Semgrep ruleset fetched from the registry. p/default is a moving target with no version Draugr can read, and resolving it means fetching it, which costs most of what caching saves. This is the case --cache-ttl exists for; leave it at a day or less if you rely on registry rulesets.

Tuning parallelism

By default Draugr runs up to one scan job per CPU. But scanners like Trivy and Semgrep are themselves multi-threaded, so on a busy or small machine that default can oversubscribe the box and slow the run down, dial it down with -j. On a big CI runner you can dial it up.

draugr scan draugr.saga.yaml -j 4      # cap parallelism
draugr scan draugr.saga.yaml -j 1      # serial: deterministic output, handy for debugging
FlagDefaultDescription
-j, --jobs0 (auto)Max scan jobs to run in parallel (0 = one per CPU)

scan.jobs in draugr.config.yaml sets the same cap for every scan on a machine, and a typed -j wins over it.

The run's JSON stats reports the effective concurrency alongside jobs (total jobs), scans, cacheHits, and deduped, so you can see the effect and tune from evidence. See the CLI reference for the full flag list.

Finding out where a slow run went

A job count says how much was planned, not what it cost. stats also carries the timings, in milliseconds:

draugr scan draugr.saga.yaml -o out
jq '.stats' out/report.json
{
  "jobs": 6,
  "scans": 6,
  "cacheHits": 0,
  "cache": { "enabled": false, "hits": 0 },
  "deduped": 0,
  "concurrency": 32,
  "durationMs": 4400,
  "byControlMs": {
    "iac": 1816,
    "licenses": 1797,
    "sast": 6876,
    "sca": 1736,
    "secrets": 1257
  }
}

Read it in this order:

  1. durationMs is what you waited; byControlMs is not. The per-control figures are job time summed across each control's jobs, so with any parallelism they add up to more than the wall-clock. 13.5 seconds of work in 4.4 seconds elapsed, above. That is the concurrency working, not a bug. Compare controls against each other, never against durationMs.
  2. The largest entry is the one worth attention, whatever the job count says: one slow job costs more than four fast ones. Here it is sast, at more than the other four together, so tuning anything else would buy nothing.
  3. toolWaitsMs separates a slow tool from a contended one. Several controls can share a single tool and its cache. iac, licenses and sca are all Trivy above, and only one may use it at a time. Time recorded there was spent queueing rather than scanning, so raising -j will not recover it. The key is absent in this run because Draugr warms shared scanner state once before the fan-out; if you see it, the warm-up did not cover that tool, and the fix is a warm cache rather than more parallelism.
  4. orderWaitsMs is time a job waited before starting, for another scanner's job on its repository or for its turn at a Go package load. On a Go repository, trivy-license starts after govulncheck and gosec finish, because it reads licenses from the Go module cache they fill. A licenses control that runs longer than its own scan is usually this wait, and raising -j does not recover it. The key also counts a govulncheck or gosec job waiting for another job's Go package load, because only one load runs at a time.
  5. cache.enabled is what tells you which kind of zero you are looking at. A run given no --cache-dir and a run whose every entry had expired both report cacheHits: 0, and they are not the same run. With a cache in use and no hits, the cache was there and nothing matched: entries are keyed on content, and for a repository that includes the revision, so a run on a new commit is expected to miss for whatever that commit changed.
  6. cache.oldestHit is how stale the oldest reused answer is. Twelve results reused is a different statement an hour old and a day old. An instant rather than an age, because a report is read later than it is written; the console prints the age, because it is read now.
  7. dataRefresh is the time each scanner spent refreshing its data, such as a vulnerability database or a checks bundle, before the jobs started, in milliseconds. It is included in durationMs. A cold run of draugr-demo spent 10,450 ms of 22,436 on Trivy's vulnerability database, and the next run spent 32 ms on it. A refresh that is long on every run means the tool's cache is not kept between runs; see cache the scanner's data first.

The timings are absent rather than zero when a run recorded none, so a consumer charting them can tell "not measured" from "took no time".

One repository, several components

A component scoped with paths: is keyed on the content of what its checkout holds rather than on the repository's commit: the directories and files its paths: names, and the scanners' configuration at the root. In a monorepo, a commit touching one component leaves every other component's entry valid, and an edit to .trivyignore invalidates every component's.

Measured on a two-component tree, twelve jobs, scanning sca:

hitsscans
a warm run with nothing changed120
a commit touching one component93

The three that re-scanned are the ones belonging to the component that changed. Keyed on the repository's commit, all twelve would.

This holds for a local checkout, which is what CI has. A repository Draugr clones from a URL is resolved with ls-remote, which answers about refs and knows nothing about trees, so those keep the commit. So does any job that reads the repository's history rather than its tree, secret scanning over history being the one that does: two commits can carry an identical tree and different history, and a key over the tree would serve one run's answer to the other.

Scanners that call a rate-limited API

You do not need to slow the whole run down for one of them. A scanner that talks to a hosted API declares how often it may be called, and Draugr spaces its calls out, while everything else keeps running at full parallelism.

virustotal is the example: its public tier allows four requests a minute, so Draugr leaves fifteen seconds between its lookups. A scan of three hosts takes about thirty seconds, no request is ever rejected, and no other control waits for it.

The waiting happens before a worker is taken, which is what makes that true. Were the scanner to hold a slot while waiting, a handful of its jobs would occupy the pool and every unrelated control would queue behind a scanner it has nothing to do with. Lowering -j would be the wrong fix for the same reason: it trades a global slowdown for one scanner's local limit.

If your key allows more, say so, per scanner, not globally:

config:
  controls:
    threats:
      virustotal:
        requestsPerMinute: 1000    # a paid key; the default assumes the free tier's 4

Raise it only to what your key actually permits. Exceeding a vendor's published limit risks losing access, and some state a permanent ban as the penalty for terms violations.