I wanted to understand what it actually means to “build a container image with Bazel.” So I built the same image both ways — a tiny Python app, once with a Dockerfile, once with rules_oci. Same output. Totally different build philosophy.
Docker: Imperative, Layer-by-Layer
FROM python:3.12-slim
COPY app/main.py /app/main.py
CMD ["python", "/app/main.py"]
You’re giving Docker a recipe. Do this, then this, then this. Each instruction creates a layer, and layers are cached by position — if the COPY changes, everything after it re-runs too. Even CMD, which didn’t change at all.
Bazel: Declarative, Content-Addressed
pkg_tar(
name = "app_layer",
srcs = ["//app:main.py"],
package_dir = "/app",
)
oci_image(
name = "hello_image",
base = "@python3_slim_linux_amd64",
entrypoint = ["python", "/app/main.py"],
tars = [":app_layer"],
)
oci_load(
name = "hello_tarball",
image = ":hello_image",
repo_tags = ["bazel-hello:latest"],
)
You’re telling Bazel what the image contains, not how to assemble it. Each target is cached by content hash — if main.py changes, only app_layer rebuilds. The base image? Untouched. No cascade.
The Difference That Matters
For this toy example, who cares. Both build in under 2 seconds.
But imagine your real Dockerfile: apt-get install, pip install, CUDA kernel compilation, tokenizer downloads. 20 minutes of layers. You change one line of application code, and Docker re-runs everything after the COPY.
Bazel doesn’t. It knows exactly which targets are affected and skips the rest.
| Docker | Bazel rules_oci | |
|---|---|---|
| Cache key | Instruction position + content | Content hash per target |
| Change app code | COPY + everything after rebuilds | Only app_layer target |
| Needs Docker daemon? | Yes, for build + run | No — only for docker load |
| Output | Image in Docker’s internal store | A directory (OCI layout) or load script |
The Gotcha: oci_image Isn’t a Tar
This tripped me up. oci_image doesn’t produce a file — it produces a directory (OCI image layout with index.json + blobs/). You can’t tar -tf it. You need oci_load to bridge it into Docker-land.
pkg_tar --> app_layer.tar (a file, inspectable)
oci_image --> hello_image/ (a directory, OCI layout)
oci_load --> hello_tarball.sh (a script that does docker load)
Different rules, different output types. bazel cquery --output=files is your friend here.
Should You Migrate?
Honestly — maybe not. If Docker builds are fast enough and your team understands Dockerfiles, the migration cost is real. Bazel’s rules_oci shines in monorepos where build caching across many targets saves serious time. For a single service with a straightforward Dockerfile, it’s probably not worth it.
But if you’re already in Bazel-land and building container images is your bottleneck — rules_oci is the move.