BuildKit-on-Ceph Migration Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development for the 6-repo fan-out (Phase 4). Steps use checkbox (
- [ ]) syntax.
Goal: Replace kaniko in-cluster image builds with a single rootless buildkitd Deployment whose state dir (--root) lives on a Ceph RBD PVC, moving all build IO off the control-plane etcd NVMe (/dev/nvme0n1p2) that kaniko's containerd-overlay writes were starving (root cause of the recurring node-flap / apiserver-crashloop CI outages).
Architecture: One buildkitd Deployment (replicas 1, Recreate) in arc-runners, state on a 100Gi rook-ceph PVC, exposed via ClusterIP Service buildkitd:1234 (gRPC), ingress-locked by NetworkPolicy to ARC runner pods only. Each repo's CI image job runs buildctl --addr tcp://buildkitd.arc-runners:1234 build with a remote git context (buildkitd clones the private repo, like kaniko did) and pushes to harbor using per-build creds forwarded from the runner's DOCKER_CONFIG (no standing cluster push secret). Migration is staged and gated by a pilot.
Tech Stack: Kubernetes 1.33, rook-ceph (RBD), moby/buildkit v0.18.2-rootless, buildctl client, GitHub Actions (ARC runners), GitLab helmfile (infra deploy).
Verified facts (checked against live cluster + repos, not assumed)¶
- Ceph OSDs are on dedicated SATA SSDs (
ata-Patriot_P210_2048GB_*), one per CP node, physically separate from/dev/nvme0n1p2(etcd + containerd). => build IO on a Ceph PVC genuinely lands off the etcd NVMe. This is the whole premise; confirmed. - CP kernel 6.1.0-34 (Debian 12) >> 5.13 => rootless buildkit native overlayfs-in-userns snapshotter works; no
/dev/fuseneeded. nas worker is 6.12. - ARC runner pods carry
app.kubernetes.io/component=runner=> NetworkPolicy ingress selector. - No default-deny egress in
arc-runners(no netpols at all) => ingress-only policy is correct; buildkitd egress to harbor/docker.io/gcr.io/DNS stays open. - arc-runners has no PSA enforce label => privileged would be allowed (fallback), but we use rootless.
- kaniko-build.sh dispatches a Job that self-clones
git://github.com/szymonrychu/<repo>.git#<SHA>, pushes both:<SHORT_SHA>and:<VERSION>(git describe --tags --always --dirty), build-argsVERSION/COMMIT/DATE. FROM images are PUBLIC (golang:*-alpinedocker.io,gcr.io/distroless/static-debian12:nonroot) pulled anonymously; the harbor docker-config was used for push (and works for FROM-proxy too). buildctl forwarding the same harbor creds covers push; public FROMs pull anonymously, same as kaniko. - ci.yml
imagejob per repo:runs-on: <repo-name>(its own scale set),needs: [secscan, lint, test, build, smoke],if: github.event_name == 'push', envGITHUB_TOKEN/HARBOR_USERNAME/HARBOR_PASSWORD, stepscheckout(fetch-depth:0)+azure/setup-kubectl+bash .github/ci/kaniko-build.sh. ONLY this job changes. - Repos that build images (the "6"): tatara-operator, tatara-cli, tatara-memory, tatara-memory-repo-ingester, tatara-claude-code-wrapper, tatara-chat.
- Dispatcher RBAC (
tatara-ci-dispatcher): batch/jobs CRUD + pods/pods.log get + secrets create/delete. Reduced torules: []only at the END (Phase 5), after all 6 repos are off kaniko (the SA is pinned byserviceAccountNamein arc-runner values, so it stays; only its Role empties). - Live incident state at plan time: 5vv07x2 cordoned (=>
rook-ceph-osd-2Pending, Ceph degraded ⅔), maxRunners=1 on all 7 scale sets. Both restored in Phase 5.
Decisions locked¶
- Raw manifests under the existing
arc-runner-szymonrychurelease, NOT a new helmfile release (no chart for one Deployment; matches thetatara-ci-dispatcher/limitrangeprecedent; rides the existing presync, zero.sub.gitlab-ci.ymlchange). - Rootless buildkit (not privileged): appropriate co-located with etcd; kernel supports it. Privileged non-rootless is the documented fallback if the pilot shows a rootlesskit netns/socket failure.
- buildkitd on control-plane (mirrors runner placement). The proven killer was IO (now on Ceph); CPU/mem of compile is a NEW watch-item measured during the pilot. If apiserver flaps during a build despite IO-on-Ceph, move buildkitd to the nas worker (Ceph PVC mounts from any node).
- Remote git context (buildkitd clones), not local-context streaming: minimal delta from kaniko, keeps the runner checkout only for
git describe. - Per-build harbor push auth from the runner (
DOCKER_CONFIGforwarded by buildctl), not a daemon-mounted standing secret: strictly better hygiene than kaniko's in-cluster transient secret. - GC cap in the daemon config from day one (no deferred cleanup): keepBytes 60 GiB on the 100Gi PVC.
Phase 1: Author + review infra manifests (infra repo, GitLab)¶
Branch feat/buildkitd-on-ceph off fresh main in ~/Documents/infra/helmfile.
Files to ADD¶
helmfiles/coding/values/arc-runner-szymonrychu/raw/buildkitd-pvc.common.pre.yaml
# State dir for rootless buildkitd: all build layers/snapshots/cache live here
# (--root), on Ceph RBD (Patriot SATA SSDs), OFF the control-plane etcd NVMe
# (/dev/nvme0n1p2) that kaniko's containerd-overlay writes were starving.
# RWO single-attach: buildkitd is replicas:1 + Recreate, so RWO is correct and
# avoids RBD multi-attach errors.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: buildkitd-state
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rook-ceph
resources:
requests:
storage: 100Gi
helmfiles/coding/values/arc-runner-szymonrychu/raw/buildkitd.common.pre.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: buildkitd
---
apiVersion: v1
kind: ConfigMap
metadata:
name: buildkitd-config
data:
buildkitd.toml: |
# Cap the local cache so the Ceph PVC (--root) never fills and wedges every
# build. buildkit auto-prunes the oldest beyond keepBytes.
[worker.oci]
gc = true
[[worker.oci.gcpolicy]]
keepBytes = 64424509440
keepDuration = 604800
---
# Rootless buildkit, no API access. The only standing cred it holds is regcred
# (for its OWN image pull); harbor push + private git clone are per-build creds
# passed by the runner over gRPC. No Role/RoleBinding needed.
apiVersion: apps/v1
kind: Deployment
metadata:
name: buildkitd
labels:
app: buildkitd
spec:
replicas: 1
# RWO PVC: never two pods at once. Recreate tears the old pod down before the
# new one binds the volume (RollingUpdate would deadlock on RBD single-attach).
strategy:
type: Recreate
selector:
matchLabels:
app: buildkitd
template:
metadata:
labels:
app: buildkitd
spec:
serviceAccountName: buildkitd
# Fresh RBD ext4 PVC mounts root-owned; rootless buildkit runs as 1000.
# fsGroup chowns the volume so --root is writable (same lesson as the
# runner fsGroup:1001 fix). Omit and buildkitd crashloops on first write.
securityContext:
fsGroup: 1000
imagePullSecrets:
- name: regcred
# Mirror the runners: build infra on control-plane. The disk is the PVC
# (Ceph), not the node; node choice is only scheduling locality.
nodeSelector:
node-role.kubernetes.io/control-plane: ""
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: buildkitd
# renovate: image=moby/buildkit
image: harbor.szymonrichert.pl/proxy-dockerhub/moby/buildkit:v0.18.2-rootless
args:
- --addr
- unix:///run/user/1000/buildkit/buildkitd.sock
- --addr
- tcp://0.0.0.0:1234
- --root
- /home/user/.local/share/buildkit
- --config
- /home/user/.config/buildkit/buildkitd.toml
- --oci-worker-no-process-sandbox
securityContext:
runAsUser: 1000
runAsGroup: 1000
# Rootless needs the relaxed profiles for the clone()/mount() the
# userns worker issues. k8s 1.33 uses the field, not the beta
# annotation.
seccompProfile:
type: Unconfined
appArmorProfile:
type: Unconfined
ports:
- name: grpc
containerPort: 1234
readinessProbe:
exec:
command: ["buildctl", "--addr", "tcp://localhost:1234", "debug", "workers"]
initialDelaySeconds: 5
periodSeconds: 30
livenessProbe:
exec:
command: ["buildctl", "--addr", "tcp://localhost:1234", "debug", "workers"]
initialDelaySeconds: 15
periodSeconds: 60
resources:
requests:
cpu: 500m
memory: 1Gi
# Tiny: real build IO is on the PVC, not the pod overlay. This cap
# is the etcd-NVMe protection, proven by builds staying under it.
ephemeral-storage: 1Gi
limits:
cpu: "4"
memory: 8Gi
ephemeral-storage: 2Gi
volumeMounts:
- name: state
mountPath: /home/user/.local/share/buildkit
- name: config
mountPath: /home/user/.config/buildkit
volumes:
- name: state
persistentVolumeClaim:
claimName: buildkitd-state
- name: config
configMap:
name: buildkitd-config
---
apiVersion: v1
kind: Service
metadata:
name: buildkitd
spec:
selector:
app: buildkitd
ports:
- name: grpc
port: 1234
targetPort: grpc
helmfiles/coding/values/arc-runner-szymonrychu/raw/buildkitd-netpol.common.pre.yaml
# Lock buildkitd's gRPC port to in-namespace ARC runner pods only. Egress stays
# open (buildkitd must reach harbor for push + DNS, and docker.io/gcr.io for
# public FROM pulls); ingress IS the security boundary. No cluster-wide
# default-deny egress exists in arc-runners (verified), so no egress block is
# needed; if one is added later, allow 443+53 egress here.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: buildkitd
spec:
podSelector:
matchLabels:
app: buildkitd
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/component: runner
ports:
- protocol: TCP
port: 1234
Step gate: mandatory opus review BEFORE the MR¶
- Dispatch an opus reviewer (
superpowers:requesting-code-review) on the 3 new manifests with the explicit charge from the user authorization: flag every potential problem; confirm zero impact on other namespaces or cluster state. Specifically: does adding an ingress NetworkPolicy selecting onlyapp: buildkitdleave runner pods and all other arc-runners pods unaffected? Does the rootless pod spec schedule and run on a CP node without privilege? Is the PVC RWO + Recreate safe? Any way this disrupts the in-flight kaniko builds (it must not — Phase 1 is additive)? - Fix all critical/high findings. Only proceed to the MR if the reviewer confirms no cross-namespace / cluster-state impact.
Step: open the infra MR (additive, kaniko untouched)¶
-
git checkout -b feat/buildkitd-on-ceph, add the 3 files, commitfeat: rootless buildkitd on ceph for tatara CI builds, push, open MR viaglab. - Pipeline runs
helmfile diff(ACTION=diff) for review. Confirm the diff adds ONLY the 4 objects inarc-runners(PVC, SA+ConfigMap+Deployment+Service, NetworkPolicy) and changes nothing else. - Merge => pipeline
helmfile applyon the nas gitlab-runner.
Step: verify buildkitd is up¶
-
kubectl -n arc-runners get pvc buildkitd-state=> Bound, rook-ceph. -
kubectl -n arc-runners rollout status deploy/buildkitd. -
kubectl -n arc-runners exec deploy/buildkitd -- buildctl --addr tcp://localhost:1234 debug workers=> lists an oci worker. -
kubectl -n arc-runners exec deploy/buildkitd -- df -h /home/user/.local/share/buildkit=> the mount is the RBD PVC (~100Gi), NOT the pod overlay.
If rootless fails to start (rootlesskit netns/socket error in logs), apply the fallback: non-rootless moby/buildkit:v0.18.2 image, drop -rootless + --oci-worker-no-process-sandbox + seccomp/apparmor Unconfined, add securityContext.privileged: true, mount at /var/lib/buildkit with --root /var/lib/buildkit. Re-review, re-apply.
Phase 2: Pilot build script + ci.yml (tatara-cli only)¶
Branch off fresh main in ~/Documents/tatara/tatara-cli (bots push here; always pull).
File to ADD: .github/ci/build.sh¶
#!/usr/bin/env bash
# Build this repo's image via the shared rootless buildkitd daemon and push to
# harbor. Runs on the ARC runner (in-cluster, namespace arc-runners). Talks gRPC
# to the buildkitd Service; buildkitd writes all layers/cache to its Ceph PVC
# (--root), OFF the control-plane etcd NVMe. No in-cluster Job, no transient
# cluster secrets: harbor push auth is a per-build docker config on THIS runner,
# the private-repo clone token is a buildkit frontend secret. Replaces
# kaniko-build.sh.
set -euo pipefail
REPO="${1:?repo name required}"
BUILDKITD_ADDR="tcp://buildkitd.arc-runners:1234"
SHORT_SHA="${GITHUB_SHA:0:7}"
VERSION="$(git describe --tags --always --dirty)"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
DEST="harbor.szymonrichert.pl/containers/${REPO}"
: "${GITHUB_TOKEN:?GITHUB_TOKEN required}"
: "${HARBOR_USERNAME:?HARBOR_USERNAME required}"
: "${HARBOR_PASSWORD:?HARBOR_PASSWORD required}"
# Per-build docker config on the runner only (never an in-cluster secret).
# buildctl reads $DOCKER_CONFIG and forwards harbor auth to buildkitd for push.
DOCKER_CONFIG="$(mktemp -d)"
export DOCKER_CONFIG
trap 'rm -rf "$DOCKER_CONFIG"' EXIT
auth="$(printf '%s:%s' "$HARBOR_USERNAME" "$HARBOR_PASSWORD" | base64 -w0)"
cat >"${DOCKER_CONFIG}/config.json" <<EOF
{"auths":{"harbor.szymonrichert.pl":{"auth":"${auth}"}}}
EOF
# Remote git context (buildkitd clones the private repo, like kaniko did).
# MUST be https:// (NOT git://): buildkit's GIT_AUTH_TOKEN basic-auth extraheader
# only engages over https, and github.com no longer serves the git:// protocol.
# GIT_AUTH_TOKEN is the buildkit git-source frontend secret for the private
# clone; it is NOT a build-arg, so it never lands in a layer.
buildctl --addr "$BUILDKITD_ADDR" build \
--frontend dockerfile.v0 \
--opt context="https://github.com/szymonrychu/${REPO}.git#${GITHUB_SHA}" \
--opt filename=Dockerfile \
--opt build-arg:VERSION="${VERSION}" \
--opt build-arg:COMMIT="${SHORT_SHA}" \
--opt build-arg:DATE="${BUILD_DATE}" \
--secret id=GIT_AUTH_TOKEN,env=GITHUB_TOKEN \
--import-cache type=registry,ref="${DEST}:buildcache" \
--export-cache type=registry,ref="${DEST}:buildcache,mode=max" \
--output "type=image,\"name=${DEST}:${SHORT_SHA},${DEST}:${VERSION}\",push=true"
echo "buildkit: pushed ${DEST}:${SHORT_SHA} and ${DEST}:${VERSION}"
Notes: - --import-cache/--export-cache type=registry ...:buildcache,mode=max gives cross-build layer reuse via harbor (kaniko had none). A separate :buildcache tag. First build has no cache to import (warning, not error) — expected. - Multi-tag push via the quoted "name=A,B" form (kaniko used two --destination). - No --opt platform: defaults to daemon-native amd64, matching kaniko. Multi-arch later. - Pilot contingency: the git-source secret id is GIT_AUTH_TOKEN; if this buildkit version expects header form, the private clone fails — then also pass --secret id=GIT_AUTH_HEADER (Authorization: Basic base64(x-access-token:TOKEN)). Resolve on the pilot before fan-out and bake the working form into the shared script.
File to DELETE: .github/ci/kaniko-build.sh¶
File to EDIT: .github/workflows/ci.yml — image job only¶
Replace the image job's steps (keep if, runs-on: tatara-cli, needs, env):
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: install buildctl
run: |
curl -sSfL "https://github.com/moby/buildkit/releases/download/v0.18.2/buildkit-v0.18.2.linux-amd64.tar.gz" \
| sudo tar -xz -C /usr/local bin/buildctl
- name: build and push image
run: bash .github/ci/build.sh "${{ github.event.repository.name }}"
(azure/setup-kubectl removed; only the static buildctl client is needed.)
- Commit on a branch, open PR, then merge to main (image job runs on push).
Phase 3: Verify the etcd-NVMe fix on the pilot (authoritative, 3 corroborating signals)¶
Per verify-fix-via-downstream-not-draining-pods: measure during a real compile, not the transition window; require multiple signals.
- No kaniko Job created:
kubectl -n arc-runners get jobs -wduring the build => none. (Proves the new path ran.) - CP etcd NVMe stays flat: on the node running buildkitd,
iostat -x 2(viakubectl debug node/<cp> --image=nicolaka/netshoot) — thenvme0n1%utilmust NOT spike across the compile window (the heaviest write phase). - Ceph took the IO instead: buildkitd PVC grows (
exec deploy/buildkitd -- du -sh /home/user/.local/share/buildkit) AND the rbd pool shows write throughput (ceph osd pool statsvia the rook tools pod if present). - apiserver healthy through the build:
kubectl get pods -n kube-system | grep apiserverrestart count does NOT increase during the build; no newTaintManagerEviction. - Image pushed, both tags:
skopeo inspect docker://${DEST}:${SHORT_SHA}and:${VERSION}resolve; the downstream chart job (pulls the image) passes.
Pass = all five. Fail on rootless/auth => fix per the Phase ½ contingencies, rebuild, re-verify before fan-out.
Phase 4: Fan out to the other 5 repos (parallel subagents)¶
tatara-cli proven, apply the identical change to tatara-operator, tatara-memory, tatara-memory-repo-ingester, tatara-claude-code-wrapper, tatara-chat. The build.sh is byte-identical (takes the repo name as $1); the ci.yml edit is identical except the job keeps each repo's own runs-on/needs. Dispatch one subagent per repo in a single message (sonnet, per tatara rule 7). Each: fresh main, add build.sh, delete kaniko-build.sh, edit the image job, PR, merge.
Stagger the merges (do NOT merge all 5 at once): the memory lesson is mass-merging spikes builds. Merge sequentially, confirm each repo's first post-migration build pushes to harbor before the next. With builds now on Ceph this is belt-and-suspenders, but the node is still recovering.
- Per repo: first push build green + image in harbor.
Phase 5: Retire kaniko + restore normal posture¶
- Second infra MR: edit
tatara-ci-dispatcher.common.pre.yamlRole torules: [](keep SA + RoleBinding; values pinserviceAccountName: tatara-ci-dispatcher). Update the file comment. This shrinks attack surface (a build can no longer create Jobs/secrets next to the ARC GitHub App key). ONLY after all 6 repos confirmed off kaniko. - Delete the unused
kaniko-executorimage reference if nothing else uses it (note, don't block). - Restore parallelism:
helmfile-side maxRunners back to 2 (the live patch is 1); uncordon 5vv07x2 (kubectl uncordon kubernetes-5vv07x2) =>rook-ceph-osd-2schedules, Ceph returns to 3/3. Do this only after the build-IO fix is proven, so restored parallelism no longer re-triggers the flap. - Abandon the 5 stale
fix/kaniko-dir-cephbranches (operator/wrapper/memory/ chat/ingester) — dead-end superseded by this migration. - MEMORY: record buildkit-on-ceph done, the GC cap, the RWO single-replica throughput ceiling + SPOF blast radius, the Phase-5 RBAC-reduction ordering constraint, and that Ceph OSDs are on separate SATA SSDs (the fact that makes this work).
Risks (from design, carried forward)¶
- RWO single buildkitd is a throughput ceiling + SPOF. Up to ~6-12 runners funnel through one daemon/one RBD volume; buildkit serializes + shares cache so correctness is fine, latency is the risk. A rollout/node-drain fails all in-flight builds for the restart window (CI is retriable; cache survives on the PVC). Start single, measure, only scale (horizontal, one RWO PVC each behind the Service) if queue latency hurts. Document the ceiling.
- Cache growth — mitigated by the GC cap (in from day one).
- Rootless kernel/netns — mitigated by kernel 6.1 + the privileged fallback; pilot is the gate.
- CPU/mem of compile on a CP node — new watch-item (Phase 3 apiserver check); move buildkitd to nas if it bites. Mitigated up front: cpu limit capped at 2.
Adversarial review outcome (2026-06-14, 4 lenses, 19 findings, 10 confirmed)¶
Cross-namespace / cluster-state gate (user authorization): PASS — all 4 lenses confirm the change is additive and arc-runners-scoped; no cluster-scoped object, no RBAC, no networking/CNI/etcd change, no name collision, the ingress-only netpol selects only app: buildkitd so other arc-runners pods are unaffected. Sole residual cluster-wide vector is CP compute contention (now capped).
Applied before MR: - CRITICAL git context git:// -> https:// (GIT_AUTH_TOKEN only authenticates over https; github.com dropped git://). Build would never clone the private repo otherwise. - HIGH buildctl install: dropped --strip-components=1 (wrote the wrong path). - MEDIUM buildkitd cpu limit 4 -> 2 (a single compile cannot starve etcd next to it). - MEDIUM/sec automountServiceAccountToken: false on the buildkitd pod (zero API access needed; neutralizes apiserver-reachability).
Documented, NOT blocking v1: - buildkitd gRPC is unauthenticated; the netpol (ingress from component=runner only) is the authz boundary. CONSTRAINT: never apply the app.kubernetes.io/component=runner label to a non-runner workload in arc-runners. Future hardening: mutual-TLS on the daemon (--tlscacert/--tlscert/--tlskey + buildctl client certs).
Rejected (reviewer error): "deny the .25 VIP in buildkitd egress" — harbor.szymonrichert.pl IS the .25 keepalived VIP, so buildkitd MUST egress to .25:443 to push/pull. Denying it would break every build.