Add nodeAffinity support to all scheduled workloads - #711
Conversation
Extends the nodeAffinity support from apache#349 (issue apache#247) to the two workloads that were not covered by the original change: - standalone-deployment.yaml: adds a fully-guarded affinity block so .Values.standalone.affinity.node_affinity places the standalone pod, matching the existing guarded nodeSelector/tolerations style. - jwt-secret-init.yaml: broadens the affinity guard so nodeAffinity can be set independently of podAntiAffinity, mirroring the statefulset layout (nodeAffinity then podAntiAffinity). Existing anti-affinity behavior is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a commented node_affinity example alongside the existing anti_affinity documentation for every component whose template consumes it: zookeeper, bookkeeper, autorecovery, broker, proxy, standalone, and the JWT generateSecrets job. Kept commented so the default (no node affinity) behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends nodeAffinity coverage to the two remaining scheduled workloads with affinity blocks. Both already emit a static affinity: key with a conditional podAntiAffinity, so the nodeAffinity block follows the same layout as the other statefulsets. values.yaml documents a commented node_affinity example for each. Default behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pre-upgrade StatefulSet cleanup jobs had no scheduling constraints. Add a guarded nodeAffinity block reusing the existing broker.affinity.node_affinity / zookeeper.affinity.node_affinity values so these transient pods honor the same node placement as the workloads they manage. Default behavior (no affinity) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pre-upgrade StatefulSet cleanup jobs previously ran with a bare pod spec. Bring them in line with their StatefulSets so they schedule the same way: imagePullSecrets, nodeSelector, priorityClassName, tolerations, topologySpreadConstraints, and the full affinity block (nodeAffinity + podAntiAffinity), plus the container imagePullPolicy. All blocks are guarded, so default behavior is unchanged except that the jobs now honor the component's existing scheduling settings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The toolset statefulset already supported nodeSelector, priorityClassName, tolerations, and topologySpreadConstraints but had no affinity block. Add a guarded nodeAffinity block keyed on toolset.affinity.node_affinity, with a nil-safe guard since toolset has no affinity map by default. The toolset runs a single replica, so pod anti-affinity is not applicable and only node_affinity is supported. Default behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lhotari
left a comment
There was a problem hiding this comment.
Thanks for reviving #349 — the nodeAffinity rendering itself works for the documented case. Two things need to change before this can land, and one design question is worth settling now rather than after release.
The blocker
helm template test charts/pulsar on this branch vs master with default values differs in exactly two places, and both are the sts-cleanup Jobs. The zookeeper one gains a required pod anti-affinity, which makes the pre-upgrade hook unschedulable on a 3-node cluster. Details inline on zookeeper-statefulset-upgrade.yaml.
That directly contradicts "When unset (the default), no node affinity is applied, so existing deployments are unaffected." Commit 6f1ffb3 says the same thing — "All blocks are guarded, so default behavior is unchanged" — and the blocks are guarded, but the podAntiAffinity guard is and .Values.affinity.anti_affinity .Values.<component>.affinity.anti_affinity, and both default to true. So it guards nothing by default.
Scope
The title says "all scheduled workloads", but 6 of 18 pod-producing templates have no support: bookkeeper-cluster-initialize.yaml, dekaf-deployment.yaml, oxia-coordinator-deployment.yaml, pulsar-cluster-initialize.yaml, pulsar-manager-cluster-initialize.yaml, pulsar-manager-statefulset.yaml. Even setting the init Jobs aside, Dekaf / Oxia coordinator / Pulsar Manager are component workloads people will expect to pin.
Separately: both cleanup Jobs also gained imagePullSecrets, nodeSelector, priorityClassName, tolerations, topologySpreadConstraints and container imagePullPolicy. That's described in 6f1ffb3 but not in the PR body — worth calling out there, and it might be cleaner as its own PR.
Checked and clean
All 12 changed templates read their own component's value path — no copy-paste errors. Node and pod affinity correctly render as siblings under a single affinity: mapping everywhere. And Exists/DoesNotExist with no values renders values: null, which is a nil slice and passes both kubeconform and API validation — that one's fine as-is.
Reviewed with Codex gpt-5.6-sol and Claude Opus 5; every finding reproduced locally by rendering the chart and validating with kubeconform -strict.
Replaces the bespoke `affinity.node_affinity.matchExpression` schema with a raw `affinity.nodeAffinity` passthrough rendered via `toYaml | nindent`. The custom schema modelled exactly one nodeSelectorTerm holding exactly one matchExpression, always under requiredDuringSchedulingIgnoredDuringExecution. It could not express a weighted preferredDuringSchedulingIgnoredDuringExecution, multiple terms (OR), multiple expressions within a term (AND), or matchFields -- all common for node pinning. It also interpolated `key` and `values` unquoted, so any label value YAML reads as a scalar (`true`, `123`, `on`) rendered with the wrong type and was rejected by the API; `--set-string` was no workaround because the template output re-introduced the type. That made `Gt`/`Lt` unusable entirely, since they require numeric-looking strings. Native syntax removes the whole class of problem: users write real YAML, so quoting is theirs to control, and every affinity feature is reachable. It also matches the convention of every adjacent scheduling knob in this chart (nodeSelector, tolerations, topologySpreadConstraints are all toYaml passthroughs), and replaces 12 lines of template per component with 3. Nothing was released with the old shape, so there is no compatibility break. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Commit 6f1ffb3 gave the broker and zookeeper sts-cleanup Jobs the same podAntiAffinity block as their component StatefulSets. The guard is `and .Values.affinity.anti_affinity .Values.<component>.affinity.anti_affinity` and both default to true, so it guarded nothing: `helm template` with no values overridden at all differed from master in exactly these two Jobs. For zookeeper that is a broken upgrade. `zookeeper.affinity.type` defaults to requiredDuringSchedulingIgnoredDuringExecution and `zookeeper.replicaCount` to 3, so on a 3-node cluster the same required rule that spreads the 3 ZK pods one-per-node also excludes every hostname for the hook pod. The Job carries `helm.sh/hook: pre-upgrade`, so it stays Pending, the hook never completes, and `helm upgrade` blocks until timeout and fails. The broker Job escaped only because `broker.affinity.type` happens to default to preferred. These are short-lived single-shot pods that run kubectl. Spreading them across nodes buys nothing, so they get node affinity (which is opt-in and reflects where the operator wants Pulsar to run at all) and no pod anti-affinity. Verified: `helm template test charts/pulsar` on default values now has zero scheduling-related differences against master, and neither Job renders podAntiAffinity even with `affinity.anti_affinity=true`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six of the chart's 18 pod-producing templates had no node affinity support, so "all scheduled workloads" was not accurate. All six already honour nodeSelector and tolerations, so all six are workloads an operator can already place -- node affinity was simply missing. bookkeeper-cluster-initialize.yaml pulsar_metadata.affinity.nodeAffinity pulsar-cluster-initialize.yaml pulsar_metadata.affinity.nodeAffinity pulsar-manager-cluster-initialize.yaml pulsar_metadata.affinity.nodeAffinity dekaf-deployment.yaml dekaf.deployment.affinity.nodeAffinity oxia-coordinator-deployment.yaml oxia.coordinator.affinity.nodeAffinity pulsar-manager-statefulset.yaml pulsar_manager.affinity.nodeAffinity The three init Jobs share pulsar_metadata, matching how they already share its nodeSelector and tolerations. The other three components had no affinity block in values.yaml at all, so one is added carrying nodeAffinity only -- they have no pod anti-affinity to configure. Access is written nil-safe -- `(.Values.x.affinity).nodeAffinity` -- so an explicit `affinity: null` in a user's values file is not a nil dereference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.ci/templates-all-values.yaml gets a nodeAffinity on every component, using a deliberately awkward value -- two nodeSelectorTerms, several matchExpressions per term, matchFields, and required plus preferred together -- so the CI sweep exercises the `toYaml | nindent` passthrough against a deeply nested multi-element value, which is what that file exists to do. The patch1 overlay swaps in a two-entry weighted preferred list, and carries the function worker, whose StatefulSet only renders under that overlay. README gains a node-pinning example next to the existing anti-affinity note, since pinning to a dedicated node pool is the case issue apache#247 asked for. Verified: helm lint clean; kubeconform -strict passes on k8s 1.25/1.31/1.36 across default values and all 19 .ci/clusters configs, with and without node affinity set on every component (120 combinations), plus both templates-all-values renders at 1.36. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All three findings reproduced locally before changing anything. Pushed four commits: Blocker — Design question — Scope — Undocumented extras — the cleanup Jobs' One thing I found while fixing the scope item, worth flagging as pre-existing and out of scope here: Verification — |
lhotari
left a comment
There was a problem hiding this comment.
All five addressed. LGTM — thanks for the thorough job on this, particularly for reproducing the blocker before changing anything.
Everything below is from rendering, not reading.
The blocker — fixed
Default render vs master is now 4 added lines, 0 removed, and they're two blank lines plus two imagePullPolicy: "IfNotPresent". Zero scheduling differences. With affinity.anti_affinity=true forced on, neither cleanup Job renders podAntiAffinity — in fact neither renders an affinity: key at all. In the all-values config where node affinity is configured, both hooks get nodeAffinity and no podAntiAffinity, which is exactly the shape we wanted.
Native passthrough — the right call
This resolves the quoting bug by construction, as you say. Verified against a config using shapes the old schema couldn't express:
values: ["true", "123"] -> - "true" - "123" # quoted, stay strings
operator: Gt, values: ["2"] -> - "2" # Gt/Lt now usable
matchFields -> renders
preferred… + weight: 80 -> renders
two nodeSelectorTerms -> renders (OR)kubeconform -kubernetes-version 1.31.0 -strict passes on that config, and on the full all-values render (64 valid, 0 invalid). nodeAffinity and podAntiAffinity stay siblings under one affinity: key, and I found no duplicate affinity: keys in any rendered workload.
Scope, and the rest
- All 18 pod-producing templates carry node-affinity handling and each reads its own component path. The three init Jobs sharing
pulsar_metadata.affinity.nodeAffinityis right — it matches how they already sharenodeSelectorandtolerations. dekaf's safe-navigation form renders correctly too. - jwt-secret-init guard: exactly one
affinity:key when node affinity is set, zero when unset, across both anti-affinity values, no errors. - Undocumented extras: keeping them is fine now that the anti-affinity is gone. Your reasoning holds — with the anti-affinity removed these are one coherent idea (make the hooks land where the operator has said Pulsar may run), and splitting would leave this PR adding node affinity to hooks that still ignore
nodeSelector. No need to pull them out. - CI values genuinely exercise the passthrough rather than your say-so:
matchFields,preferredDuringSchedulingIgnoredDuringExecutionand weighted entries all appear, none of which the old schema could express. Good addition.
Your question about the duplicate nodeSelector
Confirmed pre-existing: it's on master at pulsar-cluster-initialize.yaml:47 and :185 (:190 here), and the rendered Job really does carry two nodeSelector: keys in one pod spec. Inert today only because both emit identical content and the last key wins.
Please open a separate PR for it. This one is ready to merge and an unrelated fix would restart review on it. That's not inconsistent with my asking you to keep the bookkeeper initContainers fix inside #715 — there the fix was a side effect of a guard that had to be touched anyway, so it was inseparable. Here it's an independent three-line deletion in a file you happened to touch for another reason.
Good catch either way — worth fixing before someone runs the chart through a strict YAML parser that rejects duplicate keys outright.
One non-blocking follow-up
.ci/templates-all-values.yaml configures standalone.affinity.nodeAffinity but sets standalone.enabled: false just below it, so the standalone Deployment never renders in either all-values run — its node affinity is the one path of the eighteen that CI doesn't actually exercise. .ci/clusters/values-standalone.yaml enables standalone but sets no node affinity, so it isn't covered there either.
Not worth holding the merge for, and standalone genuinely is mutually exclusive with the distributed setup so it can't just be flipped on in that file. Adding affinity.nodeAffinity to values-standalone.yaml would close it whenever you're next in there.
Reviewed with Codex gpt-5.6-sol and Claude Opus 5; Claude Fable was in the reviewer set but did not return. Every finding reproduced locally by rendering the chart and validating with kubeconform -strict.
| # ----------------------------------------------------------------------------- | ||
| standalone: | ||
| affinity: | ||
| nodeAffinity: *nodeAffinity |
There was a problem hiding this comment.
Non-blocking, for whenever you're next in here.
This configures standalone.affinity.nodeAffinity, but enabled: false on the next line means the standalone Deployment never renders — so neither all-values run exercises this path. Of the eighteen templates that now carry node-affinity handling, standalone is the only one CI doesn't actually cover.
.ci/clusters/values-standalone.yaml does enable standalone, but sets no node affinity, so it isn't covered there either.
I wouldn't enable standalone here — your comment above is right that it's mutually exclusive with the distributed setup. Adding an affinity.nodeAffinity block to .ci/clusters/values-standalone.yaml would be the natural place to close the gap.
Concretely, the risk this leaves open is narrow: a malformed value path, an indentation error, or a duplicate affinity: key in standalone-deployment.yaml would pass both advertised all-values validations because the Deployment simply isn't in the output.
There was a problem hiding this comment.
Is merging this PR waiting on me to resolve this comment?
Fixes #247.
Motivation
Users running on clusters with multiple node pools (e.g. GKE) need to pin Pulsar
components to specific nodes. Today the chart exposes
nodeSelectorand podanti-affinity, but no
nodeAffinity, forcing workarounds like cordoning nodes.This revives the work from #349 (by @MonicaMagoniCom), whose source branch was
deleted before it could be merged, and extends it to every other scheduled
workload in the chart.
Modifications
Adds an optional
affinity.nodeAffinityto every pod-producing template. Thevalue is passed through to the pod spec verbatim via
toYaml | nindent, so itis native Kubernetes node affinity syntax — the same convention the chart's
adjacent scheduling knobs (
nodeSelector,tolerations,topologySpreadConstraints) already follow. Unset by default, so no nodeaffinity is applied and existing deployments are unaffected. It renders as a
sibling of the chart's own
podAntiAffinityrather than replacing it.The value paths, one per component:
zookeeper.affinity.nodeAffinityzookeeper-statefulset.yaml,zookeeper-statefulset-upgrade.yamlbookkeeper.affinity.nodeAffinitybookkeeper-statefulset.yamlbroker.affinity.nodeAffinitybroker-statefulset.yaml,broker-statefulset-upgrade.yamlproxy.affinity.nodeAffinityproxy-statefulset.yamlautorecovery.affinity.nodeAffinityautorecovery-statefulset.yamlfunction_worker.affinity.nodeAffinityfunction-worker-statefulset.yamltoolset.affinity.nodeAffinitytoolset-statefulset.yamlstandalone.affinity.nodeAffinitystandalone-deployment.yamloxia.server.affinity.nodeAffinityoxia-server-statefulset.yamloxia.coordinator.affinity.nodeAffinityoxia-coordinator-deployment.yamlpulsar_manager.affinity.nodeAffinitypulsar-manager-statefulset.yamldekaf.deployment.affinity.nodeAffinitydekaf-deployment.yamlpulsar_metadata.affinity.nodeAffinitybookkeeper-cluster-initialize.yaml,pulsar-cluster-initialize.yaml,pulsar-manager-cluster-initialize.yamlauth.authentication.jwt.generateSecrets.affinity.nodeAffinityjwt-secret-init.yamlAlongside the node affinity, the broker and zookeeper
sts-cleanuphook Jobspick up the scheduling knobs their component StatefulSets already honour —
imagePullSecrets,nodeSelector,priorityClassName,tolerations,topologySpreadConstraints, and containerimagePullPolicy— so a cluster thatreserves nodes for Pulsar does not leave these hooks unschedulable. They
deliberately get no pod anti-affinity: they are short-lived single-shot
kubectlpods, spreading them across nodes buys nothing, and inheritingzookeeper's default required rule would make the
pre-upgradehookunschedulable on a 3-node cluster and hang
helm upgrade..ci/templates-all-values.yamland its patch1 overlay setnodeAffinityonevery component so the CI sweep exercises the passthrough, and the README gains
a node-pinning example next to the existing anti-affinity note.
Verifying this change
helm template test charts/pulsarwith default values is byte-identical tomaster apart from the two intended cleanup-Job additions and the chart's
randomly generated secrets — in particular there are zero scheduling-related
differences, so default behavior is genuinely unchanged.
helm lint charts/pulsarclean.kubeconform -strictpasses on k8s 1.25 / 1.31 / 1.36 across default valuesand all 19
.ci/clusters/*.yamlconfigs, both with and withoutnodeAffinityset on every component — 120 combinations — plus both
templates-all-values.yamlrenders at 1.36.component's
nodeAffinity, with the expected shape and no copy-pastecrossover, and to keep
nodeAffinity/podAntiAffinityas siblings under asingle
affinity:mapping.jwt-secret-initguard was checked across all four combinations ofnodeAffinityset/unset ×anti_affinitytrue/false, including the case whereneither renders and no
affinity:key is emitted at all.Does this pull request potentially affect one of the following parts:
affinity.nodeAffinityvalue per component; additiveand unset by default
Documentation
doc-not-needed— documented inline invalues.yamland in the README