Skip to content

containerd: optimizations#293

Merged
jabrown85 merged 3 commits intomainfrom
jab/optimization-attempts
Aug 12, 2025
Merged

containerd: optimizations#293
jabrown85 merged 3 commits intomainfrom
jab/optimization-attempts

Conversation

@jabrown85
Copy link
Copy Markdown
Contributor

@jabrown85 jabrown85 commented Aug 8, 2025

This pull request introduces significant performance optimizations and code improvements to the image saving and layer handling logic in local/store.go, with a focus on supporting containerd storage scenarios more efficiently. The main changes include implementing caching for containerd storage detection, optimizing layer processing by introducing parallelism, and improving how layer sizes are calculated and cached. These updates are especially beneficial for environments using containerd, leading to faster image saves and more efficient resource usage.

Performance optimizations for containerd storage:

  • Added a cached detection mechanism for containerd storage in the Store struct and used it throughout the codebase to avoid redundant checks and improve performance. (containerdStorageCache, usesContainerdStorageCached) [1] [2]
  • Optimized the process of adding image layers to tar files by pre-processing layer metadata in parallel and streaming layer data more efficiently, with special handling for containerd storage to minimize expensive uncompressed size calculations.
  • Improved the AddLayer method to proactively calculate (or defer) the uncompressed size of compressed layers, optimizing for containerd by skipping unnecessary calculations during download and only performing them when required. [1] [2]

Parallelization and concurrency improvements:

  • Introduced parallel processing using errgroup and semaphores in both layer preparation for tar creation and during layer downloads, reducing wall-clock time and making better use of system resources. [1] [2]

API and function signature updates:

  • Updated several internal function signatures (e.g., doSave, addImageToTar) to accept an isContainerdStorage flag, ensuring the optimizations are applied conditionally based on the storage backend. [1] [2] [3] [4]

These changes collectively enhance the efficiency and scalability of image saving and layer management, especially when working with containerd-based Docker setups.

Note: the streaming of the existing layer files is the biggest win here by a large margin

This is an effort to close out buildpacks/pack#2272 and make sure perf is decent on docker's containerd storage option.

Using @edmorley 's slightly modified example in the issue above. This is a subsequent pack build. I've only extracted the Export numbers here as this isn't targeting the other pack inefficiencies.

baseline non-containerd storage current lifecycle:

2025/08/08 10:44:20.176273 [exporter] Saving testcase...
2025/08/08 10:44:20.213789 [exporter] *** Images (6c4091617b09):
2025/08/08 10:44:20.213810 [exporter]       testcase
2025/08/08 10:44:20.213815 [exporter]
2025/08/08 10:44:20.213819 [exporter] *** Image ID: 6c4091617b09fd8c1dcbe214c22785e55497a1b1882c3f454634ee1b6735d99f
2025/08/08 10:44:20.213824 [exporter]
2025/08/08 10:44:20.213827 [exporter] *** Manifest Size: 1087
2025/08/08 10:44:20.213829 [exporter] Timer: Saving testcase... ran for 37.526ms and ended at 2025-08-08T15:44:20Z
2025/08/08 10:44:20.213832 [exporter] Timer: Exporter ran for 66.211458ms and ended at 2025-08-08T15:44:20Z
2025/08/08 10:44:20.213904 [exporter] Timer: Cache started at 2025-08-08T15:44:20Z

containerd storage current lifecycle:

$ pack build --builder testbuilder:24 --pull-policy if-not-present --timestamps --verbose --buildpack heroku/procfile testcase
2025/08/08 10:43:08.903421 [exporter] Saving testcase...
2025/08/08 10:43:17.900220 [exporter] *** Images (e4de4e43ad71):
2025/08/08 10:43:17.900242 [exporter]       testcase
2025/08/08 10:43:17.900246 [exporter]
2025/08/08 10:43:17.900249 [exporter] *** Image ID: e4de4e43ad7143ad0d5c3a4b6e28ff1bca059713c78879e35cee8c3236cde8e0
2025/08/08 10:43:17.900255 [exporter]
2025/08/08 10:43:17.900258 [exporter] *** Manifest Size: 1087
2025/08/08 10:43:17.900260 [exporter] Timer: Saving testcase... ran for 8.996857171s and ended at 2025-08-08T15:43:17Z
2025/08/08 10:43:17.900262 [exporter] Timer: Exporter ran for 9.027226629s and ended at 2025-08-08T15:43:17Z
2025/08/08 10:43:17.900521 [exporter] Timer: Cache started at 2025-08-08T15:43:17Z

containerd storage improved lifecycle:

$ pack build --builder testbuilder:24 --pull-policy if-not-present --timestamps --verbose --buildpack heroku/procfile --lifecycle-image lifecycle:test testcase
2025/08/08 10:40:33.692477 [exporter] Saving testcase...
2025/08/08 10:40:36.632895 [exporter] *** Images (8a71eb3516e7):
2025/08/08 10:40:36.632916 [exporter]       testcase
2025/08/08 10:40:36.632920 [exporter]
2025/08/08 10:40:36.632924 [exporter] *** Image ID: 8a71eb3516e7ee4993c5363bfd719bd57c459d7d801818f60e4a537f3eef30ce
2025/08/08 10:40:36.632927 [exporter]
2025/08/08 10:40:36.632930 [exporter] *** Manifest Size: 1086
2025/08/08 10:40:36.632932 [exporter] Timer: Saving testcase... ran for 2.940171127s and ended at 2025-08-08T15:40:36Z
2025/08/08 10:40:36.632935 [exporter] Timer: Exporter ran for 2.968824459s and ended at 2025-08-08T15:40:36Z
2025/08/08 10:40:36.632938 [exporter] Timer: Cache started at 2025-08-08T15:40:36Z

We are never going to get to pre-containerd perf due to the cheat we were using before. We now have to give the docker daemon layers we were entirely able to skip before. Those layers are also the bigger base layers. We are much closer to docker load <exported> on a clean docker installation though. IME it was about ~1.7s.

- Use compressed layer format for containerd storage
- Added parallelization in layer processing
- Optimized size calculation during tar processing
@jabrown85 jabrown85 requested a review from a team as a code owner August 8, 2025 15:37
@jabrown85 jabrown85 requested a review from Copilot August 8, 2025 15:39
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This pull request introduces significant performance optimizations for containerd storage scenarios in the Docker image save/load functionality. The changes focus on reducing expensive operations and adding parallelization to improve image saving performance, especially when using containerd as the storage backend.

  • Implements cached containerd storage detection to avoid redundant checks
  • Adds parallel processing for layer preparation and downloads using errgroups and semaphores
  • Optimizes layer size calculations by deferring expensive operations for containerd storage

Comment thread local/store.go
Comment thread local/store.go Outdated
Comment thread local/store.go Outdated
Comment thread local/store.go Outdated
Comment thread local/store.go
Comment thread local/store.go
Signed-off-by: Jesse Brown <jabrown85@gmail.com>
@jabrown85 jabrown85 merged commit d318e60 into main Aug 12, 2025
2 of 3 checks passed
@edmorley
Copy link
Copy Markdown
Contributor

Note: This PR was reverted in #297.

Comment thread local/store.go
Comment on lines +312 to +330
// Stream compressed data directly to tar (Docker-native format)
tempFile, err := os.CreateTemp("", "compressed-layer-*.tar")
if err != nil {
return err
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()

// Calculate size while streaming to temp file
bytesRead, err := io.Copy(tempFile, compressedReader)
if err != nil {
return err
}
uncompressedSize = bytesRead

// Rewind temp file for reading
if _, err := tempFile.Seek(0, 0); err != nil {
return err
}
Copy link
Copy Markdown

@ep0ll ep0ll Feb 21, 2026

Choose a reason for hiding this comment

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

why not layer.Size()? @jabrown85

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.

layer.Size() returns the compressed size and we need the uncompressed size for the tar header.

Copy link
Copy Markdown

@ep0ll ep0ll Feb 24, 2026

Choose a reason for hiding this comment

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

i am kinda confused, how could io.Copy on layer.Compressed() returns uncompressed size?

is it supposed to be layer.Uncompressed()? (or) willingly copying the compressed layer into tar!!

if it is to copy compressed layer into tar archive then: no.of bytes copied by io.Copy on compressed layer == layer.Size() isn't it

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.

You may be right - it has been a minute since I looked at this properly. If you have the time or interest I'd love for some extra 👀 on optimizations...

This containerd-snapshotter default removes an ancient assumption that allowed for the project to not have to pay the cost of reading and re-sending the same base layers over and over after each build. Even though we are using tar.

Copy link
Copy Markdown

@ep0ll ep0ll Feb 24, 2026

Choose a reason for hiding this comment

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

i have few doubts on docker tarball format, let me know the answers if you know

  1. if a given layer already exists in docker's containerd storage, should we need to rearchive those layers into tarball, (or) docker image load doesn't error if a layer already exists in containerd content store?

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.

I think we have to rearchive them. I've tried a few ways to avoid but nothing works.

The main reason I had to revert was/is that we can produce a fine looking tarball and docker will load it fine..but then it is actually NOT ok in containerd-snapshotter storage. You can't copy/push it without getting errors like (#296)

Digest did not match, expected
  sha256:8a78... got sha256:90e0...

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.

5 participants