016 — Metrics port auto-exposure on Service
- Status: Accepted
- Date: 2025-04-25
Context
Most apps don’t expose /metrics on the same port as their main API:
- Security. The metrics endpoint shouldn’t be reachable from
Ingress. Splitting the port keeps it cluster-internal by default. - Allowed-hosts middleware. Django, Spring, FastAPI all have host-validation middleware. Mounting the metrics path on the API port forces relaxing the middleware for in-cluster scrape probe IPs — a common source of audit findings.
- Scrape isolation. A 503-storm from a misbehaving app shouldn’t impact metrics scraping; running them on different processes / sockets gives that for free.
The mechanical consequence: the chart needs a “metrics” port on the Service, with a matching containerPort in the pod template. Users who manage this manually end up writing the same port-pair declaration on every workload. The boilerplate is small but multiplied by the number of workloads.
A second concern: some workloads have no main Service at all (a pure batch worker, a gRPC client). They still want to expose /metrics. A monitoring-only Service is the right shape.
Decision
A single switch at integrations.monitoring.defaults.exposeService.enabled: true (overridable per workload via metrics.exposeService.enabled) auto-emits the metrics port. The chart’s behaviour is:
- Workload has a main Service (
service.enabled: trueand at least one entry inservice.ports): append ametricsport to the Service’sports[]and a matchingcontainerPortto the workload’s main container. Sidecars, init containers and job containers never get it —ContainerPort.namehas to be unique within a Pod, and they do not serve the endpoint. Port name is fixed asmetrics(Kubernetes convention). - Workload has no main Service: emit a dedicated Service with only the metrics port. Name:
<release-fullname>-<workload>-metrics. On a StatefulSet the governing headless Service takes the port instead whenever the chart renders it; the dedicated Service appears only when neither Service exists. This Service is also picked up by the ServiceMonitor / VMServiceScrape (when CRD discovery + service target is selected — see ADR 008). metrics.type: pod: this whole feature is skipped. PodMonitor / VMPodScrape don’t need a Service — the scrape happens directly against pod IPs.service.ports.metricsalready declared: the auto-injection is skipped to avoid a duplicate port. The user’s explicit declaration wins.
The metrics port is always rendered last in any ports[] list — see ADR 014 for the http-first / metrics-last rule.
Defaults at integrations.monitoring.defaults.exposeService:
enabled: false # opt-in
port: 9090 # ServicePort
targetPort: 9090 # containerPort
Consequences
What this enables:
- One flag (
integrations.monitoring.defaults.exposeService.enabled: trueplusmetrics.enabled: trueper workload) gets a fully-wired metrics port on Service, pod template, and ServiceMonitor / VMServiceScrape. - Pure batch workers can be scraped via a metrics-only Service without a main Service.
- The “metrics is not on http” pattern is easy to do right and hard to do wrong.
What it costs:
- When a user declares
service.ports.metricsexplicitly, they may not realise they’ve disabled the auto-exposure feature (the chart doesn’t emit two ports). The skipping rule is documented next to theintegrations.monitoring.defaults.exposeServiceblock invalues.yaml. - The dedicated metrics-only Service for service-less workloads adds an object that an inattentive operator might miss. We accept this — it matches the intuition that “every scrape target has a Service.”
- The fixed port name
metricsis a chart convention. Users with idiosyncratic monitor configs that target other names must declare the port manually inservice.portsinstead.
Worked example
integrations:
monitoring:
defaults:
enabled: true
exposeService:
enabled: true
port: 9090
targetPort: 9090
deployments:
api:
service:
ports:
http:
port: 80
targetPort: 8080
# No explicit metrics port; auto-injection adds one
metrics:
enabled: true
batch:
service:
enabled: false # no main Service — but metrics scraping still wanted
metrics:
enabled: true
type: service # explicit: a metrics-only Service is rendered
Result:
<release>-apiService with two ports:http(80) andmetrics(9090).<release>-batch-metricsService with one port:metrics(9090).- ServiceMonitor / VMServiceScrape per workload, targeting the
metricsport name.
Alternatives considered
- Always require explicit declaration. Rejected: the boilerplate costs scale with workload count and the right shape is the same every time.
- Auto-expose on the main port if no metrics port declared. Rejected: hides the security / middleware caveats described in Context.
- Configurable port name. Rejected:
metricsis the de-facto Kubernetes convention; supporting other names invites ServiceMonitor configuration drift.
References
values.yaml— theintegrations.monitoring.defaults.exposeServiceblock.templates/service.yaml— port injection (the metrics port lands last).templates/_metrics.tpl—uhc.metricsExposeService.- Tests:
tests/service_test.yaml,tests/fixtures/service-existing-metrics-port.yaml. - Related: ADR 006, ADR 008, ADR 014.