Troubleshooting¶
A field guide to what can go wrong, what it looks like, and how to fix it. The failure-mode summary is from SPECIFICATION §14.
Failure modes at a glance¶
| Scenario | Behavior you'll see | Where to look |
|---|---|---|
| No compatible JDK on any ready node | Pod stays Pending; event NoCompatibleJDK; scheduler skips nodes |
→ JDK issues |
| Requested launcher not installed | Pod stays Pending; event NoCompatibleLauncher; scheduler skips nodes |
→ launcher issues |
| OCI artifact missing/unauthorized | ImagePull-style failure on the pod |
→ artifact pull |
| JavaApplication replacement rollout is incomplete | Ready=False/Progressing even while old replicas remain ready |
→ rollout status |
| JVM OOM | ExitOnOutOfMemoryError → exit → kubelet restart |
→ OOM |
| Node provisioning fails | Node not labeled ready; condition/event ProvisionFailed |
→ provisioning |
| No provisioner pod on a node at all | Node absent from status.assignedNodes; no DaemonSet pod scheduled |
→ placement |
| NodeProfile is invalid | Ready=False/InvalidProfile; deletion with possible host state remains CleanupBlocked |
→ source policy |
| NodeProfile deletion does not finish | Profile stays with Ready=False/CleanupPending, CleanupTeardown, or CleanupBlocked |
→ deletion |
| Reinstall after Helm uninstall is refused | Brewlet CRDs already exist; install is fresh-install-only |
→ retained CRD review and recovery |
| Shim crash | containerd reports task failure; pod restarts | → shim |
| cgroup v1-only node | Provisioner refuses; node not marked ready | → provisioning |
| containerd 1.x node | Provisioner refuses; node not marked ready | → provisioning |
JavaApplication is not Ready during a rollout¶
Healthy old replicas can keep serving while a replacement image fails to start.
The JavaApplication remains Ready=False/Progressing until the current
Deployment generation reaches its desired updated, ready, and available counts
and extra active replicas are gone.
kubectl get javaapplication <app> -n <namespace> -o yaml
kubectl get deployment <app> -n <namespace> -o yaml
kubectl rollout status deployment/<app> -n <namespace> --timeout=120s
kubectl get pods -n <namespace> -l app=<app>
Compare Deployment metadata.generation with status.observedGeneration, then
inspect updatedReplicas, readyReplicas, availableReplicas, and the
Progressing condition. An Available=True Deployment or a high
JavaApplication readyReplicas count alone does not prove that the replacement
rollout succeeded. Check failing new Pods for image-pull, scheduling, probe, or
startup errors. Availability may also wait for minReadySeconds.
When HPA is enabled, compare with the Deployment's live spec.replicas.
For automation, also require the JavaApplication Ready condition's
observedGeneration to match its current generation. See
rollout readiness.
Node never becomes ready¶
Symptom: kubectl get nodes -L brewlet.sh/runtime shows no ready, or
brewlet.sh/provision-state=Failed.
# Is the node opted in?
kubectl get node <n> -o jsonpath='{.metadata.labels.brewlet\.sh/provision}{"\n"}'
# Provisioner pod on that node:
kubectl get pods -n brewlet -o wide | grep <n>
kubectl logs -n brewlet <provisioner-pod>
kubectl get events --field-selector reason=ProvisionFailed
# The stable reason code, then the human detail:
kubectl get node <n> -o jsonpath='{.metadata.annotations.brewlet\.sh/provision-error}{"\n"}'
kubectl get node <n> -o jsonpath='{.metadata.annotations.brewlet\.sh/provision-error-message}{"\n"}'
brewlet.sh/provision-error is a stable code you can alert on; the full
enumeration is in
SPECIFICATION §14.2.
brewlet.sh/provision-error-message is free-form operator text and is not a
contract — do not parse it.
Common causes & fixes:
- cgroup v1-only node — the provisioner refuses it (cgroup v2 is required). Move the node to a cgroup v2 kernel/config, or exclude it.
- containerd 1.x node — the provisioner reports
unsupported-containerd-version; upgrade the server to containerd 2.0 or newer. Config fileversion = 2remains supported on containerd 2 and is not the containerd server version. - Runtime source preflight failed — a required indexed entry is missing, an image is not a canonical SHA-256 digest reference, a path/name is malformed, an entry is duplicated, or a mirror is malformed/unapproved. The provisioner exits before pulling or mounting source content. See NodeProfile source-policy failures.
- JDK copy-from-image failed — the node can't reach the verified digest-pinned JDK image. Verify the containerd socket mount and image pull access, or mirror the exact digest through an approved destination. See JDK management.
- can't reach the registry / socket — verify the containerd socket mount and that the node can pull the JDK image.
- JDK or launcher validation failed —
brewlet.sh/provision-erroridentifies the failed component. Confirm each configured JDK has an executable<javaHome>/bin/javathat passesjava -version; confirm each launcher source is a regular non-symlink file and the staged copy is executable. Arbitrary launchers are not executed as readiness probes. A failure removes stale runtime and capability labels. - containerd config validation failed — in
validatedmode, Brewlet runscontainerd config dumpand requires the exactbrewlethandler before activation. Inspect the provisioner log and the effective host configuration; Brewlet restores/removes the rejected render automatically. - containerd restart or health check failed — validated mode restores the
prior primary config or drop-in, restarts containerd, and verifies recovery.
Reasons such as
restart-failed,containerd-health-check-failed, orruntime-handler-health-check-failedindicate the failed stage;rollback-failedmeans automated recovery also failed and the node needs immediate operator attention. - RBAC — the provisioner needs
get/patchon nodes to label them; confirm the ServiceAccount/ClusterRole from the manifest/chart are present.
No provisioner pod is scheduled¶
Symptom: the profile's DaemonSet exists but has no pods on the node you
expected, and kubectl get nodeprofile <p> -o jsonpath='{.status.assignedNodes}'
does not count it. This is placement, not provisioning: the provisioner never
started, so there is no brewlet.sh/provision-error to read.
# Which nodes may this profile touch?
kubectl get ds brewlet-node-provisioner-<profile> -n brewlet \
-o jsonpath='{.spec.template.spec.affinity.nodeAffinity}{"\n"}'
kubectl get ds brewlet-node-provisioner-<profile> -n brewlet \
-o jsonpath='{.spec.template.spec.tolerations}{"\n"}'
# Why was this node not eligible?
kubectl get node <n> --show-labels
kubectl get node <n> -o jsonpath='{.spec.taints}{"\n"}'
Common causes & fixes:
- It is a control-plane node. Every profile requires
node-role.kubernetes.io/control-planeandnode-role.kubernetes.io/masterto be absent, because the provisioner is privileged. This bites on kind and Docker Desktop, whose single node carries the label without the taint — so nothing at all is provisioned. Setspec.nodePool.includeControlPlane: true(Helm:provisioner.includeControlPlane=true) if you really intend it. - The node is tainted and the profile says nothing about it. Nothing is
tolerated implicitly. Add the matching entry to
spec.tolerations(Helm:provisioner.tolerations); every entry must name akey. - The node is not in the profile's pool. Compare the node's label for the
resolved pool key (
status.resolvedPoolKey) againstspec.nodePool.names. A profile whose pool matches nothing reportsReady=Falsewith reasonEmptyPool.
NodeProfile source-policy failures¶
The admission webhook validates sources and mirrors for immediate feedback, but the reconciler repeats the same checks. A stored invalid profile therefore fails closed even if admission is disabled or bypassed: its provisioner DaemonSet is withheld/deleted and only that profile's owned node advertisements are removed.
Repair an invalid profile before requesting deletion. If it is already deleting,
the operator stops its provisioner and cleanup workers, waits for them to
terminate, and withdraws its node advertisements. It does not run host cleanup
under an invalid policy. If recorded or claimed host state may remain, both
the cleanup finalizer and node ownership claims are retained, with
Ready=False/CleanupBlocked, even after all workers are gone.
Repair the profile's spec, source/mirror policy, or pool conflict to resume cleanup of its recorded targets. Normal finalization requires successful host cleanup and worker teardown. Missing ownership or cleanup-policy records must be restored; their absence is not proof that a node was cleaned.
Only an invalid profile proven to be unprovisioned may finalize without host cleanup, after fresh node and worker checks. Do not remove finalizers, ownership labels, or status records to force deletion.
kubectl get nodeprofile <name> \
-o jsonpath='{range .status.conditions[*]}{.type}{"="}{.status}{" reason="}{.reason}{" message="}{.message}{"\n"}{end}'
kubectl describe nodeprofile <name>
kubectl logs -n brewlet deploy/brewlet-operator
Correct the condition message:
- replace every tagged
spec.jdks[].source.imagewith a fully qualified, taglessrepository@sha256:<64 lowercase hex>reference; - supply
source.imageandsource.javaHomefor every JDK; - supply
name,source.image, andsource.pathfor every optional launcher; - add the exact mirror destination host, including any explicit port, to
security.allowedSourceMirrorHosts, or remove the mapping; and - remove duplicate/self mirror mappings and conflicting named-pool ownership.
To inspect a provisioner source declaration directly:
/opt/brewlet-dist/brewlet-source-policy validate-ref \
--image docker.io/library/azul-zulu@sha256:2e230d906cffcc7bb7360ce82836f2ff0e0be74a1d5ebaf929e4e6ac99d61bf2
/opt/brewlet-dist/brewlet-source-policy validate-path \
--path /usr/lib/jvm/zulu21
An air-gapped mirror must preserve the referenced OCI manifest/index bytes and digest.
NodeProfile deletion does not finish¶
A deleted NodeProfile stays until the operator has stopped its provisioner, resolved every recorded target by host cleanup or accepted external-retirement evidence, and torn down the cleanup workers. Follow progress per node:
CleanupPendingwith nodes stuck inpendingorcleaning. Check the cleanup worker Pods (kubectl get pods -n brewlet -l app=brewlet-cleanup,brewlet.sh/nodeprofile=<name> -o wide) for image-pull or scheduling problems, and make sure every target node is Ready and the operator is running.CleanupTeardown. Targets were resolved; the operator is waiting for the cleanup workers to terminate.CleanupBlocked. The profile's spec, source/mirror policy, or pool ownership is invalid, a target node is unavailable or was replaced, or a competing or unfenced worker was found. Read the condition message, then repair the cause as described in source-policy failures or blocked cleanup recovery; repairing a deleting profile resumes cleanup.
For an AKS or other managed-node replacement, Kubernetes readiness of the new
Nodes and a successful pool operation do not prove retirement of the original
hosts. Preserve the original UID ledger, verify permanent destruction of each
original instance, and follow
external-retirement recovery.
The same-name replacement is not a cleanup target for the old UID. An unreachable
original Node still requires connectivity repair, not an attestation bypass.
For a non-deleting profile, RetirementPending=True and
status.detachedRetirements retain missing-host cleanup obligations without
blocking distinct replacement provisioning. A healthy Ready condition does
not mean this history is resolved. Deletion and Helm uninstall still wait for
every obligation; inspect the detached records and retained evidence rather
than only the current Node list. Known providerID/systemUUID conflicts exclude
returning hosts from fresh claims even under another name, and the operator
withdraws any Brewlet runtime/capability labels from such Nodes regardless of
their ownership metadata. brewlet k8s profile list shows the unresolved count
as PENDING RETIREMENTS; the brewlet_nodeprofile_detached_retirements metric
reports the same value for alerting.
Never remove the node.brewlet.sh/cleanup finalizer, ownership labels, or
status to force deletion: that leaves runtimes and containerd changes on nodes
with no record of them.
Pod is Pending with NoCompatibleJDK¶
Symptom: the pod won't schedule; a NoCompatibleJDK event is recorded.
The pod requested a JDK (spec.jvm.version via JavaApplication, or raw pod
annotation brewlet.sh/jdk) that no ready node advertises.
# What does the pod ask for?
kubectl get pod <pod> -o jsonpath='{.metadata.annotations.brewlet\.sh/jdk}{"\n"}'
# What do nodes offer?
kubectl get nodes -o custom-columns=NODE:.metadata.name,JDKS:.metadata.annotations.brewlet\\.sh/jdks
Fix: either request a JDK the fleet has, or add the JDK to the inventory:
provisioner:
jdks:
- distribution: temurin
feature: 21
source:
image: docker.io/library/eclipse-temurin@sha256:<reviewed-digest>
javaHome: /opt/java/openjdk
Apply the values with helm upgrade ... -f values.yaml, wait for
brewlet.sh/jdks to update, and re-deploy.
Pod is Pending with NoCompatibleLauncher¶
Same as above, for launchers. The pod requested spec.jvm.launcher or
brewlet.sh/launcher: <name> that no ready node has.
kubectl get nodes -o custom-columns=NODE:.metadata.name,LAUNCHERS:.metadata.annotations.brewlet\\.sh/launchers
Fix: add a structured launcher entry with name, digest-pinned
source.image, and absolute source.path, then re-provision; or drop the
request to use the vanilla java launcher (omit the annotation). See
Launchers.
Pod is Pending after a "no ready node currently provides" warning¶
Symptom: creating the pod printed
Warning: brewlet: no ready node currently provides ..., and the pod stays
Pending with a FailedScheduling event.
Admission accepted the request because a valid NodeProfile declares it (or,
for brewlet.sh/arch, because architecture never denies), but no ready node
offers it yet. The pod waits for capacity:
- Autoscaled pool at zero: check that the autoscaler is enabled for the
pool and its node-template labels match the profile (see
Capability labels and autoscaling).
An AKS-managed Cluster Autoscaler pool cannot scale from zero for Brewlet
requests (
NotTriggerScaleUp ... didn't match Pod's node affinity/selector); keep--min-count 1or use Node Auto Provisioning with the Brewlet startup taint. - Node stuck with the startup taint: the profile must tolerate
startup-taint.cluster-autoscaler.kubernetes.io/brewlet, and the provisioner removes it only after publishing readiness; check the provisioner logs. - Fixed-size pool: add a node to a pool the named profile targets, or check
the profile's
Readycondition and provisioner logs. - Architecture: no pool provides that
kubernetes.io/arch; add one or dropspec.archif the JAR is architecture-neutral.
ImagePull-style failure¶
Symptom: the pod can't fetch the OCI image.
- Wrong ref / not pushed — verify the image exists:
oras manifest fetch <ref>(orbrewlet inspect <ref>for the local layout). - Unauthorized — add/verify
imagePullSecrets(orartifact.pullSecretsin theJavaApplication). - Not a Brewlet artifact — confirm it was pushed with the brewlet media types (Reference), not as a plain image.
Pod restarts / OOMKilled¶
Symptom: the pod restarts under load or is OOMKilled.
- The heap likely has no non-heap headroom. Set
-XX:MaxRAMPercentage=75.0(not 100%) so Metaspace/threads/code-cache/direct buffers fit underlimits.memory. See Resource requests, limits & JVM tuning. - Add
-XX:+ExitOnOutOfMemoryErrorso an OOM is a clean restart, not a hang. - Or switch to
jaz, which sizes the heap from the cgroup automatically. - Raise
limits.memoryif the workload genuinely needs more.
Task / shim failures¶
Symptom: containerd reports a task failure; the pod restarts or won't start.
kubectl describe pod <pod> # events from kubelet/containerd
# On the node:
journalctl -u containerd | grep -i brewlet
ls -l /opt/brewlet/bin/containerd-shim-brewlet-v2
grep -A2 runtimes.brewlet /etc/containerd/config.toml
test ! -f /etc/containerd/config.toml.d/99-brewlet.toml ||
cat /etc/containerd/config.toml.d/99-brewlet.toml
containerd --config /etc/containerd/config.toml config dump | grep -A4 runtimes.brewlet
- Shim not on containerd's PATH — confirm it's installed to
/usr/local/binand the effective config dump contains theruntimes.brewletblock. In validated mode the provisioner restarts and probes the handler itself; use its logs andbrewlet.sh/provision-errorrather than manually replacing config. - JDK root missing — confirm
/opt/brewlet/jdks/<dist>-<feature>/bin/javaexists on the node. - Arch mismatch — the shim is arch-specific; ensure the provisioner image matches the node arch (the build compiles it per-arch).
Webhook / admission problems¶
Symptom: compatibility hints aren't stamped, or scheduling isn't steered.
- With
admission.failurePolicy: Ignore(default), a webhook outage silently lets pods through without stamping/steering — check the webhook is healthy. If the shim still cannot resolve the workload image from containerd metadata, it fails closed rather than guessing: - caBundle/cert mismatch in the default mode should self-heal after
helm upgrade, which rotates the generated cert andcaBundletogether. With cert-manager enabled, inspectCertificate,CertificateRequest, and issuer readiness plus thecert-manager.io/inject-ca-fromannotations. - Webhook timeouts after enabling NetworkPolicies usually mean
networkPolicy.admission.apiServerCIDRsandnetworkPolicy.admission.ingressFromdo not match the sources used by the control plane. Temporarily disable the policies, confirm those sources with your Kubernetes provider/CNI documentation, and re-enable with corrected peers. On AKS, an error such asproxy error from localhost:9443 while dialing <pod-ip>:9443, code 504means thekonnectivity-agentpods inkube-systemcannot reach the webhook. CNIs such as Azure CNI powered by Cilium never match those pods with CIDRs, even0.0.0.0/0, so add them as a namespace+pod selector innetworkPolicy.admission.ingressFrom. helm upgradeon AKS fails withconflict with "admissionsenforcer"on.webhooks[name="pods.brewlet.sh"].namespaceSelector: AKS's Admissions Enforcer co-owns webhook selectors, and Helm 4 upgrades use server-side apply. Current charts render the enforcer's expressions throughadmission.platformNamespaceExclusions, so upgrading to them resolves it. If AKS injects different expressions, copy them into that value;--force-conflictsis a one-off fallback. See the chart README's "AKS Admissions Enforcer" section.- Control-plane pods fail readiness after enabling NetworkPolicies when
networkPolicy.healthProbes.ingressFromdoes not match the source addresses used by kubelets. Configure the cluster's node CIDRs or equivalent trusted peers so port 8081 remains reachable. - Metrics scrape timeouts with NetworkPolicies enabled mean the Prometheus
namespace/pod or source CIDR does not match
networkPolicy.metrics.ingressFrom, or the cluster CNI does not enforce NetworkPolicy as expected. - Non-brewlet pods are intentionally passed through untouched.
Local development issues¶
- The harness cannot find a component directory — run it from a complete
microsoft/brewletmonorepo checkout. For external component checkouts, setBREWLET_CORE_DIRandBREWLET_KUBERNETES_DIR. - Tier 2 cannot build a fixture — check
JAVA_HOMEpoints at a full JDK 21+. - Core or Kubernetes build fails — Go 1.26+ is required.
- Tier 3 skips or fails before runc — Docker must be installed and reachable; the tier runs a privileged Linux container.
- Non-Linux dev host — only the portable bundle-assembly core builds locally; use integration-test tier 3 to exercise the Linux/runc path. See Getting started.
Still stuck?¶
- Re-read the relevant guide: Installation, Configuration, JDK management, Launchers, Resource requests, limits & JVM tuning.
- The provisioner entrypoint is idempotent — safe to let it re-run after you fix node state.
- Component logs:
kubectl logs -n brewlet <operator|admission|provisioner pod>.