Lessons learned (transferable)
Non-obvious, technology-agnostic findings gathered while building and operating buildkit-operator on OVH Managed Kubernetes. Nothing platform-specific here — just the gotchas that bit us in Kata, Kubernetes/Helm, BuildKit, OVH Object Storage, and the release toolchain, and how to avoid them.
Kata Containers
- Deleting the
kata-deployDaemonSet tears the node down — even with nopreStophook. kata-deploy trapsSIGTERMin its PID 1 and runs the node cleanup on pod termination (removes/opt/kata, reverts the containerd config, drops thekatacontainers.io/kata-runtimenode label, restarts containerd). This is not a Kuberneteslifecycle.preStophook, so inspecting.spec.template.spec.containers[].lifecycleshowsNONEand lulls you into thinking akubectl delete dsis harmless. It is not — it fully de-configures Kata on the node. - Moving kata-deploy between namespaces = a full teardown + reinstall (two containerd reconfigures), precisely because of the SIGTERM cleanup above. Plan it like a node reconfigure, in a maintenance window, scoped to a dedicated nodepool.
- Uninstall cleanly; don't orphan the release. kata-deploy creates cluster-scoped objects
(a
ClusterRole,ClusterRoleBinding, aServiceAccount, and ~24RuntimeClasses — clh/qemu/fc variants). If you delete the Helm release secrets first, those resources are orphaned and the nexthelm installfails withinvalid ownership metadata ... release-namespace must equal X. Eitherhelm uninstallproperly, or adopt the resources (patchmeta.helm.sh/release-*+app.kubernetes.io/managed-by: Helm). - RuntimeClasses are cluster-scoped and outlive the node config. The node-level cleanup (label,
/opt/kata, containerd) is independent of theRuntimeClassobjects — they can persist while the node is de-configured, which is misleading when checking "is Kata still set up?". - A containerd restart does NOT kill running containers. Only the CRI control-plane blips for a few seconds. So co-located workloads (other build daemons, anything on the node) survive a kata-deploy install/reconfigure — verify after, but don't expect an outage.
- Under nested virtualization, use cloud-hypervisor (
kata-clh), not qemu. qemu boots too slowly; the kata-agent misses containerd's CRIget statedeadline (context deadline exceeded) and the kubelet kills the sandbox. And give the guest ≥ 4 vCPUs — with the default 1 vCPU the agent is still too slow and the VM restart-loops. Kata reads its config per-sandbox, so bumpingdefault_vcpusneeds no containerd restart (a sidecar/DaemonSet thatseds the clh config is enough). - Smoke-test the runtime in one command: a pod with
runtimeClassName: kata-clhrunninguname -rshows the guest kernel (e.g. 6.18) which differs from the host kernel (e.g. 5.15) — proof the workload is in a real microVM, not the host kernel. - kata-deploy's Helm chart has a node-feature-discovery dependency. Disable it
(
node-feature-discovery.enabled=false) and vendor the NFD chart.tgzincharts/so you don't need network access to the NFD Helm repo at install time.
Kubernetes & Helm
helm upgrade --reuse-valuesdoes not merge newly-added values keys. It reuses only the last release's computed values; any value key you added to the chart since is absent. Templates that reference it must be nil-safe, and on Helm 3.14+--reset-then-reuse-valuesmerges new chart defaults.- Nil-safe Helm helpers: use nested
with..Values.a.bnil-pointers when.Values.ais absent.and .Values.a .Values.a.bdoes not help — Go templates'andis not short-circuit (it evaluates every argument).dig "a" "b" def .Valuesfails too —digrejects Helm'scommon.Valuestype (interface conversion: interface {} is common.Values, not map[string]interface {}). What works:{{- $v := "default" -}} {{- with .Values.a }}{{- with .b }}{{- $v = . }}{{- end }}{{- end -}} {{- $v -}} - Don't render a
Namespaceobject for the Helm release namespace.helm install --create-namespacealready creates it; a chart-rendered Namespace for the same name collides on adoption (invalid ownership metadata). Render only the extra namespaces. - Changing a resource's namespace across an upgrade = delete-old + create-new. Namespace is part of a resource's identity, so Helm removes it from the old namespace and creates it in the new one (fine, but expect the old one to disappear).
- A controller's leader-election Lease should live in the namespace it RUNS in, not the namespace it
manages. Decouple them by feeding the pod's own namespace via the downward API:
and use
env: [{ name: POD_NAMESPACE, valueFrom: { fieldRef: { fieldPath: metadata.namespace } } }]POD_NAMESPACEforLeaderElectionNamespace, while the--namespaceflag points at the managed namespace. (controller-runtime watches all namespaces by default; cross-namespace owner references are invalid, so co-locate a CR with the objects it owns.) - Kyverno
excludedNamespacesin per-cluster values usually REPLACES, not merges. Layered Helm value files override list-typed values wholesale — so a per-cluster exclude list must repeat the base entries, and a cluster-wide addition goes in the common values to avoid dropping the others. - A mutate policy that sets
allowPrivilegeEscalation: falsemakes aprivileged: truecontainer invalid (the API rejects the combination). So a privileged node agent (hostPath installer) needs the securityContext exemption too, not only the hostPath one — exempt it likekube-systemis. - Argo "OutOfSync + Missing" + no
argocd.argoproj.io/tracking-idannotation on a resource means the resource isn't actually being reconciled by that app — manual drift will persist. Don't assume a merged GitOps change is live; verify the live object. -
Sealed Secrets without writing cleartext to disk:
kubectl create secret generic NAME -n NS --from-literal=K="$V" --dry-run=client -o yaml \ | kubeseal --cert <controller-cert-url> -o yaml --scope cluster-wide > NAME.sealedsecret.yaml--scope cluster-widelets the SealedSecret be unsealed in any namespace; the in-cluster controller decrypts it into a normalSecret. To copy a Secret between namespaces without printing its values, pipekubectl get -o json | <edit metadata.namespace> | kubectl apply -f -(values stay in the pipe). -
A controller-runtime client
Getright after its ownCreatecan returnNotFound. The default client reads from the informer cache, which lags etcd by a beat — so a "create then immediately Get-modify-Update" sequence intermittently drops the update (andRetryOnConflictwon't save you: it only retries onConflict, notNotFound). Two fixes, use both: mutate the object returned byCreate(it already carries its ResourceVersion — no Get needed), and make follow-up touch loops retry onNotFoundandConflict. This bit us as a cold-start flake: a freshly-created BuildProject's warm-upStatusstamp was dropped, so the daemon silently never scaled up. -
A controller that reaps idle ephemeral children can reap one in its own birth window. If "create the child, then mark it active (status/owner stamp) a beat later" is split across two API calls, the controller's informer can fire on the freshly-created child before the active mark lands — see it as idle (replicas 0) and delete it, so the work it was created for never runs. Here untrusted fork daemons were reaped microseconds after creation and every untrusted build hung. Guard the reaper with a birth-window grace keyed on
CreationTimestamp(don't reap a child younger than N), and requeue-after so it's still reaped once genuinely idle.
BuildKit
- Cache import/export is best-effort — a broken cache backend does NOT fail the build. BuildKit logs
a warning and builds from scratch. So "the build went green" does not prove the cache worked. To
verify, grep the build log for the real evidence:
importing cache manifest from s3:<key> exporting cache to Amazon S3 ... sending cache export 2.2s done - The S3/registry cache uses the daemon's AWS credential chain (
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYas env on the buildkitd container). Clients carry no cache credentials — the daemon does the object-store I/O. Mount the creds withenvFrom: [{ secretRef: ... }]. - Two cache layers, different lifetimes: the hot local layer +
RUN --mount=type=cachestore (a retained PVC per daemon) vs. the cold remote cache (S3/registry). A daemon shares layers across daemons via the remote cache;cachemounts stay per-daemon. See storage-and-cold-cache.md.
OVH (Object Storage & MKS)
- S3-compatible Object Storage endpoint (GRA):
https://s3.gra.io.cloud.ovh.net, regiongra. Create an S3-type container (not Swift) and generate S3 user credentials (accessKey/secretKey) — that JSON is just the user creds; the bucket name + endpoint are configured separately. - Storage class for a cache: prefer 1-AZ same-region over 3-AZ. A build cache is regenerable, so paying for 3-AZ durability is wasted — 1-AZ gives lower cost and same-region latency, and an AZ outage just degrades to cold rebuilds (BuildKit's best-effort cache), not data loss. Pick 3-AZ only if availability of the cache during an AZ incident is a real requirement. Local-zone storage only helps if the cluster sits in that local zone.
- MKS b2 nodes expose nested virtualization (
/dev/kvm, CPUvmx), so Kata microVMs run — with the clh + ≥4-vCPU caveats above. The guest kernel differs from the host kernel.
Networking, DNS & egress-proxy CI
- external-dns
servicesource needs node RBAC — adding the arg alone crash-loops it. The Helm chart derives the ClusterRole fromsources, so enabling--source=servicevia the chart also grantsnodes(+services/endpoints/pods)list,watch. If you flip the arg with a live patch (or any path that doesn't re-render RBAC), external-dns dies withfailed to sync *v1.Node: context deadline exceededand all DNS reconciliation stops (shared component!). Patch the ClusterRole in the same change, or sync via the chart — never the arg alone. - external-dns + Azure DNS + a wildcard record = the TXT registry record is rejected. A wildcard
*.fooA record is fine, but the ownership TXT becomesexternal-dns.*.foo, which Azure rejects (record set relative name '...' is invalid, HTTP 400) — every reconcile re-errors. The A record still resolves (it's re-upserted;policy=synconly deletes records it owns via TXT, and an untracked one is left alone), so it's benign-but-noisy. The proper fix is--txt-wildcard-replacement(substitutes*in TXT names) — but it's a global flag affecting every wildcard that instance manages, so coordinate it platform-wide rather than patching it in for one record. - A blocking control-plane call dies against a CONNECT-proxy's idle timeout. Egress-proxy-only CI
(e.g. an HTTP
CONNECTproxy on 443) caps how long an idle tunnel stays open (~50s observed). A control endpoint that blocks server-side (here/routewaits for a cold daemon, ~1–2 min) is dropped mid-wait withOpenSSL SSL_read: ... unexpected eof(curl 56) / a timeout (curl 28). Fix on the client: poll in bounded attempts (each--max-time< the proxy's tunnel timeout) until ready, instead of one long request. Keep the single blocking call only for direct (non-proxied) clients. - nginx Ingress
proxy-read-timeoutdefaults to 60s — too low for an endpoint that legitimately blocks (cold-start). Raise it (nginx.ingress.kubernetes.io/proxy-read-timeout: "300") — but note this only covers the Ingress hop; an upstream CONNECT proxy has its own, separate timeout (above). - GitLab
include: remote:is fetched by the GitLab server, behind an allow-list. On a locked-down instance the server denies it (Remote file could not be fetched because URL is blocked: ... not on the Allow List) and the pipeline fails with 0 jobs and no YAML error — confirm withPOST /projects/:id/ci/lint. Deliver a reusable CI brick to such platforms as a vendored/localinclude or a CI/CD Catalog component (mirror the repo into that GitLab), not a GitHub remote include. Runtime fetches (the runner pulling a script) ride the runner's proxy instead — a different egress path with different rules; vendor those too if the runner's proxy doesn't allow the source host.
Release toolchain
- conventional-changelog bumps a
feat!/breaking commit to MAJOR even pre-1.0 (→1.0.0). For a 0.x project, semver treats breaking changes as a minor bump — force it (release-it --increment minor) unless you actually intend 1.0. - golangci-lint must be built with a Go ≥ the
godirective ingo.mod. A prebuilt binary built with an older Go refuses: "the Go language version (goX) used to build golangci-lint is lower than the targeted Go version". Run it from source so it compiles with the project toolchain:go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@vX run ./.... - OCI references must be lowercase. A mixed-case repo owner (e.g.
MyOrg) breakscosign sign("could not parse reference"); lowercase it in-shell:cosign sign "ghcr.io/${OWNER,,}/img@${DIGEST}". - Keyless cosign needs
id-token: writein the workflow permissions, and signs by digest (steps.build.outputs.digest) — one signature covers all tags on that digest.