perf: build lower layer squashes only when a scan reads them

The image source provider now reads images with `image.WithLazyLayerSquashes`: the image squash is built during the read, and each lower layer's squash only when something reads it. A squashed scan never does, so it skips a full file tree copy per layer.

- squashed scans: faster, much less memory on images with many layers
- `scan`/`attest` with all-layers or deep-squashed stay eager (an internal ctx flag), since eager squashing overlaps with fetching and indexing and is faster when every layer gets read
- library callers going through `syft.GetSource` get lazy for every scope, since the scope isn't known when the image is read. All-layers is still correct, just slower
- no public API change

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-10-29 22:40:51 -04:00
parent 8698886bf7
commit 1f667ad93a
4 changed files with 103 additions and 0 deletions
+5
View File
@@ -260,6 +260,11 @@ func getSource(ctx context.Context, opts *options.Catalog, userInput string, sou
cfg = cfg.WithDigestAlgorithms(hashers...)
}
if source.ParseScope(opts.Scope) != source.SquashedScope {
// every other scope reads each layer's squash, which is cheaper to build while the image is read
ctx = internal.WithEagerLayerSquashes(ctx)
}
src, err := syft.GetSource(ctx, userInput, cfg)
if err != nil {
return nil, fmt.Errorf("could not determine source: %w", err)
+17
View File
@@ -0,0 +1,17 @@
package internal
import "context"
type eagerLayerSquashesKey struct{}
// WithEagerLayerSquashes has the image source provider build every layer's squashed tree while it reads the
// image, instead of the first time each one is used. Set it when the scan will read lower layers (any scope but
// squashed): eager squashing overlaps with fetching and indexing the layers, so those scans finish sooner.
func WithEagerLayerSquashes(ctx context.Context) context.Context {
return context.WithValue(ctx, eagerLayerSquashesKey{}, true)
}
func EagerLayerSquashes(ctx context.Context) bool {
v, _ := ctx.Value(eagerLayerSquashesKey{}).(bool)
return v
}
@@ -6,6 +6,7 @@ import (
"github.com/anchore/go-collections"
"github.com/anchore/stereoscope"
"github.com/anchore/stereoscope/pkg/image"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/syft/source"
)
@@ -29,6 +30,10 @@ func (l stereoscopeImageSourceProvider) Name() string {
}
func (l stereoscopeImageSourceProvider) Provide(ctx context.Context) (source.Source, error) {
if !internal.EagerLayerSquashes(ctx) {
// lower layer squashes are only built if something reads them, so a squashed scan never pays for them
ctx = image.WithLazyLayerSquashes(ctx)
}
img, err := l.stereoscopeProvider.Provide(ctx)
if err != nil {
return nil, err
@@ -0,0 +1,76 @@
package stereoscopesource
import (
"context"
"path/filepath"
"testing"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/random"
"github.com/google/go-containerregistry/pkg/v1/tarball"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/stereoscope"
"github.com/anchore/stereoscope/pkg/filetree"
"github.com/anchore/stereoscope/pkg/image"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/syft/source"
)
func Test_stereoscopeImageSourceProvider_layerSquashes(t *testing.T) {
img, err := random.Image(1024, 3)
require.NoError(t, err)
tag, err := name.NewTag("localhost/layer-squashes:latest")
require.NoError(t, err)
archive := filepath.Join(t.TempDir(), "image.tar")
require.NoError(t, tarball.WriteToFile(archive, tag, img))
provide := func(t *testing.T, ctx context.Context) *stereoscopeImageSource {
providers := Providers(ProviderConfig{
StereoscopeImageProviderConfig: stereoscope.ImageProviderConfig{UserInput: archive},
})
for _, p := range providers {
if p.Value.Name() != image.DockerTarballSource {
continue
}
src, err := p.Value.Provide(ctx)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, src.Close()) })
return src.(*stereoscopeImageSource)
}
t.Fatalf("no %s provider", image.DockerTarballSource)
return nil
}
lazy := provide(t, context.Background())
eager := provide(t, internal.WithEagerLayerSquashes(context.Background()))
// lower layers are built on first use unless eager was asked for, the top is the image squash either way
require.Len(t, lazy.image.Layers, 3)
for idx := range lazy.image.Layers {
_, isTree := lazy.image.Layers[idx].SquashedTree.(*filetree.FileTree)
assert.Equalf(t, idx == 2, isTree, "lazy layer %d", idx)
assert.IsTypef(t, &filetree.FileTree{}, eager.image.Layers[idx].SquashedTree, "eager layer %d", idx)
}
// every scope sees the same files either way
for _, scope := range []source.Scope{source.SquashedScope, source.AllLayersScope, source.DeepSquashedScope} {
t.Run(string(scope), func(t *testing.T) {
files := func(src *stereoscopeImageSource) []string {
res, err := src.FileResolver(scope)
require.NoError(t, err)
locs, err := res.FilesByGlob("**/*")
require.NoError(t, err)
var out []string
for _, l := range locs {
out = append(out, l.RealPath+"@"+l.FileSystemID)
}
return out
}
want := files(eager)
require.NotEmpty(t, want)
assert.ElementsMatch(t, want, files(lazy))
})
}
}