[Bug] Reconcile the head service selector when head Pod labels change (#2564) - #5102
[Bug] Reconcile the head service selector when head Pod labels change (#2564)#5102stantheman0128 wants to merge 3 commits into
Conversation
…ray-project#2564) The head service selector takes app.kubernetes.io/name and app.kubernetes.io/created-by from the head Pod template, and labelPod lets users override both on the head Pod. reconcileHeadService returned early whenever a head service already existed, so the selector kept the value the cluster was created with and stopped matching a head Pod that restarted after the label was renamed. Deleting the service does not recover it, because since ray-project#2166 the operator finds the service by its ray.io labels and then accepted whatever selector it already had. reconcileHeadService now builds the desired service on every pass and updates the parts the operator owns: spec.selector, and the label keys it stamps, merged so labels added by other tools survive. ClusterIP, ports, service type and third-party annotations are left alone, and a service that already matches produces no write. The desired selector now also reads app.kubernetes.io/created-by from the head Pod template, which was overridable on the Pod but never mirrored into the service.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 581fb88. Configure here.
The first version of this change took the two app.kubernetes.io values straight from the head Pod template. That is wrong in the common case. KubeRay never relabels a running Pod, and shouldRecreatePodsForUpgrade returns false unless upgradeStrategy.type is Recreate, which is not the default. Editing the label on a live cluster would therefore move the selector off the Pod that is currently serving traffic and cut the head off until something happened to restart it, which is worse than the bug being fixed. headServiceSelectorOverrides now reads those values from the head Pod that is running, falling back to the template only when no head Pod exists yet. Pods with a deletion timestamp are skipped, since their replacements come back carrying the template labels. The head Pod lookup uses ray.io/cluster and ray.io/node-type, both refused by labelPod, so a renamed app.kubernetes.io label cannot hide the Pod from this query. The reported case still heals: once the head Pod restarts it comes back with the new template label, and the next reconcile moves the selector onto it.
List order is not specified, so reading the selector from whichever head Pod came back first could flip it between reconciles and rewrite the service every time. The oldest Pod is the one that has been serving traffic, so it decides the selector.
|
Bugbot is right, and my e2e case is what hid it. This is fixed now. The two keys I was syncing really are in the selector. My e2e case deleted the head Pod right after editing the template, so it only ever looked at the state after the restart. That is why it stayed green. The selector values now come from the head Pod that is running, not from the template. While the old Pod is up, the desired selector equals what the Service already has, so nothing is written and connectivity holds. When the Pod restarts it comes back with the template labels and the Service follows it, which is the case #2564 reports. Terminating Pods are skipped, and creation still falls back to the template because there is no Pod to read yet. When more than one head Pod is alive, the oldest one wins, so the selector cannot flip between reconciles. Tests: one unit case asserts the selector keeps matching the running Pod after a template rename, one pins the tiebreak, and one asserts the selector follows a restarted Pod. I kept the old e2e case and added one beside it that never touches the Pod. The two that target this bug fail on the previous commit. The full One behavior change worth calling out: the selector is no longer purely CR derived, so anyone able to patch labels on the head Pod can move those two keys, and the operator will follow rather than correct it. The blast radius is bounded, because There is a larger option I did not take, since it feels like a maintainer call: narrow the selector to |
|
On the red |

Why are these changes needed?
The head service selector is not constant.
BuildServiceForHeadPodkeys it offHeadServiceLabels, and two of those keys,app.kubernetes.io/nameandapp.kubernetes.io/created-by, can be overridden on the head Pod.reconcileHeadServiceonly built the service when it was missing and returned early whenever one already existed, so the selector kept the value the cluster was created with.Rename the label on a running RayCluster and the head Pod that comes back after an eviction or a node drain carries the new value while the service still points at the old one. The service then selects nothing and the head is unreachable. Deleting the service does not recover it either: since #2166 the operator finds the head service through
ray.io/cluster,ray.io/node-typeandray.io/identifier, which the rename does not touch, so it finds the stale object, decides there is nothing to do, and returns. The reporter of #2564 was left deleting the whole RayCluster.#2166 taught the lookup to survive the rename, but nothing was added to repair the service it finds. This PR adds that:
reconcileHeadServicenow builds the desired service on every pass and updatesspec.selectorwhen it differs.app.kubernetes.iovalues in the selector are read from the head Pod that is running, not from the head Pod template.Reading the selector from the live Pod rather than the template is the part worth explaining, because the obvious implementation is wrong. KubeRay never relabels a running Pod, and
shouldRecreatePodsForUpgradereturns false unlessupgradeStrategy.typeisRecreate, which is not the default. A selector taken straight from an edited template would therefore move off the Pod that is currently serving traffic and cut the head off immediately, which is worse than the bug being fixed. Sourcing from the live Pod gives both halves: the service keeps selecting the Pod that is up, and once that Pod restarts carrying the new template label the next reconcile follows it, which is the case #2564 reports.Pods with a deletion timestamp are skipped, since their replacements come back with the template labels. When several head Pods are alive, the oldest one decides, because
Listorder is not specified and without a tiebreak the selector could flip between reconciles and rewrite the service every time. The head Pod lookup itself usesray.io/clusterandray.io/node-type, both refused bylabelPod, so a renamedapp.kubernetes.iolabel cannot hide the Pod from that query.The selector and the labels are handled differently on purpose. The selector is replaced whole, because it is the operator's alone and
BuildServiceForHeadPodalready ignores any selector a user puts inHeadGroupSpec.HeadService. A key added to the live selector by hand is therefore removed on the next reconcile, which is intended: an extra selector key the operator did not write narrows the match and can drop the head Pod out of the service, the same failure this PR is fixing. Labels get the opposite treatment, since other tools legitimately write labels the operator never wrote.TestReconcileHeadServiceReplacesHandEditedSelectorpins both halves.The same path also picks up
app.kubernetes.io/created-by. Bothapp.kubernetes.iolabels in the selector can be overridden on the head Pod, but onlyapp.kubernetes.io/namewas ever read back, so overridingcreated-byproduced a head service that never matched its Pod at all. The threeray.iolabels are deliberately not sourced this way:labelPodrefuses two of them, andray.io/identifieris how the operator finds this service again.Level of fix
Fixed in
RayClusterReconciler.reconcileHeadServicebecause that is the only place in the operator where a service selector is derived from user supplied labels. I weighed two alternatives and dropped both.The first was rejecting the override in validation. #1040 and #2166 made overriding
app.kubernetes.io/namea supported thing to do, so rejecting it is a breaking change, and the webhook is optional, so the operator still could not rely on it. It also leaves already broken services broken.The second was dropping the two
app.kubernetes.iokeys from the selector so it carries only the threeray.ioidentity labels, the same set the lookup uses. That makes the drift structurally impossible and would cover the RayJob and RayService head services too, but it rewrites the selector of every existing head service in a fleet and takes away a customization users are allowed to make today. It also still needs the update path in this PR to heal services that already exist, so this change is a prerequisite either way. I can follow up with that shape if you prefer it.Six service reconcilers in the operator never reconcile an existing service:
reconcileHeadService,reconcileServeService,reconcileHeadlessService,reconcilePerClusterServeService, plus the RayJob and RayServicereconcileServices, which update only when the RayCluster switches. Only the RayCluster head service derives its selector from user input, so only that one is changed here. The new code followsingressNeedsUpdateandreconcileIngressKubernetesin the same file.Related issue number
Closes #2564
Labels
doc-updates-requiredlabel.breaking-changelabel.Neither box applies as far as I can tell. The user-facing behavior is a head service that stops going stale, which matches what the docs already promise, and nothing that worked before stops working. Happy to add either label if you read it differently.
Checks
Evidence
Nine new unit cases in
raycluster_controller_unit_test.go. Seven of them fail againstmaster:Two of those failures, one for each half of the change:
The two that pass on
masterdo so on purpose.IgnoresProtectedLabelOverridespins thatray.io/cluster,ray.io/node-typeandray.io/identifierare never sourced from the Pod.KeepsSelectingRunningHeadPodpasses onmasterbecausemasternever touches an existing selector at all, so there is nothing to move off the Pod; it is a guard on this PR's own update path, and it fails against the first draft of this change, which read the selector from the template.All ten pass with the fix:
Three new envtest specs join the
RayCluster with overridden app.kubernetes.io labelsblock that #2166 created: the service keeps selecting the head Pod that is still running, the service follows the head Pod after it restarts with the renamed label, and the selector prefers the oldest live head Pod.Whole package, envtest 1.34.1:
Removing any one part of the fix turns the matching case red, checked by reverting each piece in turn.
What was not tested
spec.selectorandmetadata.labelsare ever written, so both headless and ClusterIP services take the same path.spec.headServiceAnnotationsis still not reconciled onto a live service. That is unchanged behavior, not a regression, and out of scope here.r.Updateerrors propagate up and the cluster is requeued, which is the usual controller-runtime shape, and the reconcile is idempotent so a retry converges.BuildServiceForHeadPod(ctx, rayCluster, nil, nil), so an overridden head Pod label leaves them wrong from creation rather than after a rename. It spans two more controllers and belongs in its own issue.ray.io/identifieris also overridable on Pods throughlabelPod, which breaks the selector in the same way. It is not sourced from the Pod here because the operator uses that label to find the head service, so the fix for it is to stoplabelPodaccepting it, which is a separate behavior change.pkg/webhooks/v1cannot finish cleanly on this machine. All 7 specs pass and only the envtest teardown fails, withunable to signal for process ... kube-apiserver to stop: not supported by windows. Nothing in this PR touches that package.AI disclosure
This change was written with AI assistance (Claude). A human reviewed the diff, ran every command shown above, and is responsible for the result.