A guided Rust project. Build a real tool from scratch, one lesson at a time.
Smash a Docker image into a single self-contained executable. No daemon. No registry. No YAML. Just a binary.
bonk alpine:latest
./alpine echo "ooga booga"
Ship anywhere Linux runs — zero runtime dependencies on the target:
bonk python:3.12-slim -o python3
scp python3 someserver:
ssh someserver ./python3 -c "print('hello')"
bwrap and unsquashfs are embedded in the output binary — the target machine needs nothing pre-installed.
bonkexports a Docker image, flattens its layers, and builds a SquashFS image withmksquashfs- Appends the SquashFS image + static tool binaries (
bwrap,unsquashfs) + container config to a small runner binary - The output is a single self-contained executable. On first run it writes the embedded
.sqfsfile to a cache dir and either loop-mounts it (if run with--mount/ as root) or extracts it viaunsquashfs. Subsequent runs skip both steps.
┌──────────────────────┐
│ bonk-runner ELF │ small Rust binary
├──────────────────────┤
│ rootfs.sqfs │ SquashFS image (zstd-compressed)
├──────────────────────┤
│ bwrap (static) │ ~134 KB embedded container runtime
├──────────────────────┤
│ unsquashfs (static) │ ~1.1 MB embedded SquashFS extractor
├──────────────────────┤
│ config.json │ entrypoint, cmd, env, workdir
├──────────────────────┤
│ footer (56 bytes) │ offsets + sizes + magic number
└──────────────────────┘
At runtime:
- Runner reads itself (
/proc/self/exe), locates payload via the footer - Extracts
bwrap+unsquashfsto/tmp/bonk-<hash>/bin/(cached) - Makes the rootfs available at
/tmp/bonk-<hash>/rootfs/— kernel squashfs loop-mount if privileged (--mount/ root), otherwiseunsquashfsextraction (both cached; skipped on warm runs) - Execs
bwrapover the rootfs — overlay filesystem when loop-mounted (read-only lower layer + ephemeral upper), bind-mount when extracted - Exits with the container's exit code
Pre-built static binaries are available for every GitHub Release.
x86_64 (Linux)
VERSION=v0.2.0
curl -fsSL https://github.com/avolkha/bonk/releases/download/${VERSION}/bonk-x86_64-unknown-linux-musl -o bonk
curl -fsSL https://github.com/avolkha/bonk/releases/download/${VERSION}/bonk-runner-x86_64-unknown-linux-musl -o bonk-runner
chmod +x bonk bonk-runner
sudo mv bonk bonk-runner /usr/local/bin/ARM64 (Linux)
VERSION=v0.2.0
curl -fsSL https://github.com/avolkha/bonk/releases/download/${VERSION}/bonk-aarch64-unknown-linux-musl -o bonk
curl -fsSL https://github.com/avolkha/bonk/releases/download/${VERSION}/bonk-runner-aarch64-unknown-linux-musl -o bonk-runner
chmod +x bonk bonk-runner
sudo mv bonk bonk-runner /usr/local/bin/Both binaries are fully static (musl) — no runtime dependencies on the target machine.
cargo build --release
cp target/release/bonk target/release/bonk-runner ~/.cargo/bin/Both bonk and bonk-runner must be locatable — either in the same directory or on $PATH.
Build machine:
- Rust toolchain
- Docker (for
docker save) mksquashfs(fromsquashfs-tools)
# Ubuntu/Debian
sudo apt install squashfs-toolsTarget machine: Linux kernel 3.8+. On Ubuntu 23.10+ and other distros with AppArmor 4.0, unprivileged user namespaces are restricted by default — see AppArmor compatibility below.
# Output name derived from image name
bonk alpine:latest # → ./alpine
# Custom output path
bonk -o myapp my_image:latest
# Run the generated binary
./alpine # launches default CMD
./alpine echo hello # override CMD
./alpine --help # show runner helpPass -e KEY=VALUE (repeatable) to inject environment variables into the container. These are appended after the image's own env vars and override any matching keys. No host environment variables leak in implicitly.
./myapp -e DEBUG=1 -e PORT=8080 -- node server.js
./myapp -e HOME=/data -- bash./myapp -v /host/data:/data # read-write
./myapp -v /etc/hosts:/etc/hosts:ro cat /etc/hosts # read-only
./myapp -v ./input:/input -v ./output:/output -- process.shFollowing Docker semantics, extra args replace CMD while ENTRYPOINT is preserved:
# Image: ENTRYPOINT ["python3"], CMD ["app.py"]
./myapp # runs: python3 app.py
./myapp -c "print(42)" # runs: python3 -c "print(42)"| Flag | Effect |
|---|---|
-e, --env KEY=VALUE |
Set an environment variable inside the container. Appended after image vars; overrides image defaults. Repeatable. No host env vars leak implicitly. |
-v, --volume HOST:GUEST[:ro] |
Bind-mount a host path. Append :ro for read-only. Repeatable. |
--mount |
Privileged first-run setup. Writes the .sqfs file, kernel loop-mounts it at rootfs/, then chowns bin/, rootfs.sqfs, the marker file, and the cache dir itself back to the invoking user (SUDO_UID:SUDO_GID) so unprivileged runs can access the cache. The squashfs mountpoint stays root-owned. Must be run with sudo or as root. Subsequent plain invocations skip this step automatically. |
-q, --quiet |
Suppress progress output. |
| Flag | Effect |
|---|---|
-o <path> |
Output binary path (default: ./<image_name>) |
--bwrap-path <path> |
Embed this specific bwrap binary |
--unsquashfs-path <path> |
Embed this specific unsquashfs binary |
| Variable | Effect |
|---|---|
BONK_TOOLS_DIR=<dir> |
Directory containing bwrap and unsquashfs to embed |
BONK_RUNNER=<path> |
Path to the bonk-runner binary to embed |
BONK_BWRAP=<path> |
Override the embedded bwrap binary at runtime (set on the target machine, not at build time) |
When --bwrap-path / --unsquashfs-path are omitted, bonk searches in order:
BONK_TOOLS_DIRenvironment variabletools/<arch>/next to the bonk binarytools/next to the bonk binary (flat)
bonk/
├── Cargo.toml # workspace
├── crates/
│ ├── bonk-common/ # shared types (footer, config)
│ ├── bonk-cli/ # `bonk` — the build tool
│ │ └── src/
│ │ ├── main.rs # CLI entry point
│ │ ├── image.rs # docker save + manifest parsing
│ │ ├── flatten.rs # layer flattening + whiteout handling
│ │ └── pack.rs # squashfs build + binary assembly
│ └── bonk-runner/ # embedded runner stub
│ └── src/
│ ├── main.rs # payload dispatch + cache management
│ ├── mount.rs # kernel squashfs loop-mount + unsquashfs fallback
│ └── runtime.rs # bwrap invocation + volume mounts
├── lessons/ # guided curriculum (see below)
└── tests/
└── e2e.sh
This repo is structured as a guided Rust curriculum. Each lesson introduces language concepts through a concrete piece of the tool.
| # | Lesson | Concepts |
|---|---|---|
| 01 | Workspace & Project Structure | Cargo workspaces, crate layout |
| 02 | Structs, Traits & Shared Types | Structs, serde, shared crates |
| 03 | Error Handling & CLI Skeleton | anyhow, clap, Result |
| 04 | Spawning Subprocesses | std::process::Command, I/O piping |
| 05 | File I/O & JSON Parsing | std::fs, serde_json, tar reading |
| 06 | Trait Objects & Compression Detection | dyn Read, dynamic dispatch |
| 07 | Iterators & Layer Flattening | Iterators, whiteout handling, HashMap |
| 08 | SquashFS Build & Binary Assembly | File I/O, binary layout, footer writing |
| 09 | Self-Reading Binaries & Caching | /proc/self/exe, seeks, cache logic |
| 10 | Container Runtime | exec, namespaces, volume mounts |
dockerc is the closest comparable tool — it also converts Docker images into single self-contained executables with no runtime dependencies. The key architectural difference is in how the rootfs is served at runtime.
| bonk | dockerc | |
|---|---|---|
| Rootfs strategy | Kernel squashfs loop-mount (privileged) or extract once → native dir | Mount squashfs via FUSE at runtime |
| Embedded tools | bwrap + unsquashfs (1.2 MB) |
crun + squashfuse + fuse-overlayfs |
| Container runtime | bwrap (user namespaces) | crun (OCI) |
| Disk usage | .sqfs file + ephemeral overlay or uncompressed rootfs |
None (mounts directly from squashfs) |
| Runtime overhead | Zero (native kernel fs) | ~20 ms+ per invocation (FUSE round-trips) |
| aarch64 16K-page kernels | ✅ works | ❌ crashes (Zig runtime panic) |
| alpine binary size | ~5.2 MB | ~11 MB |
| Runner stub size | ~720 KB | ~7.2 MB |
Why bonk is faster at runtime: dockerc mounts squashfs via FUSE — every open() and stat() goes user → kernel → FUSE daemon → kernel → back, through two FUSE layers. bonk either loop-mounts the squashfs directly via the kernel squashfs driver (zero FUSE overhead) or extracts to a native directory once; both strategies hit the filesystem at native speed on warm runs.
Where dockerc wins: no one-time setup step required — it mounts on every invocation without needing root.
bonk's privileged path vs. dockerc: sudo ./myapp --mount runs once to set up the kernel mount, then every subsequent ./myapp call runs unprivileged at full speed — no FUSE daemon, no extraction wait.
- Linux only — bwrap is Linux-specific
- No cgroup isolation — no memory/CPU limits
- No multi-container orchestration — single binaries, not a compose replacement
- Cache in
/tmp— cache is lost on reboot; first run after reboot re-extracts or re-mounts - Privileged mount requires
sudo— the kernel squashfs loop-mount path needs root; without it bonk falls back tounsquashfsextraction - Disk usage (extraction path) — rootfs cache uses disk space equal to the uncompressed image; the mount path only stores the
.sqfsfile
bonk's rootless code path depends on unprivileged user namespaces (clone(CLONE_NEWUSER)). Ubuntu 23.10+ and other distros running AppArmor 4.0 restrict this by default (kernel.apparmor_restrict_unprivileged_userns=1). Because the embedded bwrap binary is extracted to a temporary cache directory with no installed AppArmor profile, the syscall is denied and ./myapp fails silently.
| Setup | Result |
|---|---|
| Non-root, Ubuntu 24.04+ VM (default AppArmor) | Broken — user namespace creation denied |
| Non-root, older Ubuntu / AppArmor disabled | Works |
Root (sudo --mount path) |
Works — bypasses user namespaces entirely |
Non-root, system bwrap with AppArmor profile |
Works — see Option C below |
Option A — use the privileged mount path (recommended for Ubuntu VMs):
sudo ./myapp --mount # one-time setup per reboot; chowns cache back to you
./myapp # all subsequent runs are unprivileged at full speedThe loop-mount path runs bwrap as root using --unshare-ipc/pid/uts/cgroup rather than --unshare-all, so AppArmor's user-namespace restriction does not apply.
Option B — allow unprivileged user namespaces system-wide:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
# Persist across reboots:
echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/99-userns.confOption C — use the system bwrap instead of the embedded one:
If bwrap is installed via the system package manager, its distro-provided AppArmor profile grants it the userns capability:
sudo apt install bubblewrap
BONK_BWRAP=/usr/bin/bwrap ./myappCommits follow the Conventional Commits spec. This drives the changelog and version bumps:
| Prefix | Effect |
|---|---|
fix: |
patch release |
feat: |
minor release |
feat!: / BREAKING CHANGE: |
major release |
chore:, docs:, style:, test: |
no release |
Apache License 2.0 — see LICENSE for details.