Skip to content

feat(monitoring): scrape etcd, scheduler and controller-manager [DO NOT MERGE until Talos apply] - #170

Open
jdwillmsen wants to merge 1 commit into
mainfrom
feat/scrape-control-plane-metrics
Open

feat(monitoring): scrape etcd, scheduler and controller-manager [DO NOT MERGE until Talos apply]#170
jdwillmsen wants to merge 1 commit into
mainfrom
feat/scrape-control-plane-metrics

Conversation

@jdwillmsen

Copy link
Copy Markdown
Member

DO NOT MERGE until the Talos machine config is applied

Every ArgoCD app here runs prune + selfHeal, so merging this is deploying it. The scrape targets this adds cannot come up until the control-plane bind addresses are widened by a Talos machine-config apply — which is human-gated and has not run yet.

Merging early does not fail safe: Prometheus records up == 0 for six targets and the newly-enabled KubeSchedulerDown / KubeControllerManagerDown / etcdInsufficientMembers rules fire, describing a control-plane outage that is not happening.

Gate: talops reconcile --plan → review → talops reconcile → confirm 0.0.0.0:2381, 0.0.0.0:10257, 0.0.0.0:10259 LISTEN on all three control planes → then merge this.

The Talos side is already merged: infrastructure#63 (e189311) adds listen-metrics-urls and both bind-address values to bootstrap/internal/talos/patches/control-plane.yaml. It has never been applied.

Verified live on all three control planes at the time of writing — unchanged from the 2026-07-27 and 2026-07-29 baselines:

$ talosctl -n <ip> netstat -l --tcp | grep -E '2381|10257|10259'
192.168.1.98    127.0.0.1:10257  LISTEN
192.168.1.98    127.0.0.1:10259  LISTEN
192.168.1.125   127.0.0.1:10257  LISTEN
192.168.1.125   127.0.0.1:10259  LISTEN
192.168.1.241   127.0.0.1:10259  LISTEN
192.168.1.241   127.0.0.1:10257  LISTEN

2381 is absent on all three.

What this changes

tenants/platform/services/kube-prometheus-stack/values.yaml

  • kubeScheduler.enabled / kubeControllerManager.enabledtrue (stock exporters)
  • kubeEtcd.enabledtrue, with service.enabled and serviceMonitor.enabledfalse
  • defaultRules.rules: etcd, kubeControllerManager, kubeSchedulerAlerting, kubeSchedulerRecordingtrue
  • prometheus.prometheusSpec.scrapeConfigSelectorNilUsesHelmValuesfalse, matching the four sibling selector flags already there

tenants/platform/services/kube-prometheus-stack/postInstall/scrapeconfig-kube-etcd.yaml (new) — a ScrapeConfig for etcd using node service discovery.

Two design decisions, both driven by live evidence

1. Scheduler and controller-manager need no address list — the premise on the ticket was wrong.

The ticket proposed node service discovery for all three components. That is unnecessary for two of them. Both run as hostNetwork static pods in kube-system carrying exactly the labels the chart's default selector uses, and their pod IP is the node IP:

$ kubectl get pods -n kube-system -l component=kube-scheduler \
    -o custom-columns='POD:.metadata.name,PODIP:.status.podIP,HOSTIP:.status.hostIP'
POD                            PODIP           HOSTIP
kube-scheduler-talos-6iz-oey   192.168.1.98    192.168.1.98
kube-scheduler-talos-fow-vbk   192.168.1.125   192.168.1.125
kube-scheduler-talos-oam-s4g   192.168.1.241   192.168.1.241

Labels present on all three (kubectl get pods -n kube-system -l tier=control-plane --show-labels): component=kube-scheduler, component=kube-controller-manager. So enabling the stock exporters commits no addresses and adds no custom config. Rendered result:

Service        …-kube-controller-manager  selector={'component': 'kube-controller-manager'}  port=10257  clusterIP=None
Service        …-kube-scheduler           selector={'component': 'kube-scheduler'}           port=10259  clusterIP=None
ServiceMonitor …-kube-controller-manager  scheme=https  insecureSkipVerify=True  bearerTokenFile=…/serviceaccount/token
ServiceMonitor …-kube-scheduler           scheme=https  insecureSkipVerify=True  bearerTokenFile=…/serviceaccount/token

insecureSkipVerify is the chart default on Kubernetes >= 1.23 and is required, not incidental: both components generate a self-signed serving cert at process start. Authz is covered — the Prometheus ClusterRole already grants nonResourceURLs: [/metrics].

2. etcd genuinely cannot use the chart exporter.

etcd on Talos is a host-level service, not a pod:

$ kubectl get pods -A -l component=etcd
No resources found

$ talosctl -n 192.168.1.98 service etcd
ID       etcd
STATE    Running
HEALTH   OK

So the chart's component=etcd selector matches nothing, and the only fallback is kubeEtcd.endpoints — a literal list of control-plane IPs. Those are DHCP-assigned here, and a scrape config that stops resolving the moment an address moves is useless precisely during the incident class these metrics exist to diagnose. Node service discovery re-resolves from the API every cycle and commits no address.

Two details in that ScrapeConfig worth reviewing:

  • The keep rule matches __meta_kubernetes_node_labelpresent_node_role_kubernetes_io_control_plane, not the label's value. node-role.kubernetes.io/control-plane is set with an empty value on these nodes, so a value regex keeps nothing.
  • jobName: kube-etcd — every rule in the bundled etcd group selects on job=~".*etcd.*". A name without "etcd" in it loads the rules and leaves them permanently without data.

The trap this nearly shipped with

kubeEtcd.enabled: false does not only disable the etcd exporter — it also gates the etcd rules:

templates/prometheus/rules-1.14/etcd.yaml:7:
{{- if and … .Values.defaultRules.create .Values.kubeEtcd.enabled .Values.defaultRules.rules.etcd }}

My first draft set defaultRules.rules.etcd: true while leaving kubeEtcd.enabled: false, and the render came back with no etcd rule group at all — metrics arriving with nothing watching them, and a green diff. Setting enabled: true with only service/serviceMonitor off renders the rules while creating neither of the two objects that cannot work without a pod to select. The endpoints.yaml template is separately gated on a non-empty kubeEtcd.endpoints, so no Endpoints object appears either.

Validation

Chart rendered at the pinned revision (87.19.1) against the live Kubernetes version, via rtk proxy so the output is not truncated:

$ rtk proxy helm template platform-kube-prometheus-stack <chart> --namespace monitoring \
    --kube-version 1.35.1 --values tenants/platform/services/kube-prometheus-stack/values.yaml
helm exit=0    docs: 74    (no truncation marker)

Object-level diff against origin/main's values, same chart and flags — 8 added, 0 removed, 1 modified:

baseline objects: 66   new objects: 74
=== ADDED ===
  + PrometheusRule platform-kube-prometheus-s-etcd
  + PrometheusRule platform-kube-prometheus-s-kube-scheduler.rules
  + PrometheusRule platform-kube-prometheus-s-kubernetes-system-controller-manager
  + PrometheusRule platform-kube-prometheus-s-kubernetes-system-scheduler
  + Service platform-kube-prometheus-s-kube-controller-manager
  + Service platform-kube-prometheus-s-kube-scheduler
  + ServiceMonitor platform-kube-prometheus-s-kube-controller-manager
  + ServiceMonitor platform-kube-prometheus-s-kube-scheduler
=== REMOVED ===
=== MODIFIED ===
  ~ Prometheus platform-kube-prometheus-s-prometheus

The only Prometheus CR field that moved, and a scan of the 8 added objects for committed addresses:

scrapeConfigSelector:
  base={'matchLabels': {'release': 'platform-kube-prometheus-stack'}}
  new ={}

IPv4 literals found: 0

Rule groups now rendering:

etcd                                  n=15  etcdMembersDown,etcdInsufficientMembers,etcdNoLeader,etcdHighNumberOfLeaderChanges,…
kube-scheduler.rules                  n=9   cluster_quantile:scheduler_scheduling_attempt_duration_seconds:histogram_quantile,…
kubernetes-system-controller-manager  n=2   KubeControllerManagerDown,KubeControllerManagerInstanceUnreachable
kubernetes-system-scheduler           n=2   KubeSchedulerDown,KubeSchedulerInstanceUnreachable

Repo gates, all green:

yamllint (CI config, tenants/ bootstrap/)          exit=0, 0 findings
kubeconform (CI find + flags)                      102 resources / 86 files — Valid: 51, Invalid: 0, Errors: 0
kubeconform (the new ScrapeConfig, -verbose)       ScrapeConfig kube-etcd is valid — Skipped: 0
platformctl tenants validate tenants/              all tenant.yaml files valid — SUCCESS
tools/check-image-pins.py                          exit=0
tools/tests (image-pin false negatives)            Ran 32 tests — OK
tools/sync-monitoring-crds.py                      foundation-crds.yaml is in sync with chart revision 87.19.1

The ScrapeConfig is genuinely schema-checked, not skipped (Skipped: 0). It also validates against the live cluster's CRD schema rather than only the catalog copy:

VALID against LIVE CRD schema: scrapeconfigs.monitoring.coreos.com v1alpha1
jobName = kube-etcd | scheme = HTTP
sd      = [{'role': 'Node'}]

CRD ownership — no bootstrap-ordering change needed

scrapeconfigs.monitoring.coreos.com is not vendored in bootstrap/crds/foundation-crds.yaml (which carries only servicemonitors, podmonitors, prometheusrules). It does not need to be:

  • It is present in the cluster today, installed by the chart itself (crds.enabled: true), annotated operator.prometheus.io/version: 0.92.1.
  • postInstall/ is a source in the same Application as the chart (helm-charts/tenant-envelope/templates/services-appset.yaml), and that Application sets SkipDryRunOnMissingResource=true, which is exactly the CRD-created-in-the-same-sync case.
  • Because only the chart owns this CRD, there is no dual-manager SSA fight, so no ignoreDifferences entry is required — unlike the three vendored CRDs, which have one each.

What cannot be verified until after the apply

Nothing below is claimed as done. All of it is post-deploy work:

  • count by (job) (up) including kube-etcd, kube-scheduler, kube-controller-manager — 3 targets each
  • etcd_disk_wal_fsync_duration_seconds and etcd_server_leader_changes_seen_total returning data
  • Whether :10257 / :10259 accept the Prometheus service-account token against the Talos-issued serving cert in practice — the config is right in principle, but only a live scrape proves it
  • Whether etcd's :2381 truly needs no scrape-side TLS (expected plain HTTP, since the merged Talos patch sets the scheme to http)
  • All control-plane alert rules loading inactive rather than firing
  • ArgoCD platform-kube-prometheus-stack Synced + Healthy

Known gaps, deliberately not in this PR

  • etcd Grafana dashboard. The ticket lists it. Dashboards here are ConfigMaps sourced from grafana.com (DASHBOARDS.md), and grafana.enabled: false means the chart's bundled etcd dashboard never renders. No etcd dashboard exists in the repo. Adding a large JSON blob whose datasource wiring cannot be checked until data exists belongs in its own change, after the apply.
  • kube-proxy stays off. Verified it serves /metrics on 127.0.0.1:10249 with no metrics-bind-address override in its args, so it has the same problem and needs the same kind of machine-config fix. Out of scope here.
  • Exposure. 2381 is unauthenticated (serves only /metrics and /health, no client API) and becomes LAN-reachable. 10257/10259 keep their authn/authz. Recorded against the API-exposure-restriction work rather than left undocumented; note NetworkPolicy is currently unenforced cluster-wide, so there is no in-cluster restriction either.

Test plan after the apply

kubectl port-forward -n monitoring svc/platform-kube-prometheus-s-prometheus 9090:9090
# expect kube-etcd / kube-scheduler / kube-controller-manager, 3 targets each
curl -s 'localhost:9090/api/v1/query?query=count%20by%20(job)%20(up)'
curl -s 'localhost:9090/api/v1/query?query=etcd_server_leader_changes_seen_total'
curl -s 'localhost:9090/api/v1/query?query=histogram_quantile(0.99,rate(etcd_disk_wal_fsync_duration_seconds_bucket[5m]))'
# expect every control-plane rule inactive, none firing
curl -s localhost:9090/api/v1/rules | jq '[.data.groups[].rules[] | select(.name|test("etcd|KubeScheduler|KubeControllerManager")) | {name,state}]'

Also re-run the cert-approver dual-ownership pre-flight recorded on the ticket around the apply: confirm the kubelet-serving-cert-approver Deployment's managers stay argocd-controller (Apply) + kube-controller-manager (Update) with no Talos manager, and its image stays pinned at 0.11.0.

Refs JDWLABS-197

🤖 Generated with Claude Code

The cluster had no telemetry for the three control-plane components whose
degradation is cluster-fatal, so an etcd latency spike on 2026-07-24 had to be
diagnosed from apiserver logs alone and was diagnosed wrongly.

Scheduler and controller-manager need no address list: both run as hostNetwork
static pods labelled component=kube-scheduler / component=kube-controller-manager,
which the chart's default headless-Service selector already matches, and their
pod IP is the node IP. Enabling the stock exporters is sufficient.

etcd is a Talos host service with no pod, so the chart's selector matches
nothing and its only fallback is a hardcoded list of control-plane addresses.
Those addresses are DHCP-assigned, so a ScrapeConfig using node service
discovery is used instead - Prometheus re-resolves them every discovery cycle
and no address is committed.

kubeEtcd.enabled stays true with only its Service and ServiceMonitor disabled:
the bundled etcd rules are gated on that flag as well as on
defaultRules.rules.etcd, so disabling it ships metrics with no alerts.

Refs JDWLABS-197

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +6 to +40
spec:
# The chart's kubeEtcd exporter cannot be used: etcd here is a host-level
# service with no pod, so the default component=etcd Service selector matches
# nothing and the only fallback is a hardcoded address list. Node service
# discovery re-resolves control-plane addresses from the API on every
# discovery cycle, so a DHCP address change is followed instead of silently
# ending the scrape.
#
# The job name must contain "etcd": every rule in the bundled etcd group
# selects on job=~".*etcd.*", so a different name loads the rules but leaves
# them permanently without data.
jobName: kube-etcd
# Port 2381 is etcd's dedicated metrics listener, which serves only /metrics
# and /health over plain HTTP. The client port (2379) also serves metrics but
# demands client certificates; 2381 exposes no client API at all, so no
# scrape-side TLS or credentials are involved.
scheme: HTTP
kubernetesSDConfigs:
- role: Node
relabelings:
# The control-plane role label is set with an EMPTY value, so matching on
# the label's value keeps nothing. labelpresent is the only reliable test.
- action: keep
sourceLabels: [__meta_kubernetes_node_labelpresent_node_role_kubernetes_io_control_plane]
regex: "true"
# Node discovery addresses the kubelet by default; retarget to the node's
# InternalIP on the etcd metrics port.
- action: replace
sourceLabels: [__meta_kubernetes_node_address_InternalIP]
regex: (.+)
targetLabel: __address__
replacement: $1:2381
- action: replace
sourceLabels: [__meta_kubernetes_node_name]
targetLabel: node
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants