Kubernetes — per-task gotchas
Read before editing Kubernetes manifests, Helm charts, or Kustomize overlays. These are authoring-time pitfalls — situations where the naive choice compiles, lints, and applies cleanly but fails in production or at the next cluster upgrade. Reviewers catch most of these post-write via `review-code`; the point here is to avoid them at the keyboard.
Overview
Kubernetes — per-task gotchas
Read before editing Kubernetes manifests, Helm charts, or Kustomize overlays. These are authoring-time pitfalls — situations where the naive choice compiles, lints, and applies cleanly but fails in production or at the next cluster upgrade. Reviewers catch most of these post-write via review-code; the point here is to avoid them at the keyboard.
When in doubt about an API field, version, CRD schema, Helm chart behavior, or container image digest, invoke the dependency-handling skill BEFORE writing — do not guess. See ../overview.md §Looking up Kubernetes dependencies for the per-category cascade.
API-version pinning
- Target the cluster's minor version, not
latest.kubectl api-versionsandkubectl explain <resource>on the target cluster are the authoritative checks for what is actually served. - Do not use removed APIs. Common traps when copying from old examples:
extensions/v1beta1(Deployment/DaemonSet/ReplicaSet/NetworkPolicy/PodSecurityPolicy removed in 1.16; Ingress removed in 1.22 — treat the whole group as dead),policy/v1beta1/PodDisruptionBudget(removed in 1.25 — usepolicy/v1),batch/v1beta1/CronJob(removed in 1.25 — usebatch/v1),autoscaling/v2beta2/HorizontalPodAutoscaler(removed in 1.26 — useautoscaling/v2),networking.k8s.io/v1beta1/Ingress(removed in 1.22 — usenetworking.k8s.io/v1). - Deprecated ≠ removed — deprecated APIs still work but signal an upcoming removal. If the design names a target minor, check each
apiVersionagainst that minor's deprecation guide before writing. - For CRDs, the group/version is pinned to the installed operator release, not to the Kubernetes minor. Confirm with
kubectl api-resources --api-group=<group>against the target cluster.
Probe correctness
- Readiness ≠ liveness. A failing
readinessProberemoves the Pod from Service endpoints (traffic stops); a failinglivenessProbekills the container (restart). Using a liveness probe that blocks on a dependency (DB, external API) turns a transient upstream outage into a restart loop. - Add a
startupProbefor slow-starting apps (JVMs, apps with large warm caches, DB migrations on boot). When astartupProbeis defined, liveness and readiness probes are suspended until it succeeds — this is the strong guarantee, not just "give liveness more delay". Without astartupProbe, liveness fires during startup andinitialDelaySecondsis the only buffer — set it too low and the Pod is killed before it is ready; set it too high and genuine liveness failures take minutes to act on. Note: astartupProbethat never succeeds will eventually fail (failureThreshold * periodSeconds) and kill the container; tune it to match the app's worst-case warm-up. failureThreshold,periodSeconds,timeoutSecondsare independent knobs. A 1stimeoutSecondson an HTTP probe against a lightly-loaded endpoint is a common source of flakiness on busy nodes.- Exec probes fork a process every period — cheap for one container, expensive at fleet scale. Prefer HTTP or TCP probes where possible.
Image-tag immutability
- Prefer digests (
image: repo/name@sha256:...) over tags for any image that ships to production. Tags are mutable; the image behind:1.2.3can be replaced without the manifest changing. - Never ship
:latest— it pins nothing and disables rollback reasoning. This is flagged byquality-checklist.mdpost-write; fixing it at review time means re-tagging and re-testing. - When adding a new image, record the digest you pulled alongside the tag in the design/ADR. If
skopeo/craneis unavailable locally, the registry's web UI lists the digest; do not invent one. - Private registries:
imagePullSecretsmust be attached to the workload'sServiceAccount(preferred) or the Pod spec. Attaching it only to the namespace'sdefaultSA surprises people writing new workloads that opt into a different SA.
Resource requests and limits
- BestEffort requires BOTH
requestsANDlimitsto be absent on EVERY container. If any container sets either, the Pod isBurstable(not BestEffort). Settinglimitswithoutrequestsis stillBurstable— Kubernetes copieslimitsintorequestsfor scheduling. Critical workloads should set requests explicitly;BestEffortis the kubelet's first eviction target under memory pressure. - Missing
resources.limits.memory→ no hard ceiling; a leak takes the node with it. Missingresources.limits.cpuis an intentional choice on some clusters (to avoid throttling latency-sensitive apps); missinglimits.memoryalmost never is. - CPU limits cause throttling, not OOMKill. If latency matters more than noisy-neighbor protection, prefer
requests+ nolimits.cpu, enforced by quota at the namespace level. - Guaranteed QoS requires
requests == limitsfor CPU and memory on every regular container and init container (including sidecar containers, which are init containers withrestartPolicy: Always). Ephemeral containers (spec.ephemeralContainers) do not participate in the QoS calculation. Extended resources (ephemeral-storage, custom resources) are not part of the Guaranteed calculation. Partial matches fall back toBurstable. - Java/JVM: set
-XX:MaxRAMPercentageor-Xmxbased on the container's memory limit; older JVMs default to host memory, which OOMKills immediately.
Namespace and label hygiene
- Every workload resource must set
metadata.namespaceexplicitly (or live in a Kustomize base / Helm chart that pins it) — relying onkubectl's--namespacedefault makes the manifest context-dependent and breaks GitOps. - Labels: follow the project's existing scheme (usually some subset of
app.kubernetes.io/name,app.kubernetes.io/instance,app.kubernetes.io/version,app.kubernetes.io/component,app.kubernetes.io/part-of,app.kubernetes.io/managed-by). Thequality-checklist.mdnames the recommended set. - Selectors (
spec.selector.matchLabelson Deployment/StatefulSet/DaemonSet) are immutable after creation. Pick the label set deliberately — a later change forces delete-and-recreate, which is downtime. Service.spec.selectoris mutable but must match the Pod labels the workload actually emits; a typo routes traffic to zero endpoints and no error is surfaced until someone checkskubectl get endpoints.
CRD-before-CR ordering
- A Custom Resource (
Certificate,ClusterIssuer,PrometheusRule,Application, etc.) cannot apply until its CRD is installed. In a singlekubectl apply -f dir/,kubectldoes sort Namespaces and CRDs before other resources — but CRD establishment (API-server registering the new endpoint) is asynchronous; the immediately-following CR apply races the server-side registration and fails withno matches for kind. The failure is server-side async lag, not client-side ordering. - Fixes: install CRDs via a prior
kubectl applypass with a wait (kubectl wait --for=condition=Established crd/...), a Helm chart that usescrds/, or Argo CD — two distinct mechanisms: (a) sync-wave annotationargocd.argoproj.io/sync-wave: "-1"on CRDs orders them earlier within a single sync; (b)PreSynchookargocd.argoproj.io/hook: PreSyncapplies CRDs in a separate phase before the main sync. They are different knobs — do not conflate. Kustomize does not solve the ordering problem — it must come from the apply tool. - Helm specifically: CRDs placed in the chart's
crds/directory are applied before templates but are not upgraded onhelm upgrade. If the chart's CRD schema evolves, ship CRDs via a separate chart or a dedicatedkubectl apply. - Deletion order is the reverse: delete CRs (and anything with a finalizer) before the CRD, or the finalizer controller is gone and the CR blocks forever. This is the top cause of "namespace stuck in Terminating".
Admission webhook and operator timing
- New ValidatingWebhookConfiguration / MutatingWebhookConfiguration with
failurePolicy: Failcan lock out the cluster if the webhook backend is unreachable. For first rollout, start withfailurePolicy: Ignore, watch for acceptance, then flip toFail. - Avoid selecting
kube-systemin webhook scopes unless deliberate — locking out the control plane is hard to recover from. - Operators that install CRDs and then reconcile CRs they own (cert-manager, external-secrets) need to be fully ready before the first CR apply. If the operator is not ready, the CR is created but not reconciled —
status.conditionswill not reachReady=True(or equivalent), yetkubectl applyreports success and a GitOps controller will mark the sync complete. The failure mode is a silently-pending CR, not a surfaced error — checkstatus.conditionsafter the operator is healthy.
Helm specifics
Chart.yaml.kubeVersionis a Helm field with semver-range semantics (e.g.,>=1.28.0-0— the-0pre-release qualifier is a Helm convention that includes1.28.0-alpha/1.28.0-rc.1builds in the range). It is not enforced by the cluster — it gateshelm install/helm upgradeagainst the cluster's server version.dependencies[]must be pinned to a strict semver; floating ranges (~1.2,^1.0) are reproducibility hazards. Lock the exact versions inChart.lockby runninghelm dependency updateand committing both files.- Templates render Go templates BEFORE Kubernetes sees the output —
{{ ... }}errors fail athelm template/helm linttime, not at cluster-apply time. Runhelm lintlocally before committing. requiredandfailin templates are the only way to make a missing value break rendering. Plain{{ .Values.foo }}on a missing key renders as an empty string (which can silently break YAML structure — e.g., an emptyimage:field) under Helm's defaultmissingkey=default. A nested access like{{ .Values.foo.bar }}when.Values.foois undefined raises a nil-pointer template panic, not an empty string.<no value>is the Go-template sentinel string some tools print for missing keys; do not rely on it as a visible marker in rendered manifests.
Kustomize specifics
kustomization.yamlpatches are applied in list order. Strategic-merge patches merge; JSON 6902 patches target by path. Using the wrongpatches[].patchtype is the common source of "the patch didn't do anything".commonLabelsare applied to every resource and to selectors, which can collide with the workload's own selector and trigger the immutable-selector failure above. Prefer thelabels:transformer withlabels[].pairsandincludeSelectors: false— but this requires Kustomize ≥ 4.1 (runkustomize versionto check; olderkubectl kustomizebundles may predate this field). On older toolchains you are stuck withcommonLabels— audit every workload selector against the labels you inject.namePrefix/nameSuffixalso mutate references (configMapRef.name, etc.), but only those Kustomize knows how to follow. Raw string references inside annotations or CR spec fields are not rewritten — check the rendered output (kustomize build) before committing.