Skip to content

tarfs: compact Entry, reconstructing tar.Header on demand - #2433

Draft
markusthoemmes wants to merge 5 commits into
chainguard-dev:mainfrom
markusthoemmes:tarfs-compact
Draft

markusthoemmes wants to merge 5 commits into
chainguard-dev:mainfrom
markusthoemmes:tarfs-compact

Conversation

@markusthoemmes

@markusthoemmes markusthoemmes commented Aug 25, 2026 •

Copy link
Copy Markdown
Member

A tarfs index retains ~1KB per file: the stored tar.Header costs three time.Times and several always-zero fields, every entry carries a PAX map allocated to hold a single well-known checksum record, and hdr.FileInfo() boxes another allocation. Since the package cache keeps a tarfs index alive per cached package, this multiplies across every file of every package in long-running processes.

Store a compact form instead: scalar fields at their original widths, the fs.FileMode computed once from hdr.FileInfo() so the FileInfo view cannot drift from archive/tar, the APK-TOOLS.checksum.SHA1 record hex-decoded into a fixed [20]byte when it is the lowercase form apk-tools writes (any other spelling stays a verbatim PAX record so it round-trips), and remaining PAX records (xattrs, mostly) spilled to a nil-by-default map. *Entry implements fs.FileInfo and fs.DirEntry itself, and Sys() returns a *tar.Header as archive/tar does. Entry.Header becomes a method that reconstructs the header on demand, which is a breaking change for consumers of the Entry.Header field. The only in-tree consumer is contents.Entries(), which already copies into []tar.Header. AccessTime and ChangeTime are not retained since apk-tools never writes them.

The package had no tests. Fixtures cover directories, setuid and xattr records, symlinks, hardlinks, PAX long names, ids above 2^31, device nodes, fifos, non-hex checksum spellings and format variants, and a seeded property test generates headers across the field space and compares every Header() and FileInfo method against what archive/tar reads back from the same bytes.

On a wolfi go-1.25 package (6354 files), measured with runtime.MemStats around tarfs.New and including the index and directory maps:

main this branch
HeapAlloc per entry 1155 B 489 B
HeapInuse per entry 1599 B 728 B

Over half of the saving comes from replacing the per-entry PAX map with the decoded checksum field. Before the rebase onto the PackageContents refactor, an image built from this branch was byte-identical to one built from main in every blob (layers, manifests, configs), differing only in the vcs-revision annotations.

Supersedes #2493, which has the same diagnosis and fix shape. Fixes #2492.

coreydaley-cg added a commit that referenced this pull request Sep 10, 2026
apk-tools records APK-TOOLS.checksum.SHA1 on nearly every regular file
as 40 lowercase hex characters. Holding that as a PAX record costs the
string, its header, and a slice element per file. Decode it into a fixed
[20]byte on the entry and re-encode on materialization.

Only the exact lowercase hex spelling is decoded, checked by re-encoding,
so any other form round-trips verbatim. Measured on real apks: 446 -> 385
bytes per member on a 133-file package.

The decoded-checksum idea is from #2433.

Co-authored-by: Claude <noreply@anthropic.com>
A tarfs index retains ~1KB per file: the stored tar.Header costs three time.Times and several always-zero fields, every entry carries a PAX map allocated to hold a single well-known checksum record, and hdr.FileInfo() boxes another allocation. Since the package cache keeps a tarfs index alive per cached package, this multiplies across every file of every package in long-running processes.

Store a compact form instead: packed scalar fields, the APK-TOOLS.checksum.SHA1 record hex-decoded into a fixed [20]byte with remaining PAX records (xattrs, mostly) spilled to a nil-by-default map, and *Entry implementing fs.FileInfo itself. Entry.Header becomes a method that reconstructs the header on demand, which is a breaking change for consumers of the Entry.Header field.

On a wolfi go-1.25 package (6339 files) this shrinks the index from 987 to 324 bytes per entry (6.0 to 2.0 MB), over half of which comes from replacing the per-entry PAX map with the decoded checksum field. An image built from this branch is byte-identical to one built from main in every blob (layers, manifests, configs), differing only in the vcs-revision annotations.
Narrowing uid, gid and device numbers to int32 silently corrupts ids above 2^31, which tar can carry. Re-deriving the fs.FileMode by hand duplicates headerFileInfo.Mode and can drift from it, so compute it once from hdr.FileInfo() at index time and store it. Name() and Sys() now match archive/tar's FileInfo too: directories are cleaned before taking the base, and Sys returns a *tar.Header. Header() documents that access and change times are not retained.
hex.Decode accepts uppercase, but hex.EncodeToString always emits lowercase, so an uppercase APK-TOOLS.checksum.SHA1 record did not round-trip through Header(). Keep anything but the exact lowercase form apk-tools writes as a verbatim PAX record.
The package had no tests. Fixtures cover directories, setuid and xattr records, symlinks, hardlinks, long names, wide ids, device nodes, fifos, non-hex checksum spellings and format variants, and a seeded property test generates headers across the field space and compares every Header() and FileInfo method with what archive/tar reads back from the same bytes. Open, Stat, Readlink and WalkDir are exercised as well.
Ordering fields by size removes 24 bytes of padding, and path.Dir(name) is a prefix slice of name that does not allocate, so storing it separately cost 16 bytes per entry for nothing. Entry goes from 208 to 176 bytes.
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.

tarfs: Entry retains a full tar.Header per file; store a compact record

1 participant