A Faster Path to Container Images in Bazel
Key topics
The debate around Bazel's container image production has sparked a lively discussion, with some commenters questioning the necessity of Docker in the Bazel workflow. While one commenter fumed about downloading Docker base images from external URLs, others clarified that Bazel's role is in producing container images for deployment, not building programs. The conversation also veered into tangential topics, such as the use of AI-generated content and the preference for zstd compression over gzip, with some users pointing out that tools like crane still lack zstd support. Amidst the varied perspectives, a consensus emerged that the Bazel workflow can be counterintuitive, particularly when it comes to handling external dependencies.
Snapshot generated from the HN discussion
Discussion Activity
Very active discussionFirst comment
6d
Peak period
46
Day 7
Avg / period
11.6
Based on 58 loaded comments
Key moments
- 01Story posted
Dec 18, 2025 at 2:50 PM EST
15 days ago
Step 01 - 02First comment
Dec 24, 2025 at 1:55 PM EST
6d after posting
Step 02 - 03Peak activity
46 comments in Day 7
Hottest window of the conversation
Step 03 - 04Latest activity
Dec 30, 2025 at 12:57 PM EST
3d ago
Step 04
Generating AI Summary...
Analyzing up to 500 comments to identify key contributors and discussion patterns
Want the full context?
Jump to the original sources
Read the primary article or dive into the live Hacker News thread when you're ready.
Like, using the words "leverage", "matters for...", "as for", and so on. And you could almost hear him doing the bullet points.
When you work with AI a lot, it changes your vocabulary.
https://github.com/google/go-containerregistry/pull/1827
It drives me absolute batshit insane that modern systems are incapable of either building or running computer programs without docker. Everyone should profoundly embarrassed and ashamed by this.
I’m a charlatan VR and gamedev that primarily uses Windows. But my deeply unpopular opinion is that windows is a significantly better dev environment and runtime environment because it doesn’t require all this Docker garbage. I swear that building and running programs does not actually have to be that complicated!! Linux userspace got pretty much everything related to dependencies and packages very very very wrong.
I am greatly pleased and amused that the most reliable API for gaming in Linux is Win32 via Proton. That should be a clear signal that Linux userspace has gone off the rails.
On Linux vs Win32 flame warring: can you be more specific? What aspects of Windows dependency resolution do you mean is missing in Linux?
Fair. Docker does trigger my predator drive.
I’m pretty shocked that the Bazel workflow involves downloading Docker base images from external URLs. That seems very unbazel like! That belongs in the monorepo for sure.
> What specifically is very very wrong with Linux packaging and dependency resolution?
Linux userspace for the most part is built on a pool of global shared libraries and package managers. The theory is that this is good because you can upgrade libfoo.so just once for all programs on the system.
In practice this turns into pure dependency hell. The total work around is to use Docker which completely nullifies the entire theoretic benefit.
Linux toolchains and build systems are particularly egregious at just assuming a bunch of crap is magically available in the global search path.
Docker is roughly correct in that computer programs should include their gosh darn dependencies. But it introduces so many layers of complexity that are solved by adding yet another layer. Why do I need estargz??
If you’re going to deploy with Docker then you might as well just statically link everything. You can’t always get down to a single exe. But you can typically get pretty close!
Not every dependency in Bazel requires you to "first invent the universe" locally. Lots of examples of this like toolchains, git_repository, http_archive rules and on and on. As long as they are checksum'ed (as they are in this case) so that you can still produce a reproducible artifact, I don't see the problem
I suppose a URL with checksum is kinda sorta equivalent. But the article adds a bunch of new layers and complexity to avoid “downloading Cuda for the 4th time this week”. A whole lot of problems don’t exist if they binary blobs exist directly in the monorepo and local blob store.
It’s hard to describe the magic of a version control system that actually controls the version of all your dependencies.
Webdev is notorious for old projects being hard to compile. It should be trivial to build and run a 10+ year old project.
I don’t think they’re opposites. It seems orthogonal to me.
If you have a bunch of remote execution workers then ideally they sit idle on a full (shallow) clone of the repo. There should be no reason to reset between jobs. And definitely no reason to constantly refetch content.
WORKSPACE files came into being to prevent needing to do that, and now we're on MODULE files instead because they do the same things much more nicely.
That being said, Bazel will absolutely build stuff fully offline if you add the one step of running `bazel sync //...` in between cloning the repo and yanking the cable, with some caveats depending on how your toolchains are set up and of course the possibility that every mirror of your remote dependency has been deleted.
Buildkit from Docker is just a pure bullshit design. Instead of the elegant layer-based system, there's now two daemons that fling around TAR files. And for no real reason that I can discern. But the worst thing is that the caching is just plain broken.
More importantly, the layers were represented as directories on the host system. So when you wanted to run something in the final container, Docker just needed to reassemble it.
Buildkit has broken all of it. Now building is done, essentially, in a separate system, the "docker buildx" command talks with it over a socket. It transmits the context, and gets the result back as an OCI image that it then needs to unpack.
This is an entirely useless step. It also breaks caching all the time. If you build two images that differ only slightly, the host still gets two full OCI artifacts, even if two containers share most of the layers.
It looks like their Bazel infrastructure optimized it by moving caching down to the file level.
Buldkit is far more efficient than the old model.
And since it's a separate system, there are also these strange limitations. For example, I can't just cache pre-built images in an NFS directory and then just push them into the Buildkit context. There's simply no command for it. Buildkit can only pull them from a registry.
> Buldkit is far more efficient than the old model.
I've yet to see it work faster than podman+buildah. And it's also just plain buggy. Caching for multi-stage and/or parallel builds has been broken since the beginning. The Docker team just ignores it and closes the bugs: https://github.com/moby/buildkit/issues/1981 https://github.com/moby/buildkit/issues/2274 https://github.com/moby/buildkit/issues/2279
I understand why. I tried to debug it, and simply getting it running under a debugger is an adventure.
So far, I found that switching to podman+podman-compose is a better solution. At least my brain is good enough to understand them completely, and contribute fixes if needed.
I'm not quite sure I understand what you are trying to do with nfs there. But you can definitely export the cache to a local filesystem and import it with cache-from. You can also provide named contexts.
"Buildkit can only pull them from a registry" is just plain false.
I don't think that the older builder created special containers for itself?
> I'm not quite sure I understand what you are trying to do with nfs there. But you can definitely export the cache to a local filesystem and import it with cache-from.
Which is dog-slow, because it squirts the cache through a socket. I have an NFS disk that can be used to cache the data directly. This was just one of the attempts to make it go faster.
> You can also provide named contexts.
Which can only refer to images that are built inside this particular buildkit or are pullable from a repo.
This is really all I want, a way to quickly reused the previous state saved in some format in Github Cache, NFS, or other storage.
Buildkit doesn't create special containers for itself? It's literally a service integrated into dockerd.
> Which can only refer to images that are built inside this particular buildkit or are pullable from a repo.
No, it supports anything Buildkit can fetch: git, http, client dir... for that matter the client itself can shim that to be whatever it wants.
> This is really all I want, a way to quickly reused the previous state saved in some format in Github Cache, NFS, or other storage.
You can cache to GitHub actions cache, S3, az blob, gcs, registries, or export to the client.
Anything you want to stick it on is going to require copying the data, and yeah that's going to be expensive.
No, it's not. It's a utility container that is hidden from the normal "docker ps". You can see it easily when you use docker-compose with podman.
The easiest way to see it in regular Docker is to create a simple Dockerfile with 'RUN sleep 1000' at the end and start building it. Then enter the Docker host ("docker run -it --rm --privileged --pid=host justincormack/nsenter1") and do 'mount' to see the mounts.
You'll see that buildkit will have its own overlay tree ('/var/lib/docker/buildkit/containerd-overlayfs') and the executor will have its own separate branch too. However, they do share the layers. Now wait for the container to complete building and run it.
You'll see that the running container uses an entirely _different_ set of layers. There is no reuse of layers between the buildkit and the running image.
Yes, the Docker buildkit is technically a daemon that is co-located with dockerd and just runs in its own cgroup tree. But it might as well be remote, because the resulting image runs in a completely different environment.
And the way the image is transferred from buildkit is through the containerd. Which is another separate container in the "moby" namespace.
> No, it supports anything Buildkit can fetch: git, http, client dir... for that matter the client itself can shim that to be whatever it wants.
Any examples?
> You can cache to GitHub actions cache, S3, az blob, gcs, registries, or export to the client.
Go on, try it. Here's a minimal repro: https://gist.github.com/Cyberax/61e6b419cd338ae7c3a7c7098abe...
First, you can build the base image, with the GHA or registry cache. It works. But the `proto` stage will never use cache. The "base" image is supplied through an additional context.
> > No, it supports anything Buildkit can fetch: git, http, client dir... for that matter the client itself can shim that to be whatever it wants. > Any examples?
--build-context foo=https://github.com/example/repo.git
Then you can "FROM foo" or whatever you want to do with that context.
> First, you can build the base image, with the GHA or registry cache. It works. But the `proto` stage will never use cache. The "base" image is supplied through an additional context.
What are you expecting to cache here? Are you saying using an extra context like this is preventing it from using the cache?
Each layer is a tarball.
So build your tarballs (concurrently!), and then add some metadata to make an image.
From your comment elsewhere it seems maybe you are expecting the docker build paradigm of running a container and snapshotting it at various stages.
That is messy and has a number of limitations — not the least of which is cross-compilation. Reproducibility being another. But in any case, that definitely not what these rules are trying to do.
I guess the answer for Bazel is "don't do this"? Docker handles cross-compilation by using emulators, btw.
Yes. The Bazel way use to produce binaries, files, directories, and then create an image “directly” from these.
Much as you would create a JAR or ZIP or MSI.
This is (1) fast (2) small and (3) more importantly reproducible. Bazel users want their builds to produce artifacts that are exactly the same, for a number of reasons. Size is also nice…do you really need ls in your containerized service?
Most Docker users don’t care about reproducibility. They’ll apt-get install and get one version today and another version tomorrow.
Good? Bad? That’s a value judgement. But Bazel users have fundamentally different objectives.
> emulators
Yeah emulators is the Docker solution for producing images of different architectures.
Since Bazel doesn’t run commands as a running container, that’s N/A for it.
Yeah, I do. For debugging mostly :(
> Most Docker users don’t care about reproducibility. They’ll apt-get install and get one version today and another version tomorrow.
Ubuntu has daily snapshots. Not great, but works reasonably well. I tried going down the Nix route, but my team (well, and also myself) struggled with it.
I'd love to have fully bit-for-bit reproducible builds, but it's too complicated with the current tooling. Especially for something like mobile iOS apps (blergh).
I'd also avoid loading the result back into the docker daemon unless you really need it there. Buildkit can output directly to a registry, or an OCI Layout, each of which will maintain the image digest and support multi-platform images (admittedly, those problems go away with the containerd storage changes happening, but it's still an additional export/import that can be skipped).
All that said, I think caching is often the wrong goal. Personally, I want reproducible builds, and those should bypass any cache to verify each step always has the same output. Also, when saving the cache, every build caches every step, even if they aren't used in future builds. As a result, for my own projects, the net result of adding a cache could be slower builds.
Instead of catching the image build steps, I think where we should be spending a lot more effort is in creating local proxies of upstream dependencies, removing the network overhead of pulling dependencies on every build. Compute intensive build steps would still be slow, but a significant number of image builds could be sped up with a proxy at the CI server level without tuning builds individually.
Well, that's what I've been trying to do. And failing, because it simply doesn't work.
> I'd also avoid loading the result back into the docker daemon unless you really need it there.
I need Docker to provide me a reproducible environment to run lints, inspections, UI tests and so on. These images are quite massive. And because caching in Docker is broken, they were getting rebuilt every time we did a push.
Well. I switched to Podman and podman-compose. Now they do get cached, and the build time is within ~1 min with the help of the GHA cache.
And yes, my deployment builds are produced without any caching.
I'm not too surprised that out of the box docker images exhibit more of this. While it's good they're fixing it, it feels like maybe some of the core concepts cause pretty systematic issues anytime you try to do anything beyond the basic feature set...
Bazel is a general purpose tool like Make. But with caching and sandboxing.
Make is no less focused on Docker than Bazel is.
Unlike Make however, Bazel does make it easy to share rule sets.
But you don’t need to use other people’s Bazel rule sets any more than you need to use other people’s Make recipes.
This author has a clever way to minimize needing to touch layers at all.
I don't agree with your parent comments about Bazel, but your comment is not fair too. Bazel tries to be better build tool so it took on responsibility on registry / rules_* and get critics for it is a fair game.
The "bloated Bazel" blame is not fair too, but I think somewhat understandable. If you ever going to do JavaScript, bun or other package manager is enough and "lighter-weight". Same goes to uv + Python bundle. Bazel only shines if you are dealing with your C++ mess and even there, people prefer CMake for reasons beyond me.
Genuine question - also find Bazel frustrating at times.
Of the three, I went with Buck2. Maybe just circumstance with Rust support being good and not built to replace Cargo?
Bazel was a huge pain - broke all standard tooling by taking over Cargos job, then unable to actually build most packages without massive multi-day patching efforts.
Pants seemed premature - front page examples from the docs didn’t work, apparently due to breaking changes in minor versions, and the Rust support is very very early days.
Buck2 worked out of the box exactly as claimed, leaves Cargo intact so all the tooling works.. I’m hopeful.
Previously I’ve used Make for polyglot monorepos.. but it requires an enormous amount of discipline from the team, so I’m very keen for a replacement with less foot guns
Any readily available build system is more of a meta-language onto which you code your own logic, but with limited control and capabilities. Might as well take control of the whole stack in a real programming language.
Building my own build system lets me optimize my workflow end-to-end, from modular version management, packaging and releasing, building and testing, tightly integrating whatever tool or reporting I want, all seamlessly under the same umbrella.
I mostly do C++, Assembly, eBPF, Python (including C++ Python modules), and multi-stage codegen on Linux, so I haven't really looked at the complexity of other languages or platforms.
Unfortunately, the amount of work you need to do just to maintain the build across language and bazel version upgrades is incredibly high. Let alone adding new build steps, or going even slightly off the well-trodded path.
My go-to now is to use mise + just to glue together build artifacts from every language's standard build tools. It's not great but at least I get to spend time on programming instead of fixing the build.
They care about setting standards over widely different teams and managing large-scale upgrades etc.
It's not optimized for velocity or cost efficiency, which is what a smaller organization needs.
Container layers are so large that moving them around is heavy.
So defer that part for the non-hermetic push/load parts of the process, while retaining heremticity/reproducibility.
You can sort of think of it like the IO monad in Haskell…defer it all until the impure end.
Ok, wait, why?
At build time, it generates a JSON file describing the image metadata and the layers data location. At runtime, it consumes this JSON file to stream layer data and image configuration to a destination. This is implemented by adding a new transport to Skopeo. Thanks to this implementation, nix2container doesn't need to handle all various destrination since this is managed by Skopeo itself.
Recently, we introduced a mechanism to also produce such kind of JSON file for the base image (see https://github.com/nlewo/nix2container?tab=readme-ov-file#ni...).
I'm pretty sure the added (not usptreamed yet) transport could be useful in some other close contexts, such as Bazel or Guix.
I'm the nix2container author and i will be glad to discuss with you if you think this Skopeo transport could be useful for you!
(btw, your blog post is pretty well written!)
Most of the hyper scaler actually do not store container images as tarballs at scale. They usually flatten the layers and either cache the entire file system merkle tree, or breaking it down to even smaller blocks to cache them efficiently. See Alibaba Firefly Nydus, AWS Firecracker, etc… There is also various different forms of snapshotters that can lazily materialize the layers like estargz, soci, nix, etc… but none of them are widely adopted.
I'm the author of one of those off the shelf tools, and the rules_oci decision here always struck me as a bit unusual. OCI is a relatively easy spec with a number of libraries that implement it. Instead of creating a custom build command that leveraged those libraries to be an efficient build tool, they found commands that could be leveraged even if image building wasn't their design.
It looks like rules_img is taking that other path with their own build command based on the go-containerregistry library. I wish them all the best with their effort.
That said, if all you need to do is add a layer to an existing base, there are tools like crane [0] and regctl [1] that do that today.
The reason other build tools typically pull the base image first is to support "RUN" build steps that execute a command inside of a container and store the filesystem changes in a new layer. If that functionality is ever added to rules_img, I expect it to have the same performance as other build tools.
[0]: https://github.com/google/go-containerregistry/blob/main/cmd...
[1]: https://regclient.org/cli/regctl/image/mod/