|
| 1 | +# Building Custom Node Images |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +When debugging a bug in a node component (bootc, CRI-O, kubelet, kernel, etc.), you need to replace the package or binary with a custom build and verify the fix. There are two approaches: |
| 6 | + |
| 7 | +1. **Runtime update** — Build a custom bootc OCI image and use `bootc switch` to deploy it on a running node. Faster, no disk rebuild needed. |
| 8 | +2. **Disk image rebuild** — Rebuild the entire qcow2 disk image. Required when the fix must be present from first boot (kernel, bootc itself, initrd). |
| 9 | + |
| 10 | +**When to use which:** |
| 11 | + |
| 12 | +| Situation | Approach | |
| 13 | +|-----------|----------| |
| 14 | +| Patching a userspace component (CRI-O, kubelet, a CLI tool) | Runtime update | |
| 15 | +| Patching bootc itself, the kernel, or initrd | Disk image rebuild | |
| 16 | +| Need the fix before the first boot completes | Disk image rebuild | |
| 17 | + |
| 18 | +## Prerequisites |
| 19 | + |
| 20 | +- Podman with socket running (`systemctl --user start podman.socket`) |
| 21 | +- `/dev/kvm` and `/dev/fuse` accessible |
| 22 | +- bink binary built (see [README](../README.md)) |
| 23 | +- A running bink cluster, or you will create one |
| 24 | + |
| 25 | +## Inspecting the Current Node State |
| 26 | + |
| 27 | +Before making changes, inspect what is currently deployed. SSH into a node: |
| 28 | + |
| 29 | +```bash |
| 30 | +bink node ssh node1 --cluster-name <name> |
| 31 | +``` |
| 32 | + |
| 33 | +Then run: |
| 34 | + |
| 35 | +```bash |
| 36 | +# Check the ostree deployment |
| 37 | +ostree admin status |
| 38 | + |
| 39 | +# Show ostree commit details |
| 40 | +ostree show --repo=/sysroot/ostree/repo <checksum> |
| 41 | + |
| 42 | +# Check the origin file (which container image the node tracks) |
| 43 | +sudo cat /sysroot/ostree/deploy/default/deploy/*.origin |
| 44 | + |
| 45 | +# Full bootc status |
| 46 | +sudo bootc status --json | jq |
| 47 | + |
| 48 | +# Check specific package versions |
| 49 | +rpm -qa | grep -E 'bootc|cri-o|kubeadm' |
| 50 | +``` |
| 51 | + |
| 52 | +## Approach 1: Runtime Update via `bootc switch` |
| 53 | + |
| 54 | +The simpler path when the patch does not need to be in the boot image. Build a custom bootc OCI image, push it to bink's local registry, and switch the node to it. |
| 55 | + |
| 56 | +### Example: Building bootc from Source |
| 57 | + |
| 58 | +The [bootc repository](https://github.com/bootc-dev/bootc) has a Dockerfile with `ARG base=quay.io/centos-bootc/centos-bootc:stream10`. Override this to use the bink node image as the base, producing a new bootable container image with bootc compiled from any git commit: |
| 59 | + |
| 60 | +```bash |
| 61 | +git clone https://github.com/bootc-dev/bootc.git |
| 62 | +cd bootc |
| 63 | +git checkout <commit-or-branch> |
| 64 | + |
| 65 | +podman build \ |
| 66 | + --build-arg base=ghcr.io/bootc-dev/bink/node:v1.35-fedora-44 \ |
| 67 | + -t localhost/custom-bootc-node:latest \ |
| 68 | + . |
| 69 | +``` |
| 70 | + |
| 71 | +This compiles bootc from source and installs it into an image derived from the bink node image, preserving all the existing packages (kubernetes, CRI-O, etc.). |
| 72 | + |
| 73 | +### Example: Layering a Custom Package |
| 74 | + |
| 75 | +For simpler cases (installing a different package version), write a minimal Containerfile: |
| 76 | + |
| 77 | +```dockerfile |
| 78 | +FROM ghcr.io/bootc-dev/bink/node:v1.35-fedora-44 |
| 79 | +RUN dnf -y install <your-package> |
| 80 | +``` |
| 81 | + |
| 82 | +Build it: |
| 83 | + |
| 84 | +```bash |
| 85 | +podman build -t localhost/custom-node:latest -f Containerfile.custom . |
| 86 | +``` |
| 87 | + |
| 88 | +### Push to the Local Registry and Switch |
| 89 | + |
| 90 | +Bink runs a local OCI registry on port 5000. From the host, push to `localhost:5000`. Inside the VMs, the registry is reachable at `registry.cluster.local:5000`. |
| 91 | + |
| 92 | +```bash |
| 93 | +podman push --tls-verify=false \ |
| 94 | + localhost/custom-bootc-node:latest \ |
| 95 | + localhost:5000/custom-bootc-node:latest |
| 96 | +``` |
| 97 | + |
| 98 | +SSH into the node and switch: |
| 99 | + |
| 100 | +```bash |
| 101 | +bink node ssh node1 --cluster-name <name> |
| 102 | + |
| 103 | +sudo bootc switch \ |
| 104 | + registry.cluster.local:5000/custom-bootc-node:latest |
| 105 | +``` |
| 106 | + |
| 107 | +To switch by digest: |
| 108 | + |
| 109 | +```bash |
| 110 | +sudo bootc switch \ |
| 111 | + registry.cluster.local:5000/custom-bootc-node@sha256:<digest> |
| 112 | +``` |
| 113 | + |
| 114 | +Reboot to apply: |
| 115 | + |
| 116 | +```bash |
| 117 | +sudo reboot |
| 118 | +``` |
| 119 | + |
| 120 | +### Verify After Reboot |
| 121 | + |
| 122 | +```bash |
| 123 | +bink node ssh node1 --cluster-name <name> |
| 124 | + |
| 125 | +ostree admin status |
| 126 | +sudo bootc status --json | jq '.status.booted.image' |
| 127 | +rpm -q bootc |
| 128 | +``` |
| 129 | + |
| 130 | +### Using `--target-imgref` for New Clusters |
| 131 | + |
| 132 | +Instead of switching after boot, set the tracked image reference at cluster creation: |
| 133 | + |
| 134 | +```bash |
| 135 | +bink cluster start \ |
| 136 | + --cluster-name test \ |
| 137 | + --target-imgref registry.cluster.local:5000/custom-bootc-node:latest |
| 138 | +``` |
| 139 | + |
| 140 | +This rewrites the ostree origin file during cloud-init so the node tracks your custom image from the start. Running `bootc upgrade` will pull updates from your custom image reference. |
| 141 | + |
| 142 | +## Approach 2: Rebuild the Disk Image |
| 143 | + |
| 144 | +Required when the fix must be present from first boot (kernel, bootc, initrd changes). |
| 145 | + |
| 146 | +### Build Pipeline Overview |
| 147 | + |
| 148 | +The node disk image is built in two stages from `node-images/fedora/`: |
| 149 | + |
| 150 | +``` |
| 151 | +Containerfile -> bootc OCI image (bootc-base-imagectl build-rootfs) |
| 152 | +Containerfile.disk -> qcow2 disk image (bcvk to-disk) |
| 153 | +``` |
| 154 | + |
| 155 | +- **Stage 1** (`Containerfile`): Builds a bootc OCI image from `quay.io/fedora/fedora-bootc:44`. Uses `bootc-base-imagectl build-rootfs` with `--install` flags for packages (kubernetes, CRI-O, etc.). Validated with `bootc container lint`. |
| 156 | +- **Stage 2** (`Containerfile.disk`): Converts the bootc OCI image to a qcow2 disk using `bcvk to-disk`. The final container image contains only `/disk.qcow2` and `/images.txt`. |
| 157 | + |
| 158 | +> **Important:** Do not run `podman build` directly on `node-images/fedora/Containerfile`. The `build-rootfs` command requires elevated privileges. Always use the Makefile targets (`make build-bootc-image`, `make build-disk-image`) which pass the correct flags (`--cap-add=all --security-opt=label=disable --device /dev/fuse`). |
| 159 | +
|
| 160 | +### Scenario A: Custom Package from a COPR Repo |
| 161 | + |
| 162 | +Modify `node-images/fedora/Containerfile` to add a COPR repo before the `build-rootfs` command: |
| 163 | + |
| 164 | +```dockerfile |
| 165 | +RUN dnf -y install 'dnf5-command(copr)' && \ |
| 166 | + dnf -y copr enable <owner>/<project> fedora-44-x86_64 |
| 167 | +``` |
| 168 | + |
| 169 | +The COPR architecture string must match the base image (e.g., `fedora-44-x86_64` for Fedora 44). |
| 170 | + |
| 171 | +Build and test: |
| 172 | + |
| 173 | +```bash |
| 174 | +cd node-images/fedora |
| 175 | + |
| 176 | +make build-bootc-image BOOTC_IMAGE=localhost/custom-node:latest |
| 177 | +make build-disk-image \ |
| 178 | + BOOTC_IMAGE=localhost/custom-node:latest \ |
| 179 | + NODE_IMAGE=localhost/custom-node:disk |
| 180 | + |
| 181 | +cd ../.. |
| 182 | + |
| 183 | +bink cluster start \ |
| 184 | + --node-image localhost/custom-node:disk \ |
| 185 | + --cluster-name custom-test |
| 186 | +``` |
| 187 | + |
| 188 | +> **Note:** COPR repos should be used as an opt-in customization for debugging and testing, not as a permanent change to the default image. Upstream COPR updates can introduce unexpected changes that break CI. |
| 189 | +
|
| 190 | +### Scenario B: Local RPM File |
| 191 | + |
| 192 | +Copy the RPM into the build context and install it into the target rootfs after `build-rootfs` completes: |
| 193 | + |
| 194 | +```dockerfile |
| 195 | +COPY my-package.rpm /tmp/my-package.rpm |
| 196 | + |
| 197 | +# After the build-rootfs RUN instruction: |
| 198 | +RUN dnf --installroot=/target-rootfs install -y /tmp/my-package.rpm |
| 199 | +``` |
| 200 | + |
| 201 | +The `--installroot=/target-rootfs` flag is required because `build-rootfs` assembles the filesystem at `/target-rootfs`, not in the builder's own root. |
| 202 | + |
| 203 | +Build and test with the same `make` commands as Scenario A. |
| 204 | + |
| 205 | +### Scenario C: Custom Binary Replacement |
| 206 | + |
| 207 | +Use a multi-stage build to compile the binary and copy it into the node image: |
| 208 | + |
| 209 | +```dockerfile |
| 210 | +FROM fedora:44 AS custom-build |
| 211 | +RUN dnf -y install git golang make |
| 212 | +COPY my-source/ /src |
| 213 | +WORKDIR /src |
| 214 | +RUN make build |
| 215 | + |
| 216 | +FROM quay.io/fedora/fedora-bootc:44 AS builder |
| 217 | +ARG KUBE_MINOR=1.35 |
| 218 | +ARG KERNEL_VERSION=6.19.14-300.fc44 |
| 219 | +RUN /usr/libexec/bootc-base-imagectl build-rootfs \ |
| 220 | + --manifest=minimal \ |
| 221 | + --no-docs \ |
| 222 | + --lock kernel-${KERNEL_VERSION} \ |
| 223 | + --lock kernel-core-${KERNEL_VERSION} \ |
| 224 | + --lock kernel-modules-${KERNEL_VERSION} \ |
| 225 | + --lock kernel-modules-core-${KERNEL_VERSION} \ |
| 226 | + --install NetworkManager \ |
| 227 | + --install openssh-server \ |
| 228 | + --install openssh-clients \ |
| 229 | + --install kubernetes \ |
| 230 | + --install kubernetes${KUBE_MINOR}-kubeadm \ |
| 231 | + --install kubernetes${KUBE_MINOR}-client \ |
| 232 | + --install cri-o${KUBE_MINOR} \ |
| 233 | + --install qemu-guest-agent \ |
| 234 | + --install bind-utils \ |
| 235 | + --install iputils \ |
| 236 | + --install cloud-init \ |
| 237 | + --install dnsmasq \ |
| 238 | + --install bubblewrap \ |
| 239 | + --install sudo \ |
| 240 | + --install vim-minimal \ |
| 241 | + --install jq \ |
| 242 | + --install less \ |
| 243 | + /target-rootfs |
| 244 | + |
| 245 | +FROM scratch AS root |
| 246 | +COPY --from=builder /target-rootfs/ / |
| 247 | +COPY --from=custom-build /src/my-binary /usr/bin/my-binary |
| 248 | + |
| 249 | +RUN passwd -d root |
| 250 | +RUN sed -i 's|"/opt/cni/bin"|"/var/lib/cni/bin"|g' /etc/crio/crio.conf && \ |
| 251 | + sed -i 's|"/opt/cni/net.d"|"/etc/cni/net.d"|g' /etc/crio/crio.conf |
| 252 | +RUN bootc container lint |
| 253 | + |
| 254 | +LABEL containers.bootc 1 |
| 255 | +LABEL ostree.bootable 1 |
| 256 | +STOPSIGNAL SIGRTMIN+3 |
| 257 | +CMD ["/sbin/init"] |
| 258 | +``` |
| 259 | + |
| 260 | +Build and test with the same `make` commands as Scenario A. |
| 261 | + |
| 262 | +### Composefs Variant |
| 263 | + |
| 264 | +To build the disk image with the composefs backend: |
| 265 | + |
| 266 | +```bash |
| 267 | +make build-disk-image-composefs \ |
| 268 | + BOOTC_IMAGE=localhost/custom-node:latest \ |
| 269 | + NODE_IMAGE=localhost/custom-node:disk-composefs |
| 270 | +``` |
| 271 | + |
| 272 | +## Testing with Multiple Nodes |
| 273 | + |
| 274 | +Use two nodes to compare stock vs. custom behavior: |
| 275 | + |
| 276 | +```bash |
| 277 | +# Start a cluster with the stock image |
| 278 | +bink cluster start --cluster-name test |
| 279 | + |
| 280 | +# Add a worker with the custom disk image |
| 281 | +bink node add node2 --cluster-name test \ |
| 282 | + --node-image localhost/custom-node:disk |
| 283 | + |
| 284 | +# SSH into each to compare |
| 285 | +bink node ssh node1 --cluster-name test |
| 286 | +bink node ssh node2 --cluster-name test |
| 287 | +``` |
| 288 | + |
| 289 | +Alternatively, switch a single worker at runtime: |
| 290 | + |
| 291 | +```bash |
| 292 | +bink node add node2 --cluster-name test |
| 293 | + |
| 294 | +# SSH into node2 and switch to the custom image |
| 295 | +bink node ssh node2 --cluster-name test |
| 296 | +sudo bootc switch \ |
| 297 | + registry.cluster.local:5000/custom-node:latest |
| 298 | +sudo reboot |
| 299 | +``` |
| 300 | + |
| 301 | +## Makefile Variable Reference |
| 302 | + |
| 303 | +Variables in `node-images/fedora/Makefile`: |
| 304 | + |
| 305 | +| Variable | Default | Description | |
| 306 | +|----------|---------|-------------| |
| 307 | +| `KUBE_MINOR` | `1.35` | Kubernetes minor version | |
| 308 | +| `FEDORA_VERSION` | `44` | Fedora base version | |
| 309 | +| `DISK_SIZE` | `10G` | VM disk size | |
| 310 | +| `BUILD_MEMORY` | `4G` | Memory for bcvk build | |
| 311 | +| `BOOTC_IMAGE` | `ghcr.io/bootc-dev/bink/node:v1.35-fedora-44` | Bootc OCI image name | |
| 312 | +| `NODE_IMAGE` | `ghcr.io/bootc-dev/bink/node:v1.35-fedora-44-disk` | Disk image name | |
| 313 | +| `BCVK_EXTRA_ARGS` | (none) | Extra flags for `bcvk to-disk` | |
| 314 | + |
| 315 | +## Troubleshooting |
| 316 | + |
| 317 | +### KVM Permission Errors |
| 318 | + |
| 319 | +``` |
| 320 | +qemu-system-x86_64: Could not access KVM kernel module: Permission denied |
| 321 | +``` |
| 322 | + |
| 323 | +Add your user to the `kvm` group or set permissions: `sudo chmod 666 /dev/kvm`. |
| 324 | + |
| 325 | +### `bootc container lint` Failures |
| 326 | + |
| 327 | +The Containerfile runs `bootc container lint` at the end. Custom modifications must preserve: |
| 328 | +- `/sbin/init` as the entrypoint (`CMD ["/sbin/init"]`) |
| 329 | +- Labels: `containers.bootc 1` and `ostree.bootable 1` |
| 330 | + |
| 331 | +### Disk Space |
| 332 | + |
| 333 | +The qcow2 build via bcvk needs approximately 15 GB of temporary space. Free disk space or reduce the disk size with `DISK_SIZE=8G`. |
| 334 | + |
| 335 | +### COPR Repo Not Found |
| 336 | + |
| 337 | +Verify the Fedora version and architecture string matches the base image. For `quay.io/fedora/fedora-bootc:44`, use `fedora-44-x86_64`. |
0 commit comments