I read Tweag's post, "Announcing rules_img: a faster path to container images in Bazel," and noted its main points. What stood out was that the speed improvement comes less from optimizing the tool and more from a design that changes how data moves. The idea is simple: treat images as metadata first, and move the bytes (layers) only at the end.
Starting with Bazel
Bazel is a build system created by Google. It manages builds, tests, and packaging based on a dependency graph, with strengths in caching and reproducibility. Adding a remote cache or remote build execution (RBE) can reduce build times even in large codebases. rules_img, the subject of this post, is a ruleset for building container images in Bazel.
The Starting Problem: Why Bazel Image Builds Slow Down
Bazel caches build outputs well and provides reproducibility. But adding container images introduces a bottleneck. In the existing rules_oci workflow, the following happens.
- Pulling a base image downloads all layers from the registry to the local machine.
- With remote execution (RBE), the local machine uploads the layers to the remote cache again.
- The image assembly action receives layers ranging from hundreds of MB to GB as inputs.
- Pushing also downloads layers locally before uploading them to the registry again.
In other words, large amounts of data unrelated to the build are moved repeatedly. The slowdown ultimately comes down to the amount of data transferred.
A 2-Minute Overview: OCI Image Components
An image is broadly divided into metadata and layer bytes.
- config: Execution command, environment variables, user, and diff ID list
- manifest: Metadata pointing to the config and layer blobs
- index: A list of manifests for multiple architectures
- layers: The actual file changes as compressed tar archives
An image therefore consists of the actual layer bytes and JSON pointing to those bytes. rules_img uses this structure directly to optimize performance.
The Core Ideas Behind rules_img
rules_img: 메타데이터는 빌드 그래프로, 레이어 바이트는 끝에서만
rules_img: 메타데이터는 빌드 그래프로, 레이어 바이트는 끝에서만
Read the diagram as text
- pull은 레지스트리에서 manifest와 config JSON만 가져온다. 레이어 블롭은 레지스트리에 둔다.
- 가져온 메타데이터가 빌드 그래프로 들어간다.
- image_layer 액션이 tar와 메타데이터를 함께 만들고, 레이어 바이트는 CAS에 저장된다.
- 같은 액션의 작은 메타데이터만 빌드 그래프로 흐른다.
- image_manifest 조립은 레이어의 digest와 size만 참조하는 작은 JSON 작업이다.
- 빌드 단계는 무엇을 푸시할지 명세만 만들고, 실제 바이트 이동은 bazel run 시점으로 미룬다.
- bazel run은 레지스트리에 없는 레이어만 전송한다.
1) Metadata-Only Pulls
Pulling a base image does not download its layers. Only the manifest and config JSON are fetched, revealing what is present. The layer blobs stay in the registry.
2) Layer Creation in CAS, Metadata in the Graph
A single action creates both the layer tar and its metadata. The layer bytes are stored in CAS, while only compact metadata flows through the build graph.
3) Manifest Assembly Is Just a Small JSON Operation
The image assembly step references only layer digests and sizes. This keeps remote execution inputs small and improves cache efficiency.
4) Defer Push/Load to the run Stage
The build stage only produces a specification of what to push. Actual byte transfer happens at bazel run time, sending only layers missing from the registry. The local machine does not act as an unnecessary intermediary.
Why It Is Faster, in One Sentence
Let only metadata flow through the build graph, and move bytes only at the end.
The post describes the following effects.
- Base image pull time falls from minutes to seconds.
- Input staging delays are greatly reduced in remote execution environments.
- Local loading, such as
docker load, finishes quickly through incremental updates. - Pushes transfer only the necessary layers.
Comparing rules_oci and rules_img
| Category | rules_oci | rules_img |
|---|---|---|
| Base image pull | Download all layers | Download only manifest/config |
| Build inputs | Include full layers | Metadata-focused |
| Remote execution | Heavy input staging | Light input staging |
| Push method | Retransfer all layers | Transfer only missing layers |
| Local load | Full reload with docker load | Incremental loading based on containerd |
Practical Points to Check
- Because base layers are not actually mounted at build time, some binaries need compatibility validation. The post mentions validation tools such as container-structure-test.
- Environments that must fetch layers at build time can change the behavior through options.
- Performance varies substantially with the remote execution environment's input staging method (lazy vs eager download).
A Short Getting-Started Snippet
# MODULE.bazel
bazel_dep(name = "rules_img", version = "<version>")
pull = use_repo_rule("@rules_img//img:pull.bzl", "pull")
pull(
name = "ubuntu",
registry = "index.docker.io",
repository = "library/ubuntu",
tag = "24.04",
)
# BUILD.bazel
load("@rules_img//img:layer.bzl", "image_layer")
load("@rules_img//img:image.bzl", "image_manifest")
image_layer(
name = "app_layer",
srcs = {
"/app/bin/server": "//cmd/server",
},
)
image_manifest(
name = "app_image",
base = "@ubuntu",
layers = [":app_layer"],
)
Closing Notes
rules_img turns the fact that an image is a combination of metadata and bytes directly into a performance strategy. During the build, it handles only metadata; bytes move only at the end. This simple principle makes a noticeable speed difference in remote execution environments. If image builds are a bottleneck in Bazel-based CI, understanding why rules_img is fast can itself change the direction of your design.