Uh oh!
There was an error while loading. Please reload this page.
fix(reload,health): re-initialize monitors on config change; guard probe bind ordering - #40
Conversation
…obe bind ordering Two paired fixes to the agent's startup/reload lifecycle. ## 243 — config hot-reload left running components stale Reproduced against the real rendered chart config. The monitor restart machinery itself worked, but four defects around it made ConfigMap edits untrustworthy: 1. Startup/reload normalization asymmetry (the severe one). main.go applied monitors.ApplyDefaultMonitors() + CLI overrides + ApplyDefaults() to the startup config; the reload path used a bare util.LoadConfig. The two therefore disagreed about what the configuration contained, so every auto-defaulted monitor looked REMOVED on the first reload and was silently stopped. Measured with the shipped chart: a single dns-health edit dropped the running monitor count from 10 to 1. The -debug/-dry-run/-log-* flags were likewise silently reverted by any reload. Fixed by building the normalizer once and installing it on the reload coordinator. 2. diff.RemediationChanged was computed and then ignored. Editing dryRun or maxRemediationsPerHour reported "reload succeeded" while the registry kept the startup values until a pod restart — silent staleness on the exact kill-switch operators reach for mid-incident. Added RemediatorRegistry.ApplyConfig (+ SetMaxRemediationsPerHour) and an optional ReconfigurableRemediationExecutor interface the detector now invokes. 3. Settings-only edits fell through to a cheerful "no changes" event, because ComputeConfigDiff only inspects monitors/exporters/remediation. Added reload.ClassifyReload, which reports what was reconfigured in place versus what is latched at startup and genuinely needs a rollout (nodeName, log destination, pprof, enabling a previously-disabled exporter or remediation, coordination/lease settings). Those now emit an explicit ConfigReloadRestartRequired warning event instead of a silent success. 4. reload.enabled was parsed and never read. It is now honored in both directions and defaults to enabled (*bool, nil means true, so existing deployments are unaffected); disabling it logs loudly that edits require a rollout. Also: log level/format are now re-applied on reload, and every successful reload logs a line naming which monitors were reconfigured/started/stopped. ## 246 — probe bind ordering + liveness/readiness split - Split createExporters into an explicit phase 1 (health server) / phase 2 (networked exporters) and added two regression guards, both verified to FAIL when the ordering is deliberately inverted: a behavioural test that blocks phase 2 and asserts the health socket already answers the real exec-probe code path, and an AST guard over main.go that rejects networked init ordered ahead of startHealthServer. - Liveness and readiness are now genuinely distinct. /healthz consults only process-internal state and is documented/tested to ignore downstream failures, so a broken API server can never restart the agent on the degraded nodes it exists to observe. /ready gains readiness checks plus per-exporter dependency status fed from the detector's export loop, with a 3-consecutive- failure threshold so a transient blip cannot flap the whole DaemonSet. - Confirmed the startup budget: initialDelay 10 + 20 x 5 = 110s, documented in values.yaml.template with a floor, and pinned by a chart contract test. Probes remain exec-against-unix-socket; a new chart test fails if httpGet on 8080 is ever reintroduced, since that host port conflict still exists in the fleet. Chart: values.yaml.template and configmap.yaml edited, values.yaml regenerated via make helm-generate. Verification: make build, make test, go test -race ./... , go vet ./... , make helm-lint all pass. Task: #node-doctor-243 Task: #node-doctor-246 Claude-Session: https://claude.ai/code/session_01Av4whriQf6KqJgRxGKQP7N
…-probe-guard-243-246 # Conflicts: # helm/node-doctor/values.yaml # helm/node-doctor/values.yaml.template
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit:9c943bc63d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // someone restarted the pod. That is the silent-staleness bug #node-doctor-243 | ||
| // was filed for; remediation is the worst place to have it, because dryRun is | ||
| // the kill-switch operators reach for during an incident. | ||
| if diff.RemediationChanged { |
There was a problem hiding this comment.
Detect per-minute remediation-limit changes
When an operator changes only remediation.maxRemediationsPerMinute, remediationEqual in pkg/reload/diff.go still considers the configurations equal because it compares only enabled, dry-run, the hourly limit, and circuit-breaker fields. Consequently this guard is false, ApplyConfig is never called, and the running token bucket keeps the old safety limit even though the new implementation explicitly supports reloading it.
Useful? React with 👍 / 👎.
| for _, e := range exps { | ||
| if !e.oldEnabled && e.newEnabled { | ||
| r.RestartRequired = append(r.RestartRequired, | ||
| fmt.Sprintf("%s.enabled (false -> true): the exporter is constructed at startup, so there is no instance to reload", e.name)) |
There was a problem hiding this comment.
Stop exporters when they are disabled
Handle the true-to-false transition as restart-required or actually stop/remove the exporter. The built-in Reload implementations merely replace their configuration and do not inspect Enabled, while the detector continues calling every instance in pd.exporters; therefore changing an HTTP, Kubernetes, or Prometheus exporter from enabled to disabled is reported as successfully reconfigured but it continues sending data or serving its listener.
Useful? React with 👍 / 👎.
| oldConfig := rc.currentConfig | ||
| diff := ComputeConfigDiff(oldConfig, newConfig) | ||
| reloadability := ClassifyReload(oldConfig, newConfig, diff) |
There was a problem hiding this comment.
Detect monitor remediation and dependency edits
Before relying on ComputeConfigDiff to drive monitor reinitialization, include all behavior-bearing monitor fields in its equality check. monitorsEqual currently omits Remediation and DependsOn, so an edit limited to a monitor's remediation strategy or dependency list yields no diff, skips the callback, and leaves monitorConfigIndex and dependency behavior at their previous values while the coordinator adopts the new file.
Useful? React with 👍 / 👎.
| // Step 4: Check if there are any hot-applicable changes | ||
| if !diff.HasChanges() { |
There was a problem hiding this comment.
Apply log-only changes before adopting the config
When a ConfigMap edit changes only settings.logLevel or settings.logFormat, ConfigDiff.HasChanges() is false and ClassifyReload does not mark either field restart-required. This branch therefore returns without invoking handleConfigReload, so reapplyLoggingConfig never runs and the advertised live log-level/format change has no effect, even though the coordinator records the new config as current.
Useful? React with 👍 / 👎.
| if depReporter != nil { | ||
| depReporter("exporter/"+pd.getExporterType(exporter), err) |
There was a problem hiding this comment.
Feed webhook delivery failures into readiness
For the HTTP exporter, the error passed here reports only whether SubmitStatusRequest could enqueue work; actual webhook requests run asynchronously and failures are recorded later by the worker. Thus an unreachable or consistently failing webhook normally produces err == nil on every export and repeatedly clears exporter/http, so /ready remains healthy despite the downstream failure this new wiring is intended to expose.
Useful? React with 👍 / 👎.
…raped Readiness on the agent DaemonSet now has teeth (a sustained exporter failure marks the pod NotReady), which interacts badly with the chart as it stood. The agent Service is selected by the ServiceMonitor and exists ONLY for Prometheus discovery — no request traffic flows through it. It did not set publishNotReadyAddresses, which defaults to false, so Kubernetes drops NotReady pods out of the Service's Endpoints. A pod goes NotReady precisely when something is wrong with that node's agent, so the effect was to stop scraping exactly the nodes we most need data from, at exactly the moment we need them. The blackout would also be silent. NodeDoctorNoMetrics is absent(node_doctor_monitor_uptime_seconds) which is fleet-wide: it only fires when EVERY node stops reporting. Losing one node, or five, raises nothing at all. Set publishNotReadyAddresses: true on the Service, with the reasoning recorded at both the template and the values site so it does not get "cleaned up" later. Readiness still gates rollouts via maxUnavailable and still surfaces degradation in kubectl/dashboards; it just no longer destroys observability while doing so. Added a chart contract test asserting it renders true, in the same spirit as the existing guard that fails if httpGet-on-8080 probes are reintroduced. Verified the test fails when the value is flipped to false. values.yaml is generated: edited values.yaml.template and ran make helm-generate; both committed. Task: #node-doctor-246 Claude-Session: https://claude.ai/code/session_01Av4whriQf6KqJgRxGKQP7N
Uh oh!
There was an error while loading. Please reload this page.
Paired because both touch
cmd/node-doctor/main.goand the agent's startup/reload lifecycle.243 — config hot-reload did not re-initialize running components
I reproduced this against the real rendered chart config rather than a synthetic one. Two findings up front, because they change the shape of the fix:
..dataarrives as aCreate), and a modified monitor was being stopped and rebuilt. Both are now pinned by tests so they stay fine.1. Startup/reload normalization asymmetry — the severe one
main.goappliedmonitors.ApplyDefaultMonitors()+ CLI overrides +ApplyDefaults()to the startup config. The reload path used a bareutil.LoadConfig. So the two disagreed about what the configuration contained: every monitorApplyDefaultMonitorshad auto-added is absent from the file, so on the first reload it looked REMOVED and was silently stopped.Measured with the shipped chart, editing only
dns-health:The
-debug/-dry-run/-log-*flags were also silently reverted by any reload. Fixed by constructing the normalizer once and installing it on the reload coordinator (SetConfigNormalizer), so both paths normalize identically.2.
diff.RemediationChangedwas computed and then dropped on the floorEditing
dryRunormaxRemediationsPerHourproduced a "reload succeeded" event while the registry kept its startup values until a pod restart. That is silent staleness on the exact kill-switch an operator reaches for mid-incident. AddedRemediatorRegistry.ApplyConfig(+SetMaxRemediationsPerHour) and an optionalReconfigurableRemediationExecutorinterface the detector now invokes.3. Settings-only edits reported "no changes"
ComputeConfigDiffonly inspects monitors/exporters/remediation, so e.g. asettings.logFileedit fell through to a cheerful success event.New
reload.ClassifyReloadseparates what is genuinely re-initialized in place from what is latched at process startup:settings.nodeNamesettings.logOutput/logFilefeatures.enableProfiling/profilingPortremediation.enabledfalse→trueremediation.coordination.*Restart-required changes now emit a
ConfigReloadRestartRequiredwarning event naming each field and why. Per the ticket, an honest "this needs a rollout" is an acceptable outcome; pretending it applied is not.4.
reload.enabledwas parsed and never readA knob that silently does nothing is the same bug class. Now honored in both directions. It became
*boolso absent (→ enabled, preserving today's behaviour for every existing deployment) is distinguishable from an explicitfalse, which now logs loudly that edits require a rollout.Also
243 did not need splitting — the scope stayed bounded once the root causes were identified.
246 — bind-ordering guard + liveness/readiness split
Ordering guard (both verified to fail when the ordering is inverted)
createExportersis now an explicit phase 1 (health server) / phase 2 (networked exporters), with two guards:runHealthCheck, the exact code path the kubelet exec probe runs.main.goand fails if any networked constructor is ordered ahead ofstartHealthServer. This catches someone inlining an exporter back in, which could otherwise slip past a test that stubs the seam.I deliberately inverted the ordering to confirm both fail rather than passing vacuously:
Liveness vs readiness
Previously
healthywas initializedtrueand never set false in production, so/healthzwas correct only by accident, and/readynever reflected downstream failure at all./healthz(liveness) consults only process-internal state, documented and tested to ignore dependency state. A broken API server must never restart the agent on the degraded nodes it exists to observe./ready(readiness) gainsAddReadinessCheckplus per-exporterSetDependencyStatus, fed from the detector's export loop.Added 3-consecutive-failure hysteresis before a dependency counts: node-doctor is a DaemonSet, and one transient export error flipping every pod to NotReady would stall rolling updates fleet-wide for a blip that already healed. One success resets the counter.
Startup budget
Confirmed
initialDelaySeconds 10 + (failureThreshold 20 x periodSeconds 5) = 110s. Because the health listener now provably binds in phase 1, it consumes ~none of that budget. Documented invalues.yaml.templatewith a floor, and pinned by a chart contract test.Not regressed
Probes remain
execagainst the per-pod unix socket. A new chart test fails ifhttpGeton 8080 is ever reintroduced, since that host port conflict still exists on a1pinode01 today. Another asserts liveness and readiness use distinct commands, and that startup uses liveness semantics (a downstream down at boot must not prevent start).Chart
Edited
values.yaml.templateandconfigmap.yaml, regeneratedvalues.yamlviamake helm-generate; both committed.make helm-lint(which runshelm-verify-generated) passes.Verification
make build✅make test✅go test -race ./...✅ (clean)go vet ./...✅ ·gofmtcleanmake helm-lint✅Task: #node-doctor-243
Task: #node-doctor-246
https://claude.ai/code/session_01Av4whriQf6KqJgRxGKQP7N
Follow-up: readiness must not blind Prometheus (added in
f11cc76)The readiness change interacted badly with the chart. The agent Service is selected by the ServiceMonitor and exists only for Prometheus discovery — no request traffic flows through it — but it did not set
publishNotReadyAddresses, which defaults tofalse. Kubernetes therefore drops NotReady pods out of Endpoints, so a degraded node would stop being scraped and everynode_doctor_*series for it would disappear.Worse, silently:
NodeDoctorNoMetricsisabsent(node_doctor_monitor_uptime_seconds)— fleet-wide, firing only when every node stops reporting. A one- or five-node blackout raises nothing.Fixed by setting
publishNotReadyAddresses: true, with the reasoning recorded at both the template and values site so it isn't "cleaned up" later, plus a chart contract test (verified to fail when flipped tofalse). Readiness still gates rollouts and still surfaces degradation; it no longer destroys observability while doing so.Peer discovery is unaffected — nothing in the Go code resolves this Service; it is referenced only by the ServiceMonitor and NOTES.txt.
Rollout deadlock question: it cannot deadlock — verified from source
Read
pkg/controller/daemon/update.go(k8s v1.35.4) rather than relying on recollection. WithmaxSurge == 0(our case — no surge, hostPort collision):Old pods that are already unavailable are deleted unconditionally —
maxUnavailablegates onlycandidatePodsToDelete, i.e. still-healthy old pods.So a shared downstream failure (API server briefly unavailable, hitting all 13 pods at once) makes the rollout go faster for not-yet-updated pods: they are already unavailable, so replacing them costs no budget. The budget only throttles taking down pods that are still healthy — which is the behaviour you want — and unavailable new pods shrink
remainingUnavailable, pausing further healthy-pod churn until they recover. That pause self-heals when the dependency does.Notably this also rules out the nastier variant: "the shipped version is broken, every pod is NotReady, so I can't roll out the fix." Those pods are all in
allowedReplacementPodsand get replaced immediately.Per-node metrics alert: recommend filing separately, not in this PR
absent(node_doctor_monitor_uptime_seconds)is genuinely inadequate for partial blackouts — but that is a pre-existing gap, not one this PR creates, andpublishNotReadyAddresses: trueremoves the mechanism by which this PR would have widened it. So it is separable, and I recommend filing it. Reasoning:absent()per node needs templating per node (not viable). Themetric offset 1h unless metricidiom false-fires on every legitimate node drain, scale-down, or autoscaler event — exactly the trap you flagged.(node_doctor_monitor_uptime_seconds offset 1h unless node_doctor_monitor_uptime_seconds) and on(node) kube_node_info. That introduces a hard dependency on kube-state-metrics that this chart has nowhere else, plus label-alignment assumptions I cannot verify without cluster access (I confirmed our metric carries anodelabel; I cannot confirm the kube-state-metrics side from here, and I'm barred from touching the cluster).for:window needs tuning against real rollout gaps, or every deploy pages. That wants observation data, not a guess.Shipping an alert that false-fires on routine node lifecycle is worse than the status quo, because it trains people to ignore it.
up{job="node-doctor"} == 0is a cheaper partial-credit option (it disappears rather than firing when a node is removed) and may be the right first step — but it is still a real design decision deserving its own PR.