Skip to content

feat(build): single-pass layer compression behind WithCompressedLayerFile - #2479

Merged
coreydaley-cg merged 2 commits into
mainfrom
coreydaley/single-pass-layer-compression
Sep 14, 2026
Merged

coreydaley-cg merged 2 commits into
mainfrom
coreydaley/single-pass-layer-compression

Conversation

@coreydaley-cg

@coreydaley-cg coreydaley-cg commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Problem

Tar layers are written twice. ImageLayoutToLayer writes the layer as plain tar into a file named .tar.gz (newLayerWriter streams the diffID but never compresses, despite its comment), and the first Digest()/Size() call triggers layer.compress(), which re-reads the whole file and writes a second, actually-gzipped file beside it. Both files then live until the caller's cleanup, because the layer object backs publish.

Peak scratch per layer is therefore roughly uncompressed + compressed size. For services that build in tmpfs-backed temp dirs (where scratch bytes are instance memory), large builds can hold roughly 1.4x the image's installed size in RAM per build, and the biggest builds OOM the instance.

Change

A new WithCompressedLayerFile() option makes the layer writer gzip during the original write: tar bytes flow through the diffID hash into a pgzip writer whose output flows through the compressed-digest hash and a byte counter into the file. Only the compressed file ever exists (its .tar.gz name finally honest), the descriptor is complete at finalize, and Uncompressed() gunzips on demand for the few consumers that need tar bytes (e.g. CPIO conversion). Peak scratch drops to roughly the compressed size.

Details:

  • Default behavior is unchanged in output bytes and in the returned file's format; the option is opt-in, so build-minirootfs and anyone consuming the returned path as plain tar are unaffected. Two error-path behaviors do change for both modes: ImageLayoutToLayer now reports the output file's close error instead of discarding it in a defer, and a failed splitLayers deletes its partial temp files rather than leaving them behind.
  • Compressed output is byte-identical to the two-pass path (same pgzip level and 1 MiB block size), so blob digests do not change; the equivalence test pins diffID, digest, and size across both modes.
  • Consumers needing tar bytes need no changes. Uncompressed() returns a decompressing reader in this mode, so CPIO/EFI conversion and any tar-reading SBOM path keep working. They pay a gunzip they didn't pay before, which is why the option is opt-in per caller rather than a default flip. The read path uses compress/gzip rather than pgzip: decode is serial and feeds a streaming tar reader, so pgzip's read-ahead buffers and goroutine buy nothing here.
  • EROFS builds get nothing from this. --format erofs branches before the layer writer is constructed and returns an erofsLayer, whose Uncompressed() is a plain os.Open, so the option is inert there. EROFS layers are uncompressed filesystem images by design, which means their peak scratch is already roughly the installed size and this change does not reduce it. A caller budgeting scratch per build should key off ic.Format rather than assuming the compressed-tar figure applies everywhere.
  • Eager digests replace the lazy compressionCache in this mode: laziness needs a materialized plain tar to defer compression against, which is exactly the file this option eliminates. Single-pass layers are a separate v1.Layer implementation (compressedLayer) in the same shape as the existing erofsLayer, so every field is set at construction and Digest()/Size() need no lock, and single-pass layers never read or write that cache.
  • Multi-layer builds trade compression parallelism for bounded memory: splitLayers holds every layer writer open through the filesystem walk, so each gets one pgzip worker rather than the min(GOMAXPROCS, 8) fan-out a sequential compress() call gets. Single-layer builds are unaffected and use that same worker count as before, so their CPU profile is identical to the two-pass path. A build with a few large layers has less compression parallelism than before, partly offset by the compression now overlapping the I/O-bound walk. That case is not measured below.
  • Error paths tear down in both modes: writers gained an explicit Abort, and a failed splitLayers now removes its partial temp files, so retried builds in the same directory no longer accumulate leftovers. One caveat worth stating rather than burying: Abort shuts the pgzip pipeline down via gzw.Close(), and pgzip's Close returns at its own error check before closing its results channel, so a writer that already recorded an output error (ENOSPC on the scratch dir, say) can still leave its listener goroutine parked. The teardown is reliable for input-side failures and best-effort for output-side ones.

Why this isn't a two-line change

Flipping the writer to gzip is the easy part. The rest of the diff exists because compressing in the same pass changes three invariants the two-pass path relied on:

  1. The descriptor can no longer be computed lazily. compress() deferred the compressed digest until someone asked, using the plain tar as the source it could re-read. Once that file doesn't exist, the digest and size have to be captured as the bytes stream, which means hashing and counting inline and sealing the descriptor at finalize.
  2. Uncompressed() has to gunzip. Consumers that want tar bytes previously got the file as-is. They now get a decompressing reader, which has to close both the gzip reader and the file underneath it.
  3. Failure paths have to shut down a live compressor. A pgzip writer with workers is a running pipeline, not a buffer. Abandoning one mid-write leaks goroutines, so writers gained an explicit Abort and splitLayers gained ordered teardown (abort every writer, then close and remove every file).

Roughly 80 lines are that bookkeeping, and the rest is tests.

Measurements

Built with a local harness against real Wolfi images, sampling scratch usage every 50ms.

two-pass single-pass
peak scratch, 3 GiB toolchain image 3.91 GiB (3.03 tar + 0.88 gz) 0.88 GiB

77% less peak scratch, with byte-identical diffIDs and digests on every image tested.

What bypassing the cache costs

The two-pass path can skip compression when compressionCache already holds the diffID. This mode never consults that cache, so every layer is compressed exactly once, on write. That is a CPU cost, and it is measurable: five variants of one layered image built sequentially in one process, where the cache is hot, ran ~1.8-1.9s per build two-pass against ~2.2-2.3s single-pass, at 0.52 GiB against 0.18 GiB peak scratch. At high hit rates the flag trades roughly 25% wall time for roughly 65% less scratch.

How often the cache actually hits is #2485. Production ran 47-79% during a full catalog rebuild, which is the best case for a per-process diffID cache; the steady-state figure is still being collected. The option is opt-in because that trade is right for a memory-bound caller and wrong for a CPU-bound one.

What this means for callers that budget scratch

A caller deciding how many builds fit on a machine has to size scratch before the build runs, so the only number available is the image's installed size from package metadata. Against that baseline the same measurements read:

reserved actual peak scratch
two-pass 1.0x installed ~1.3x installed
single-pass 1.0x installed ~0.3x installed

Both rows matter. The first is the failure this change addresses: a caller sizing scratch at installed size is under-provisioned by roughly a third, and on a memory-backed temp dir that gap is what turns a large build into an OOM.

The second is the part that is easy to miss. After the switch the same caller over-reserves by roughly 3x, which is safe but means the freed space does not become usable capacity on its own. Turning it into density requires the caller to scale its own estimate, and the compression ratio is image-dependent, so that multiplier should come from a distribution of real builds rather than any single measurement here.

Tests

  • Legacy-vs-single-pass equivalence: identical diffID, compressed digest, and size; exactly one file on disk where the two-pass path leaves two; gzip magic; and the gunzipped stream compared byte-for-byte against the legacy plain tar.
  • The single-pass digest is also pinned to a golden constant, so a pgzip or block-size change fails the test rather than silently moving every blob digest.
  • WithCompressedLayerFile() driven end to end through ImageLayoutToLayer, asserting the option produces a gzip file and that the default does not. Mutation-checked in both directions: forcing the branch off fails it, and forcing it always-on fails it too.
  • Finalize-after-abort returns an error.
  • The multi-layer directory-creation test runs under both modes.
  • A failure-injection test drives splitLayers to a mid-walk error in both modes and asserts the temp dir is emptied and no pgzip goroutines are left running (counted from a stack dump, so unrelated goroutines cannot flake it). Mutation-checked: removing the abort teardown fails it, and so does removing the partial-file cleanup.
  • The added tests pass under -race and at -count=2, which pins their isolation against the process-global compression cache.

One limit worth naming rather than leaving to be discovered:

  • gunzipReadCloser's two-handle Close is exercised but not pinned. The round-trip proves the stream decodes, and Close is asserted not to error, but a regression that released only one of the two handles would return nil and pass. There is no cheap way to observe a leaked file handle from outside the type.

🤖 Generated with Claude Code

Comment thread pkg/build/build.go Outdated
defer outfile.Close()
lw := newLayerWriter(outfile)
var lw *layerWriter
if bc.o.CompressedLayerFile {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this all be a lot simpler if we got rid of the diffID cache, which is the reason this is two-pass in the first place? Should we instrument that cache and measure effectiveness first? If it's not really needed, maybe we get rid of it wholesale and make this the one-and-only way?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the causality — db97d1ee (#1735) added the laziness and the cache in one commit, and before it newLayerWriter was already tar → MultiWriter(diffid, gzw), which is what this reintroduces.

This PR is narrower on purpose: it's aimed purely at cutting peak memory on the build instances, where the double write is ~3 GiB of the 3.91 GiB scratch peak on a large image. It's opt-in and doesn't touch the cache, so the default path is unchanged.

Deleting the cache flips the default for every consumer, and as you say it wants the hit rate measured first. One data point for when we do: at a 100% hit rate (three identical 512 MiB layers) two-pass still lost, 1.476s vs 1.284s. The cache only skips the compression; the plain-tar write is unconditional and it's the larger cost. So it may well go your way, and if it does, this option comes out along with compress() and the cache.

Filed as #2485.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do that first, should be cheap to measure and guides us from the get-go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to measure it.

The hit rate won't settle the whole question though. build-minirootfs writes the layer to a path the user names (<output.tar.gz>), and today that file is plain tar — same for anyone using WithTarball() or reading the path from BuildLayer(). Making single-pass mandatory changes that artifact for every consumer, and apko is a public module.

So there are two gates on flipping the default and the cache measurement only clears one. This PR is opt-in and touches neither, so I'd rather land it and make the default flip its own change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in #2486

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to the 100% hit-rate number above: that benchmark was single-layer. A multi-layer A/B (five variants of one layered image, one process, hot cache) went the other way: two-pass ~1.8-1.9s vs single-pass ~2.2-2.3s per build, at 0.52 GiB vs 0.18 GiB peak scratch. So the cache does buy wall time when it hits, roughly 25%, and this option gives that up for the memory.

First production numbers from #2486 are on #2485: 47-79% hit rate during a full catalog rebuild, which is the cache's best case. Steady state pending. The PR body now states the trade with those numbers instead of the single-layer claim.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on if we could make this "the one way", though I think starting with a flag is reasonable so we can roll it out easier. I think the other callsites mentioned could be adapted so that this would be non-breaking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and that is #2485. I audited the callers for it: everything except build-minirootfs reaches the layer through the v1.Layer interface and never reads the returned path, so a default flip is invisible to them. build-minirootfs is the exception, because it writes to a path the user names and that file is plain tar today. It either keeps writing plain tar explicitly or starts writing what the .tar.gz extension says, with a release note.

The cost for the rest is that Uncompressed() gunzips in this mode, which lands on consumers that extract a whole rootfs through it (melange among them). That is CPU, not breakage.

…File

Tar layers are written twice today. ImageLayoutToLayer writes the layer as
plain tar into a file named .tar.gz, and the first Digest/Size call triggers
layer.compress(), which re-reads the whole file and writes a second, actually
gzipped file beside it. Both are retained until the caller's cleanup, because
the layer object backs publish. Peak scratch per layer is therefore roughly
uncompressed + compressed size, which on a memory-backed temp dir is instance
memory, and the largest builds OOM the instance.

WithCompressedLayerFile() makes the layer writer gzip during the original
write: tar bytes flow through the diffID hash into a pgzip writer whose output
flows through the compressed-digest hash and a byte counter into the file.
Only the compressed file ever exists, the descriptor is complete at finalize,
and Uncompressed() gunzips on demand for the consumers that need tar bytes.

Compressed output is byte-identical to the two-pass path (same pgzip level and
1 MiB block size), so blob digests do not change. The option is opt-in, so
build-minirootfs and anyone consuming the returned path as plain tar are
unaffected, and EROFS is untouched because it branches before any layer writer
is constructed.

Single-pass layers get their own v1.Layer implementation rather than a mode
flag on the existing one, in the same shape as erofsLayer: every field set at
construction, no locking, nothing lazy. splitLayers gives each concurrently
open writer one pgzip worker to bound compressor memory, and failed builds now
abort writer pipelines and remove partial layer files in both modes.

Measured on a 3 GiB toolchain image: peak scratch 3.91 GiB -> 0.88 GiB (77%),
with identical diffIDs and digests. Cold build ~5s -> ~3s. The warm path, where
the two-pass side can skip compression on a compressionCache hit, is still
faster single-pass (1.476s -> 1.284s across three identical 512 MiB layers)
because it never writes the plain tar regardless.

Co-authored-by: Claude <noreply@anthropic.com>
@coreydaley-cg
coreydaley-cg force-pushed the coreydaley/single-pass-layer-compression branch from e6a989f to 2a74c3f Compare September 10, 2026 18:09
@coreydaley-cg
coreydaley-cg enabled auto-merge (squash) September 10, 2026 18:27
Comment thread pkg/build/build_implementation.go Outdated
Comment on lines +166 to +175
diffid := sha256.New()
digest := sha256.New()
cw := &countingWriter{w: io.MultiWriter(digest, out)}

gzw := gzip.NewWriter(cw)
if err := gzw.SetConcurrency(1<<20, workers); err != nil {
return nil, fmt.Errorf("setting pgzip concurrency to %d: %w", workers, err)
}

w := tar.NewWriter(io.MultiWriter(diffid, gzw))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this use the existing pooledGzipWriter()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not as-is: pooledGzipWriter bakes in SetConcurrency(1<<20, pgzipThreads), and this path needs a variable count. Multi-layer builds hold every writer open across the walk and take one worker each, so compressor memory scales with layer count rather than layers times fan-out. A pooledGzipWriter(w, workers) could express that.

The hazard is the error path. Close sets z.closed = true and can return at its error check before reaching close(z.results). Reset only closes that channel when !z.closed, so a writer whose Close saw an output error returns to the pool with its output listener parked forever, still holding the old file. That goroutine isn't in pgzip's waitgroup, so init's wg.Wait() misses it too. Right now an ENOSPC leaks one goroutine; pooling would keep the entry.

Can add the parameter and pool it, but I'd want a fault-injection test for the poisoned writer first.

Comment thread pkg/build/build_implementation.go Outdated
Comment on lines +98 to +104
// Abort shuts down the writer pipeline without producing a layer. Safe to
// call after finalize.
func (lw *layerWriter) Abort() {
if lw.abort != nil {
lw.abort()
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can finalize handle this rather than adding another method to the interface?

Comment thread pkg/build/build_implementation.go Outdated
// newCompressedLayerWriter wraps a file with a tar writer that gzips in the
// same pass, computing the diffID, compressed digest, and size as the bytes
// stream, so the plain tar never exists on disk.
func newCompressedLayerWriter(out *os.File, workers int) (*layerWriter, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shares a lot of machinery with newLayerWriter, can it be folded together? The function could decide which struct to create (compressedLayer vs layer) or possibly pass in io.ReadClosers to use for satisfying the interface (if collapsed into single layer struct)?

Comment thread pkg/build/build.go Outdated
defer outfile.Close()
lw := newLayerWriter(outfile)
var lw *layerWriter
if bc.o.CompressedLayerFile {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on if we could make this "the one way", though I think starting with a flag is reasonable so we can roll it out easier. I think the other callsites mentioned could be adapted so that this would be non-breaking.

compressedLayer duplicated most of layer, and newCompressedLayerWriter
duplicated most of newLayerWriter, to express what is really one
difference: whether the tar is gzipped as it is written or later.

Fold both pairs together. layer carries either mode, discriminated by
singlePass(), and newLayerWriter takes the mode plus the pgzip worker
count. finalize now always runs every close, even after one fails, so a
live pgzip pipeline can no longer outlive a failed tar close; that makes
it a complete teardown and removes the need for a separate Abort, which
callers replace by discarding finalize's result.

Digest and Size skip the compression cache for single-pass layers. They
have nothing to look up, and counting the lookup would report a miss for
work that never needed doing, skewing the cache metric from #2486.

Co-authored-by: Claude <noreply@anthropic.com>
@coreydaley-cg
coreydaley-cg merged commit 1abe0b0 into main Sep 14, 2026
23 checks passed
coreydaley-cg added a commit that referenced this pull request Sep 14, 2026
Every other option in `pkg/build` takes the value it sets.
`WithCompressedLayerFile` was a no-argument toggle, which forces any
caller driving it from configuration to branch:

```go
opts := []build.Option{
	build.WithSBOMGenerators(spdx.New()),
	build.WithAuthenticator(authn),
	build.WithCache(cacheDir, false, apk.NewCache(false)),
}
if compressed {
	opts = append(opts, build.WithCompressedLayerFile())
}
```

Taking the bool lets that collapse into the literal alongside its
siblings. It also means the caller's option slice keeps `len == cap`, so
it cannot hand `LockImageConfigurationWithPackages` spare capacity to
append into, which is the aliasing that function already clones around.

## Breaking change

`WithCompressedLayerFile()` becomes `WithCompressedLayerFile(enabled
bool)`. Existing calls become `WithCompressedLayerFile(true)`.

Landing it now because the blast radius is currently zero. The option
shipped in v1.4.0 a few hours ago, and there are no callers in this repo
outside the option's own definition and test, nor in melange or
terraform-provider-apko. That window closes as soon as something adopts
it.

## Tests

The existing round-trip gains an explicitly disabled case. It previously
covered the option present versus absent, so an implementation that
ignored its argument would have passed. Mutation-checked: hardcoding
`true` fails the new leg.

The doc comment also picks up the EROFS caveat, which held in v1.4.0 but
was only written down in #2479's description.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants