materialize-monitoring Helm Reference#
Unified observability stack for Materialize: Alloy-based pipeline, optional bundled backends (Loki / Thanos / Grafana / Alertmanager), and the dashboards / alerts / rules that consume the Materialize metrics surface.
Source Code#
Requirements#
Kubernetes: >=1.27.0-0
Storage Requirements#
Using Thanos or Loki (both enabled by default) requires object storage (such as an AWS S3 bucket) for long-term storage.
In development, Thanos may use a PVC for less-reliable storage. Loki however will not run without object storage. You may consider Garage or RustFS or MinIO for manually provisioned object storage.
Included Subcharts#
| Repository | Name | Version |
|---|---|---|
| https://grafana.github.io/helm-charts | alloy(alloy-agent) | 1.15.0 |
| https://grafana.github.io/helm-charts | alloy(alloy-gateway) | 1.15.0 |
| https://kubernetes-sigs.github.io/metrics-server | metrics-server | 3.14.0 |
| oci://ghcr.io/grafana-community/helm-charts | grafana | 14.3.0 |
| oci://ghcr.io/grafana-community/helm-charts | loki | 20.2.2 |
| oci://ghcr.io/grafana/helm-charts | grafana-operator | 5.25.0 |
| oci://ghcr.io/prometheus-community/charts | alertmanager | 1.43.3 |
| oci://ghcr.io/prometheus-community/charts | kube-state-metrics | 8.8.0 |
| oci://ghcr.io/prometheus-community/charts | prometheus-node-exporter(node-exporter) | 4.60.1 |
| oci://ghcr.io/prometheus-community/charts | prometheus-postgres-exporter(postgres-exporter) | 8.2.0 |
| oci://ghcr.io/thanos-community/helm-charts | thanos | 0.48.0 |
Values#
Globals#
Values that are passed to all subcharts
| Key | Type | Default | Description |
|---|---|---|---|
| global | string | "" | Override the registry for all images in this chart and its subcharts. Leave empty to use upstream defaults. |
| global | list | [] | Image pull secrets for this chart's own workloads and for the subcharts that read a global. Not all of them do.
Reaches this chart's own workloads, plus Thanos, Grafana, kube-state-metrics
and node-exporter. It does **not** reach every subchart on its own, because
they do not agree on where to read it from:
|
| global | string | "cluster.local" | The cluster's DNS domain.
`cluster.local` is a **default, not a fact** — clusters get built with
`--cluster-domain=cluster.internal` or a site-specific domain often enough
that hardcoding it is a reliable way to ship a feature that works everywhere
we test and nowhere a customer runs. It lives under One subchart is not covered by that propagation. Load-bearing for |
Main chart configuration#
Configuration for the main chart
| Key | Type | Default | Description |
|---|---|---|---|
| nameOverride | string | "" | Standard Helm name override. Note that in umbrella charts, fullname may be shorted. See fullnameOverride. |
| fullnameOverride | string | "mzmon" | Standard Helm fullname override. Note that in umbrella charts, this may be shortened to avoid long prefixes. (and have more stable names in resources) |
| namespaceOverride | string | "" | Namespace override for default workloads. |
| clusterName | string | "default" | Name of this Kubernetes cluster, stamped as `cluster` on every log line, metric sample and alert. One value, read everywhere the stack labels a signal: the Alloy agent and
gateway (through Give each cluster its own. The label is what tells clusters apart once they
share a log or metric store, a notification channel or an incident tool, and
the default distinguishes nothing. Letters, digits, |
Subchart enablement (Helm tags)#
Group toggles for subchart enablement. Subchart enablement has two layers, in precedence order:
- Per-chart circuit breaker (
<chart>.enabled). Each subchart dependency carries acondition: <chart>.enabledinChart.yaml. The correspondingenabled:key is left commented-out under each subchart block invalues.yaml; uncommenting it force-includes (true) or force-excludes (false) that chart regardless of any tag state. Helm evaluatescondition:first and only falls through to tags when the path is absent from values, so this is a hard override. - Helm tags (the
tags:block). When no<chart>.enabledis set, enablement is decided by tags. Each dependency inChart.yamlcarries the masterdefaulttag, a group tag (e.g.bundled-backends), and a per-chart tag (e.g.loki); a dependency is included if any of its tags evaluates true (OR semantics). To opt one chart out of an otherwise-enabled group, set the group tag false and flip the per-chart tags individually — or use the circuit breaker.
Group ↔ chart mapping:
| Group tag | Charts |
|---|---|
default | pipeline + bundled-backends + managed-grafana |
groups + kube-state-metrics (recommended stack) | |
pipeline | alloy-agent, alloy-gateway |
bundled-backends | loki, thanos, alertmanager |
managed-grafana | grafana, grafana-operator |
cluster-metrics | kube-state-metrics, node-exporter, |
metrics-server | |
crds | prometheus-operator-crds, grafana-operator-crds |
(in the sibling materialize-monitoring-crds chart) |
default is the only group on by default and enables the full
recommended stack. --set tags.default=false turns everything off, so
you can enable a single group (bundled-backends, managed-grafana,
pipeline, cluster-metrics) or individual charts on top. Profile
preset values files under profiles/ flip these appropriately.
(kube-state-metrics and node-exporter are in default, but
metrics-server is not — most clusters already run metrics-server;
enable it via tags.cluster-metrics or tags.metrics-server if yours
doesn’t. A cluster that already runs its own node-exporter should turn
ours off with the node-exporter.enabled: false circuit breaker —
setting tags.node-exporter: false does nothing while tags.default
is true, because tags are OR’d.)
postgres-exporter belongs to no group, default included. It watches
databases you name with credentials you create, so it stays off until you
set tags.postgres-exporter: true.
| Key | Type | Default | Description |
|---|---|---|---|
| tags | bool | true | Enable all recommended defaults. You can `--set tags.default=false` to disable all services and explicitly enable others. |
| tags | bool | false | Enable both Alloy releases (agent + gateway). |
| tags | bool | false | Enable Loki, Thanos, and Alertmanager as a group. (Grafana is in the `managed-grafana` group.) |
| tags | bool | false | Enable kube-state-metrics and metrics-server as a group. |
| tags | bool | false | Per-chart override: enable just the Alloy agent. OR'd with `tags.default` / `tags.pipeline`. |
| tags | bool | false | Per-chart override: enable just the Alloy gateway. OR'd with `tags.default` / `tags.pipeline`. |
| tags | bool | false | Per-chart override: enable just Loki. OR'd with `tags.default` / `tags.bundled-backends`. |
| tags | bool | false | Per-chart override: enable just Thanos. OR'd with `tags.default` / `tags.bundled-backends`. |
| tags | bool | false | Per-chart override: enable just Grafana standalone. OR'd with `tags.default` / `tags.managed-grafana`. |
| tags | bool | false | Per-chart override: enable just Grafana operator. OR'd with `tags.default` / `tags.managed-grafana`. |
| tags | bool | false | Per-chart override: enable just Alertmanager. OR'd with `tags.default` / `tags.bundled-backends`. |
| tags | bool | false | Per-chart override: enable just kube-state-metrics. OR'd with `tags.default` / `tags.cluster-metrics`. |
| tags | bool | false | Per-chart override: enable just node-exporter. OR'd with `tags.default` / `tags.cluster-metrics`. |
| tags | bool | false | Per-chart override: enable just metrics-server. OR'd with `tags.cluster-metrics`. |
| tags | bool | false | Enable postgres_exporter. In no group, `default` included: it watches databases you list under `postgres-exporter.targets`. |
Priority classes#
Scheduling priority shared across the stack’s subcharts. Two PriorityClasses, created here and referenced by name from the subchart blocks further down. The split is about what losing a pod costs you:
| Class | Used by | Losing a pod means |
|---|---|---|
monitoring-critical | alloy-agent, node-exporter, alloy-gateway | a blind spot with no replica to cover it |
monitoring-scalable | loki, thanos, grafana, alertmanager, KSM | reduced capacity a surviving replica or a retry absorbs |
The per-node collectors are singletons per node: when the agent or node-exporter is evicted from a node, nothing else reports that node, and the gap is permanent (there is no backfill). The backends are replicated, buffered, or both — Alloy retries writes, so a Loki ingester that is evicted costs latency rather than data. The gateway is graded critical despite being a Deployment because it is the single egress choke point for every signal.
Both classes set preemptionPolicy: Never. That is the load-bearing
choice: priority still decides scheduling-queue order and, more importantly,
which pod the kubelet evicts first under node pressure — but monitoring will
never evict a Materialize pod to make room for itself. Monitoring that takes
down the thing it monitors is worse than monitoring that is late.
Both sit well below system-cluster-critical (2000000000) and
system-node-critical (2000001000), so cluster plumbing still outranks us.
PriorityClasses are cluster-scoped. Two releases of this chart in one
cluster will fight over these objects. Set create: false on all but one, or
rename them per release — and if you rename, update the priorityClassName
values in the subchart blocks to match. A priorityClassName naming a class
that does not exist does not degrade: the API server rejects the pod. The
chart warns at render time when it can tell that has happened.
| Key | Type | Default | Description |
|---|---|---|---|
| priorityClasses | bool | true | Create the PriorityClass objects. Set false when they are managed elsewhere (another release, or cluster-wide by a platform team); the `priorityClassName` references below still apply, so the classes must already exist. |
| priorityClasses | object |
{
"description": "Materialize monitoring components whose loss creates a blind spot with no replica to cover it. Never preempts other workloads.",
"name": "monitoring-critical",
"preemptionPolicy": "Never",
"value": 1000000
} | Priority for components whose loss creates an unrecoverable blind spot — the per-node collectors and the egress gateway. |
| priorityClasses | object |
{
"description": "Materialize monitoring backends that are replicated or write-buffered. Evicted before the critical tier; never preempts other workloads.",
"name": "monitoring-scalable",
"preemptionPolicy": "Never",
"value": 1000
} | Priority for replicated or buffered components, where losing a pod costs capacity rather than visibility. |
Network policies#
NetworkPolicies for the components whose own charts ship none. Most of the stack polices itself, and each component is configured in its own block further down:
| Component | Key |
|---|---|
| Alloy agent | alloy-agent.networkPolicy |
| Alloy gateway | alloy-gateway.networkPolicy |
| Loki | loki.networkPolicy |
| Thanos | thanos.global.networkPolicies |
| Grafana | grafana.networkPolicy |
| kube-state-metrics | kube-state-metrics.networkPolicy |
| node-exporter | node-exporter.networkPolicy |
This block covers the three subcharts that render no networkpolicy.yaml at
all — Alertmanager, grafana-operator, and metrics-server — so the
stack does not leave three unpoliced pods in an otherwise policed namespace.
The policies are rendered by this chart, from templates/networkpolicies.yaml.
The shape mirrors Loki’s, because Loki is the component whose policy set this
repo has actually operated: allow the release to talk to itself, allow DNS,
name the destinations that live outside the cluster, deny the rest. Each app
gets one Ingress+Egress policy plus a separate DNS policy, for the same
reason Loki splits them — a rule you have to disable should not take the rest
of the policy with it.
A NetworkPolicy is a declaration, not always a control. Four limits are worth knowing before treating any of this as a boundary:
- It does nothing unless the CNI enforces it.
kindnet(the default inkind, and therefore in the E2E tiers) ignores NetworkPolicy entirely; Cilium and Calico enforce it. - Most CNIs do not apply pod policy to host-networked pods. Nothing in this
block is host-networked today, but
node-exporter.networkPolicycarries the same caveat at greater length, and it is the same caveat. - Rules written with
podSelectoralone stop at the namespace boundary. Underprofiles/split-namespace.values.yamlthe components no longer share a namespace, so those rules match nothing — addnamespaceSelectorentries through theextrahooks below, or turn the affected policy off. - Nothing here allows the kubelet’s liveness and readiness probes, which arrive from the node’s own IP. Cilium and Calico both exempt host-to-pod traffic so probes keep working; a CNI that does not would fail every pod in the namespace, Loki’s included, and that is the symptom to recognize.
Egress is where a wrong rule hurts: it fails silently, minutes or hours later,
somewhere other than the pod you changed. So egress is narrowed only where the
destination set is knowable from the chart. Where it is not — the API server’s
address, kubelet ports on node IPs, an arbitrary notification provider — the
rule is broad and says so, and egress.extra is where you narrow it against
facts only your cluster has.
Every component below takes the same eight keys, so they are described once here rather than three times over. Each component’s own entry then says only what is particular to it — which ports, and why that egress is as wide as it is.
| Key | Meaning |
|---|---|
enabled | Render this policy. Unset follows networkPolicies.enabled. |
ingress.ports | Ports opened to the rest of the component’s namespace. A bare number means TCP; {port, protocol} spells out anything else. |
ingress.allowExternal | Drop the source restriction entirely, so the ports above accept connections from any namespace and from outside the cluster. |
ingress.extra | Raw NetworkPolicyIngressRule entries, appended verbatim. The hook for a cross-namespace source. |
egress.inNamespace | Reach the other pods of this release, on any port. |
egress.dns | Reach cluster DNS. Rendered as a policy of its own. |
egress.external.ports / .cidrs | Ports and CIDRs outside the cluster. Ports with no CIDRs means those ports anywhere. |
egress.extra | Raw NetworkPolicyEgressRule entries, appended verbatim. |
| Key | Type | Default | Description |
|---|---|---|---|
| networkPolicies | bool | true | Render the policies in this block. Off skips all three at once; the per-component `enabled` keys below override it either way. |
| networkPolicies | object |
{
"egress": {
"dns": true,
"external": {
"cidrs": [
"0.0.0.0/0"
],
"ports": [
443,
587,
465
]
},
"extra": [],
"inNamespace": true
},
"enabled": null,
"ingress": {
"allowExternal": false,
"extra": [],
"ports": [
9093,
{
"port": 9094,
"protocol": "TCP"
},
{
"port": 9094,
"protocol": "UDP"
}
]
}
} | Alertmanager: reachable from the release namespace, free to egress to notification providers. Ingress covers
Both are listed even at the default Egress is deliberately wide. Alertmanager’s job is to reach PagerDuty, Slack,
an SMTP relay, or a webhook on someone’s internal network, and this chart
cannot know which — |
| networkPolicies | object |
{
"egress": {
"dns": true,
"external": {
"cidrs": [
"0.0.0.0/0"
],
"ports": [
443,
6443
]
},
"extra": [],
"inNamespace": true
},
"enabled": null,
"ingress": {
"allowExternal": false,
"extra": [],
"ports": [
9090
]
}
} | grafana-operator: metrics scraped by the Alloy gateway, egress to the API server and to whichever Grafana it reconciles. Ingress is only the metrics port. Egress carries
|
| networkPolicies | object |
{
"enabled": null,
"port": 9094
} | Grafana's unified-alerting gossip port, which the `grafana` subchart's own policy cannot open. A supplement, not a replacement: This renders the missing rule from the umbrella chart, which can. TCP and UDP, because memberlist uses both — TCP for the join and for state pushes, UDP for the gossip itself — and a TCP-only rule produces a cluster that forms and then does not converge. It renders only when |
| networkPolicies | object |
{
"egress": {
"dns": true,
"external": {
"cidrs": [
"0.0.0.0/0"
],
"ports": [
10250,
443,
6443
]
},
"extra": [],
"inNamespace": true
},
"enabled": null,
"ingress": {
"allowExternal": true,
"extra": [],
"ports": [
10250
]
}
} | metrics-server: serves the metrics API to the API server, scrapes every kubelet. Both directions are broad, and both for the same reason: the peers are the
control plane and the nodes, neither of which has an address this chart can
select. What the policy still buys is the rest of the surface: no other port on the pod accepts a connection, and egress is limited to the kubelet port plus the API server rather than anywhere at all. Egress |
Certificates#
cert-manager Certificate resources for in-cluster TLS.
Off by default, and cert-manager is never a hard dependency. With
enabled: false this section renders nothing at all, which is what keeps a
hardening feature from becoming a new prerequisite for an install that does not
want it.
When it is on, the chart renders one Certificate per component with the full
SAN ladder for that component’s Services. It does not turn TLS on anywhere:
issuing a certificate and using it are separate switches, on purpose. Each hop
moves through the phases in
Securing
under its own flag, once that component’s renewal behaviour has been proven.
Gated on values, never on .Capabilities. The obvious implementation is to
probe the API server for cert-manager.io/v1, and it is wrong: the same chart
would then render differently under helm template, the tier-0 Terraform render
check, and an ArgoCD server-side diff than it does under a live install — which
is exactly the class of bug the render tests exist to catch. A missing CRD with
this flag on is a clear apply-time failure with a name in it.
Two issuers, because they cannot be one#
| Internal | External | |
|---|---|---|
| Names | $svc, $svc.$ns, $svc.$ns.svc, $svc.$ns.svc.$clusterDomain, localhost | the public DNS name the load balancer answers on |
| Typical issuer | a self-signed root, or a private CA | ACME, or a private CA that signs your public names |
| Who verifies it | the stack’s own components | a browser |
A public ACME issuer cannot sign loki-distributor.monitoring.svc, and a
self-signed root means nothing to a browser, so collapsing these into one key
would make one of the two unusable. They are separate issuerRefs for that
reason and no other.
The external certificate is only needed for an L4 load balancer, which
passes TCP through and leaves TLS to terminate at the pod — so the material has
to exist in the cluster. An L7 load balancer terminating with a cloud-managed
certificate (ACM, Google Certificate Manager, Azure Key Vault) attaches it by
ARN or resource ID and the key never enters the cluster; for that shape, leave
external unset and pass the annotation through grafana.service.annotations.
| Key | Type | Default | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| certificates | bool | false | Render the cert-manager resources in this section. | ||||||||||||
| certificates | object |
{
"issuerRef": {
"group": "cert-manager.io",
"kind": "ClusterIssuer",
"name": ""
},
"selfSigned": {
"caSecretNamespace": "cert-manager",
"commonName": "mzmon-internal-ca",
"duration": "43800h",
"enabled": false,
"kind": "ClusterIssuer",
"renewBefore": "2160h",
"secretName": "mzmon-internal-ca"
}
} | The internal mesh: certificates the stack's components present to each other, carrying in-cluster DNS names. Supply an issuer with Prefer an issuer scoped to this stack over the cluster’s general-purpose
one. None of the receiving components here implement per-client
authorization — Loki, Thanos receive and Alloy’s receivers can be told to
require a certificate signed by a given CA, and none of them can be told that
this identity may write and that one may not. The whole authorization
decision is “is this signed by the CA we trust”, so the size of the trust
domain is the security property. Reusing a | ||||||||||||
| certificates | object |
{
"group": "cert-manager.io",
"kind": "ClusterIssuer",
"name": ""
} | An existing cert-manager issuer to sign internal certificates. `kind` is `Issuer` or `ClusterIssuer`. Leave `name` empty to use `selfSigned` instead. | ||||||||||||
| certificates | object |
{
"caSecretNamespace": "cert-manager",
"commonName": "mzmon-internal-ca",
"duration": "43800h",
"enabled": false,
"kind": "ClusterIssuer",
"renewBefore": "2160h",
"secretName": "mzmon-internal-ca"
} | Have the chart bootstrap a self-signed root and issue from it. Renders the three resources cert-manager needs for a private CA: a
Convenient rather than principled — a real deployment usually has a PKI
already, and
| ||||||||||||
| certificates | string | "mzmon-internal-ca" | Subject common name on the generated root. | ||||||||||||
| certificates | string | "43800h" | Lifetime of the **root**, not of the leaves. Long, because rotating a root means re-trusting it everywhere at once. | ||||||||||||
| certificates | string | "mzmon-internal-ca" | Secret the generated root's key pair lands in. | ||||||||||||
| certificates | string | "cert-manager" | Namespace that Secret is created in.
**cert-manager's cluster resource namespace, not this release's.** A
`ClusterIssuer` of kind `ca` reads its Secret from wherever cert-manager
was told its cluster resources live — `cert-manager` unless the install
overrode `--cluster-resource-namespace` — regardless of where the
`Certificate` that produced it was created. Render the CA into the
release namespace and the issuer sits `False` with `secret not found`
while the Secret it wants is one namespace away. Only read for | ||||||||||||
| certificates | object |
{
"dnsNames": [],
"issuerRef": {
"group": "cert-manager.io",
"kind": "ClusterIssuer",
"name": ""
}
} | The browser-facing name, for a Grafana behind an L4 load balancer. Only Grafana takes an external certificate today, because it is the only
component in the stack meant for a human and the only one this chart helps
expose. Set Leave | ||||||||||||
| certificates | list | [] | Public DNS names to put on the certificate. | ||||||||||||
| certificates | string | "2160h" | Lifetime of each component certificate, and how long before expiry cert-manager renews it. Short durations are the only mitigation available for the fact that nothing here can check revocation — `otelcol.receiver.otlp` has no CRL support, and in-cluster there is no load balancer to act as a checkpoint, so revoking means re-issuing the CA. They also raise the cost of a component that does not reload its certificate cleanly, which is why no hop turns on until its rotation behaviour is proven. 90 days with 30 days of headroom is a starting point rather than a researched one. | ||||||||||||
| certificates | object |
{
"alertmanager": {
"enabled": null,
"extraDnsNames": [],
"extraIpAddresses": [],
"secretName": "",
"services": [
"alertmanager"
]
},
"alloy-agent": {
"enabled": null,
"extraDnsNames": [],
"extraIpAddresses": [],
"secretName": "",
"services": [
"alloy-agent"
]
},
"alloy-gateway": {
"enabled": null,
"extraDnsNames": [],
"extraIpAddresses": [],
"secretName": "",
"services": [
"alloy-gateway",
"alloy-gateway-cluster"
]
},
"grafana": {
"enabled": null,
"extraDnsNames": [],
"extraIpAddresses": [],
"secretName": "",
"services": [
"grafana"
]
},
"loki": {
"enabled": null,
"extraDnsNames": [],
"extraIpAddresses": [],
"secretName": "",
"services": [
"loki-distributor",
"loki-gateway",
"loki-query-frontend",
"loki-ruler"
]
},
"thanos": {
"enabled": null,
"extraDnsNames": [],
"extraIpAddresses": [],
"secretName": "",
"services": [
"thanos-receive",
"thanos-query"
]
}
} | Per-component certificates.
One `Certificate` per component rather than one shared across the stack: the
SANs differ, the namespaces differ under `split-namespace`, and a single key
shared by every workload makes any one compromise total.
Each entry takes:
|
Materialize Integration#
Materialize-specific configuration values.
| Key | Type | Default | Description |
|---|---|---|---|
| materialize | list | [] | Namespaces to scrape Materialize workloads from. Empty list means all namespaces the chart can read. |
| materialize | object |
{
"enabled": true,
"metricEndpoints": [],
"selector": {},
"targetLabels": []
} | ServiceMonitor/PodMonitor configuration for environmentd |
| materialize | bool | true | Deploy a PodMonitor to collect Materialize metrics. |
| materialize | object |
{} | Override for default selector |
| materialize | list | [] | Override for default labels to copy from the pod into metrics |
| materialize | list | [] | Override for default metric endpoints The defaults carry `metricRelabelings` that copy label aliases into their canonical names, such as `instance_id` into `cluster_id`. A replacement restates them from `pre-rendered/scrapers/prometheus-operator/podmonitor-environmentd.yaml`, or its series lack the canonical labels. |
| materialize | object |
{
"enabled": true,
"metricEndpoints": [],
"selector": {},
"targetLabels": []
} | ServiceMonitor/PodMonitor configuration for environmentd-sql |
| materialize | bool | true | Deploy a PodMonitor to collect Materialize metrics. |
| materialize | object |
{} | Override for default selector |
| materialize | list | [] | Override for default labels to copy from the pod into metrics |
| materialize | list | [] | Override for default metric endpoints The defaults share one port, so each copies `__metrics_path__` into `metrics_path` with a `relabelings` entry. A replacement keeps that on every endpoint, or their `up` and `scrape_*` series collide. The defaults carry `metricRelabelings` that copy label aliases into their canonical names, such as `instance_id` into `cluster_id`. A replacement restates them from `pre-rendered/scrapers/prometheus-operator/podmonitor-sql.yaml`, or its series lack the canonical labels. |
| materialize | object |
{
"enabled": true,
"metricEndpoints": [],
"selector": {},
"targetLabels": []
} | ServiceMonitor/PodMonitor configuration for clusterd |
| materialize | bool | true | Deploy a PodMonitor to collect Materialize metrics. |
| materialize | object |
{} | Override for default selector |
| materialize | list | [] | Override for default labels to copy from the pod into metrics The default `metricRelabelings` fill `cluster_id` and `replica_id` from the `cluster.environmentd.materialize.cloud/cluster-id` and `replica-id` labels copied here, so a replacement keeps both. |
| materialize | list | [] | Override for default metric endpoints The defaults carry `metricRelabelings` that copy label aliases into their canonical names, such as `instance_id` into `cluster_id`. A replacement restates them from `pre-rendered/scrapers/prometheus-operator/podmonitor-clusterd.yaml`, or its series lack the canonical labels. |
| materialize | object |
{
"enabled": true,
"metricEndpoints": [],
"selector": {},
"targetLabels": []
} | ServiceMonitor/PodMonitor configuration for balancerd |
| materialize | bool | true | Deploy a PodMonitor to collect balancerd's connection metrics (`mz_balancer_*`). |
| materialize | object |
{} | Override for default selector |
| materialize | list | [] | Override for default labels to copy from the pod into metrics |
| materialize | list | [] | Override for default metric endpoints |
| materialize | object |
{
"enabled": true,
"metricEndpoints": [],
"selector": {},
"targetLabels": []
} | ServiceMonitor/PodMonitor configuration for the console |
| materialize | bool | true | Deploy a PodMonitor to collect the console's request metrics (`mz_console_*`). Console pods before Materialize v26.49.0 declare no `internal-http` port and yield no target. |
| materialize | object |
{} | Override for default selector |
| materialize | list | [] | Override for default labels to copy from the pod into metrics |
| materialize | list | [] | Override for default metric endpoints |
| materialize | string | "self-managed" | Where Materialize runs: `self-managed` or `cloud`. Decides the prefix the alerting rules expect on SQL-backed metrics (`mz_`, or Cloud's `v2_mz_`). |
| materialize-system | string | "materialize-environment" | The namespace materialize-system was deployed into |
| materialize-system | object |
{
"enabled": true
} | ServiceMonitor/PodMonitor configuration TODO: nothing uses this yet |
| materialize-operator | string | "materialize" | The namespace materialize-operator was deployed into |
| materialize-operator | object |
{
"enabled": true,
"metricEndpoints": [],
"selector": {},
"targetLabels": []
} | ServiceMonitor/PodMonitor configuration |
| materialize-operator | object |
{} | Override for default selector |
| materialize-operator | list | [] | Override for default labels to copy from the pod into metrics |
| materialize-operator | list | [] | Override for default metric endpoints The defaults carry `metricRelabelings` that copy label aliases into their canonical names, such as `instance_id` into `cluster_id`. A replacement restates them from `pre-rendered/scrapers/prometheus-operator/podmonitor-materialize-operator.yaml`, or its series lack the canonical labels. |
Networking collection#
Collection from the cluster’s networking layer.
The CNI is the one part of the platform this chart cannot assume, because it differs per cloud and per install: EKS defaults to the AWS VPC CNI, GKE to Dataplane V2, AKS to Azure CNI powered by Cilium.
Rather than asking which one is present, the monitors below are all deployed
and each is self-disabling. Their pod selectors match nothing on a cluster
running a different vendor, and their endpoints name container ports that
exist only where the vendor exposes metrics at all — so a cluster collects
from whichever it runs, and from nothing else. Every series they collect
carries a network_component label, which is what the infra-net dashboard
discovers its dataplane from.
Leaving these on costs a cluster nothing it does not use. Turn one off to stop collecting from a CNI that is present.
| Key | Type | Default | Description |
|---|---|---|---|
| networking | bool | true | Collect from the AWS VPC CNI (`aws-node`): IPAM, ENI and address exhaustion, plus NetworkPolicy drops from the node agent where it runs. |
| networking | list | [] | Namespaces to look for the CNI daemon in. |
| networking | object |
{} | Override for default pod selector |
| networking | list | [] | Override for default metric endpoints |
| networking | bool | true | Collect from Cilium, including GKE Dataplane V2 and Azure CNI powered by Cilium, plus Hubble where its metrics are enabled. Note that GKE Dataplane V2 disables the agent's Prometheus endpoint, so this collects nothing there by design — see the dashboard's CNI tab. |
| networking | list | [] | Namespaces to look for the CNI daemon in. |
| networking | object |
{} | Override for default pod selector |
| networking | list | [] | Override for default metric endpoints |
Pipeline configuration#
Pipeline configuration values that drive Alloy behavior and defaults.
The Alloy pipeline is the flagship deliverable of this chart.
Pipeline configuration is expressed in values, rendered into ConfigMaps under
templates/pipelines/, and consumed by the Alloy agent and gateway
release instances.
| Key | Type | Default | Description |
|---|---|---|---|
| pipeline | bool | true | Run a hook/dependency to validate rendered configs before rolling them out |
| pipeline | object |
{
"helm.sh/hook": "pre-install,pre-upgrade",
"helm.sh/hook-delete-policy": "before-hook-creation"
} | Job specific annotations This should be used to control when the job is executed. The default is as a helm hook, but setting any other annotation overrides this. |
| pipeline | int | 1 | Number of times to retry the job before failing the release. |
| pipeline | object |
{
"fsGroup": 473,
"runAsGroup": 473,
"runAsUser": 473,
"seccompProfile": {
"type": "RuntimeDefault"
}
} | Security context for the pre-validate job pod. This is the hardened recommendation with the alloy user. |
| pipeline | object |
{
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": [
"ALL"
]
},
"readOnlyRootFilesystem": true,
"runAsGroup": 473,
"runAsNonRoot": true,
"runAsUser": 473
} | Security context for the pre-validate job container. This is the hardened recommendation with the alloy user. |
| pipeline | object |
{} | Environment variables to set in agent and gateway pods. These support tpl rendering. Since these are injected into an envFrom, any env entries take precedence. |
| pipeline | string | "{{ include \"mzmon.clusterName\" $ }}" | The cluster name the agent and gateway stamp as `cluster`. Set `clusterName` instead. Defaults to `clusterName`, so logs and metrics agree with alerts. A literal here overrides it for Alloy alone, and the render warns that the two differ. |
| pipeline | string | "info" | Level for the gateway logs. |
Log configuration#
Configuration for log behavior
| Key | Type | Default | Description | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| pipeline | int | 5000 | Rate limit for alloy agent incoming pod logs. This is per agent. | ||||||||||||||||||||||||
| pipeline | int | 20000 | Burst limit for alloy agent incoming pod logs. | ||||||||||||||||||||||||
| pipeline | string | "http://alloy-gateway.{{ include \"mzmon.alloyGateway.namespace\" $ }}.svc:3100/loki/api/v1/push" | alloy-gateway push endpoint URL. | ||||||||||||||||||||||||
| pipeline | object |
{
"maxBackoffPeriod": "5m",
"maxBackoffRetries": 10,
"minBackoffPeriod": "1s",
"retryOnHttp429": true
} | Retry configuration. | ||||||||||||||||||||||||
| pipeline | string | "none" | Type of authentication to use with the alloy-gateway endpoint. Use none if no authentication is required. | ||||||||||||||||||||||||
| pipeline | bool | false | Whether to enable TLS for alloy-gateway dest. | ||||||||||||||||||||||||
| pipeline | bool | true | Whether to verify the TLS certificate for the alloy-gateway dest. | ||||||||||||||||||||||||
| pipeline | string | "" | Certificate Authority (CA) PEM contents for TLS. | ||||||||||||||||||||||||
| pipeline | string | "" | Client certificate PEM contents for TLS. | ||||||||||||||||||||||||
| pipeline | string | "" | Client private key PEM contents for TLS. | ||||||||||||||||||||||||
| pipeline | string | "" | Paths to certificate material on disk, and the preferred carrier for anything cert-manager renews.
The inline `ca`/`cert`/`key` above travel through **environment
variables**, which are captured once at process start. cert-manager
renews by rewriting the Secret in place, so an env-carried PEM works
for exactly one certificate lifetime and then fails on every hop at
once, months after the change that caused it and with no deploy
nearby to blame. A mounted file does not have that problem: the kubelet refreshes
Secret contents atomically, and Alloy’s client paths pick up the new
material on the next connection. Set these to paths under
Inline PEM stays supported as the bring-your-own-PKI escape hatch. | ||||||||||||||||||||||||
| pipeline | string | "" | Alternative SNI (Server Name Indication) to specify. | ||||||||||||||||||||||||
| pipeline | string | "TLS13" | Minimum TLS version to allow. Use TLS12 if you need better compat. TLS11 and TLS10 are not recommended for production. | ||||||||||||||||||||||||
| pipeline | object |
{
"tls": {
"certFile": "",
"clientAuth": "NoClientCert",
"clientCAFile": "",
"enabled": false,
"keyFile": "",
"minVersion": "TLS13",
"reloadInterval": "1m"
}
} | Server-side TLS on this role's listeners.
The other half of the `destination.*.tls` blocks: those configure Alloy
as a *client*, this configures it as a *server*. A configured client
against an unconfigured server is TLS off, which is why the two have to
move together. The gateway has three listeners, not two, and they are split across the two pipeline trees the same way the listeners are:
Unlike Loki’s server, Alloy’s listeners are not probed by the kubelet on
the same port — the readiness probe is on | ||||||||||||||||||||||||
| pipeline | bool | false | Serve TLS on this role's listeners. | ||||||||||||||||||||||||
| pipeline | string | "" | Certificate and key the listener presents. Files rather than inline PEM, because cert-manager renews by rewriting the Secret and Alloy re-reads the file on new connections. | ||||||||||||||||||||||||
| pipeline | string | "" | CA used to verify client certificates. Required by every `clientAuth` value except `NoClientCert`. | ||||||||||||||||||||||||
| pipeline | string | "NoClientCert" | How hard to insist on a client certificate. See the table above. | ||||||||||||||||||||||||
| pipeline | string | "TLS13" | Minimum TLS version the listener accepts. | ||||||||||||||||||||||||
| pipeline | string | "1m" | How often the OTLP listeners re-read their certificate files. `otelcol.receiver.otlp` only — the `loki.source.api` listener has no equivalent and re-reads on new connections instead. This is the best renewal behaviour of anything in the stack, so it is worth setting: without it a long-lived gRPC stream can outlive the certificate that established it. | ||||||||||||||||||||||||
| pipeline | bool | true | Enable writing to a loki destination. By default, we use the in-cluster loki | ||||||||||||||||||||||||
| pipeline | string | "http://loki-distributor.{{ include \"mzmon.loki.namespace\" $ }}.svc:3100/loki/api/v1/push" | Loki push endpoint URL. | ||||||||||||||||||||||||
| pipeline | object |
{
"maxBackoffPeriod": "5m",
"maxBackoffRetries": 10,
"minBackoffPeriod": "1s",
"retryOnHttp429": true
} | Retry configuration. | ||||||||||||||||||||||||
| pipeline | string | "none" | Type of authentication to use with the loki endpoint. Use none if no authentication is required. Use basicAuth for username/password. Use bearer for bearer token. Use oauth2 for OAuth2 client credentials. | ||||||||||||||||||||||||
| pipeline | object |
{
"password": "",
"passwordEnv": "GATEWAY_LOKI_DEST_PASSWORD",
"username": "",
"usernameEnv": "GATEWAY_LOKI_DEST_USERNAME"
} | Configuration for auth when using authType=basicAuth You will need to provide alloy-gateway.alloy.agent.extraEnv TODO: add a check for this | ||||||||||||||||||||||||
| pipeline | object |
{
"token": "",
"tokenEnv": "GATEWAY_LOKI_DEST_BEARER_TOKEN"
} | Configuration for bearer token when using authType=bearer This is used for bearer type tokens. | ||||||||||||||||||||||||
| pipeline | object |
{
"clientId": "",
"clientIdEnv": "GATEWAY_LOKI_DEST_OAUTH2_CLIENT_ID",
"clientSecret": "",
"clientSecretEnv": "GATEWAY_LOKI_DEST_OAUTH2_CLIENT_SECRET",
"scopes": [],
"tokenUrl": "",
"tokenUrlEnv": "GATEWAY_LOKI_DEST_OAUTH2_TOKEN_URL"
} | Configuration for OAuth2 when using authType=oauth2 | ||||||||||||||||||||||||
| pipeline | bool | false | Whether to enable TLS for the loki destination. | ||||||||||||||||||||||||
| pipeline | bool | true | Whether to verify the TLS certificate for the loki destination. | ||||||||||||||||||||||||
| pipeline | string | "" | Certificate Authority (CA) PEM contents for TLS. | ||||||||||||||||||||||||
| pipeline | string | "" | Client certificate PEM contents for TLS. | ||||||||||||||||||||||||
| pipeline | string | "" | Client private key PEM contents for TLS. | ||||||||||||||||||||||||
| pipeline | string | "" | Paths to certificate material on disk, and the preferred carrier for anything cert-manager renews.
The inline `ca`/`cert`/`key` above travel through **environment
variables**, which are captured once at process start. cert-manager
renews by rewriting the Secret in place, so an env-carried PEM works
for exactly one certificate lifetime and then fails on every hop at
once, months after the change that caused it and with no deploy
nearby to blame. A mounted file does not have that problem: the kubelet refreshes
Secret contents atomically, and Alloy’s client paths pick up the new
material on the next connection. Set these to paths under
Inline PEM stays supported as the bring-your-own-PKI escape hatch. | ||||||||||||||||||||||||
| pipeline | string | "" | Alternative SNI (Server Name Indication) to specify. | ||||||||||||||||||||||||
| pipeline | string | "TLS13" | Minimum TLS version to allow. Use TLS12 if you need better compat. TLS11 and TLS10 are not recommended for production. | ||||||||||||||||||||||||
| pipeline | bool | false | Enable writing to an OpenTelemetry/OTLP destination. By default, we do not use the OTLP destination. NOTE: This is configured in the pipeline.metrics.gateway.destination.otlp block, not the logging block. | ||||||||||||||||||||||||
| pipeline | string | "loki" | Default tenant to write logs to. This is used when tenantMap values is set to `static`. | ||||||||||||||||||||||||
| pipeline | object |
{
"audit": "static",
"default": "static",
"environment": "static",
"infra": "static"
} | Type of tenancy used to write logs. Use static to write to the staticTenant value (recommended). Use byEnvironment to separate by the environment name. Use byNamespace to use the namespace of the source pod as the tenant. Use byLabel to use a label (tenantLabel) to specifically identify the tenant. Use none to disable tenancy (only use if loki does not have tenancy). |
Metrics configuration#
Configuration for metrics behavior
| Key | Type | Default | Description | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| pipeline | h5 | {"scrapeInterval":"60s", "tlsInsecureSkipVerify":false} | Node-local container metrics, scraped from each node's kubelet. The gateway scrapes Coverage is not the tradeoff it was assumed to be: a GKE kubelet serves 69
distinct Needs | ||||||||||||||||||||||||
| pipeline | string | "60s" | Scrape interval for the kubelet cAdvisor endpoint. The dominant cost lever — roughly 6.7k series per node per scrape. | ||||||||||||||||||||||||
| pipeline | bool | false | Skip verification of the kubelet's serving certificate. Left false: on GKE the kubelet certificate verifies against the
in-cluster CA, which the chart passes as | ||||||||||||||||||||||||
| pipeline | object |
{
"scrapeInterval": "60s"
} | Scraping kube-proxy, the service proxy. kube-proxy turns a Service into rules on the node, so its sync latency is
the answer when connections to a Service fail while every pod behind it is
healthy. The Discovery is a server-side pod selector, so a cluster without kube-proxy
returns no targets and this costs nothing. GKE Dataplane V2 and any Cilium
install in kube-proxy-replacement mode are exactly that case.
No enable toggle, for the same reason | ||||||||||||||||||||||||
| pipeline | string | "60s" | Scrape interval for kube-proxy. Roughly 230 series per node. | ||||||||||||||||||||||||
| pipeline | h5 | {"azure":{"aks":{"clusters":[]}, "blob":{"storageAccounts":[]}, "cloudEnvironment":"azurecloud", "enabled":false, "loadBalancer":{"clusters":[]}, "metricImportance":"extended", "natGateway":{"names":[]}, "postgres":{"servers":[]}, "scrapeInterval":"5m", "scrapeTimeout":"2m", "subscriptionId":""}, "cloudwatch":{"eks":{"clusters":[]}, "enabled":false, "metricImportance":"extended", "natGateway":{"ids":[]}, "nlb":{"loadBalancers":[]}, "rds":{"instances":[]}, "region":"", "s3":{"buckets":[]}, "scrapeInterval":"5m", "scrapeTimeout":"2m"}, "gcp":{"cloudNat":{"gateways":[]}, "cloudSql":{"instances":[]}, "compute":{"regions":[]}, "enabled":false, "gcs":{"buckets":[]}, "loadBalancing":{"forwardingRules":[], "networks":[]}, "metricImportance":"extended", "projectId":"", "requestInterval":"10m", "scrapeInterval":"5m", "scrapeTimeout":"2m"}} | Cloud provider metrics, pulled into the gateway. The gateway can pull what a cloud provider’s monitoring API publishes about
the managed database and the buckets a deployment depends on, and write it
beside every other metric. The result shares retention, PromQL and alerting
with the rest of the stack, so a provider series is joinable with
Every provider is off by default. Provider collection is an addition to what the clients already report about the same dependencies, not a replacement for it. Resources are named, not discovered. Each service lists the instances or buckets to watch, and nothing else is pulled. Tag discovery would find every resource in the account and bill for each of them. Where a resource cannot be named in advance, a named scope stands in for it: EKS nodes are found by their cluster, AKS node VMs and load balancers by theirs, and GCP load balancers by their VPC network. A provider that is enabled with nothing listed fails at render. Credentials never travel through values. The pull uses the gateway
pod’s own cloud identity, bound through
The data is minutes old when it arrives. CloudWatch publishes RDS
metrics a few minutes late and S3 storage metrics once a day. Cloud
Monitoring stamps each sample with its own time: Cloud SQL samples arrive
about three minutes old, and GCS storage samples over ten. Azure Monitor
values are a minute or two behind, and blob capacity is refreshed daily.
Query these families with Each pull is billed by the provider. The cost is a function of the interval and of how many resources and metrics are listed, not of how many dashboards are open. The interval is the main lever. Every replica runs the exporter, and one scrapes it. The scrape is clustered, so a provider’s API is called once per interval however many gateway replicas there are. The metric sets are fixed, and live in the pipeline, not here. Each
pull is a custom component in the chart’s | ||||||||||||||||||||||||
| pipeline | bool | false | Pull CloudWatch metrics for the resources listed below. | ||||||||||||||||||||||||
| pipeline | string | "" | AWS region the resources live in, and the region CloudWatch and STS are called in. Required when enabled. | ||||||||||||||||||||||||
| pipeline | string | "5m" | How often the gateway pulls. Every pull is one billed `GetMetricStatistics` call per metric per resource. | ||||||||||||||||||||||||
| pipeline | string | "2m" | Scrape timeout. Must not exceed `scrapeInterval`. | ||||||||||||||||||||||||
| pipeline | string | "extended" | Importance tier assigned to every CloudWatch family, for destinations that filter by `minMetricImportance`. One of `essential`, `recommended`, `extended`, `diagnostic`. | ||||||||||||||||||||||||
| pipeline | list | [] | RDS DB instance identifiers to watch, as `DBInstanceIdentifier`. Each is pulled for a fixed set: CPU, and the credit balance of a burstable `db.t*` class; freeable memory, free storage and connections; read and write latency and queue depth; the burst and EBS balances whose exhaustion slows every write while every in-database metric stays flat; and transaction-ID consumption. A metric CloudWatch does not publish for an instance is absent, never zero. | ||||||||||||||||||||||||
| pipeline | list | [] | S3 bucket names to watch, for their size in standard storage and their object count, which S3 publishes daily. | ||||||||||||||||||||||||
| pipeline | list | [] | EKS cluster names whose nodes to watch. Each cluster's nodes are found by the `aws:eks:cluster-name` tag EKS and Karpenter put on them, since the nodes themselves come and go, and pulled for AWS's status checks: the host, the instance and its attached EBS volumes. Its managed node groups are pulled for desired, in-service and pending instances against their maximum. Listing any cluster also pulls the region's On-Demand vCPU usage, account-wide, which is what the vCPU quota counts; CloudWatch does not publish the quota itself. Needs `cloudwatch:GetMetricData`, `cloudwatch:ListMetrics`, `tag:GetResources` and `autoscaling:DescribeAutoScalingGroups` beside the grant the other services need. | ||||||||||||||||||||||||
| pipeline | list | [] | Network Load Balancers to watch, each by ARN, with the target groups behind it.
Each entry is `arn`, the load balancer's ARN, and `targetGroups`,
its target groups' ARNs; either may be given as the end of the ARN
(`net/ | ||||||||||||||||||||||||
| pipeline | list | [] | NAT gateway IDs to watch, such as `nat-0123456789abcdef0`. Each is pulled for port allocation errors and dropped packets, which is how exhaustion shows; connections closed for idling, which a long-lived source connection hits when it is quiet for 350 seconds; open connections; and connection attempts beside those established. | ||||||||||||||||||||||||
| pipeline | bool | false | Pull Cloud Monitoring metrics for the resources listed below. | ||||||||||||||||||||||||
| pipeline | string | "" | Google Cloud project that holds the resources. Required when enabled. | ||||||||||||||||||||||||
| pipeline | string | "5m" | How often the gateway pulls. Every pull lists time series once per metric type. | ||||||||||||||||||||||||
| pipeline | string | "2m" | Scrape timeout. Must not exceed `scrapeInterval`. | ||||||||||||||||||||||||
| pipeline | string | "10m" | How far back each pull reaches. Only the newest point per series is kept, so a window longer than the interval costs nothing in samples and covers a late point. | ||||||||||||||||||||||||
| pipeline | string | "extended" | Importance tier assigned to every Cloud Monitoring family, for destinations that filter by `minMetricImportance`. One of `essential`, `recommended`, `extended`, `diagnostic`. | ||||||||||||||||||||||||
| pipeline | list | [] | Cloud SQL instance names to watch — the instance name, not the `project:region:instance` connection name. Each is pulled for a fixed set: CPU, memory and disk utilization, backends by database, state and application, transaction-ID utilization, and whether the instance is up. | ||||||||||||||||||||||||
| pipeline | list | [] | GCS bucket names to watch. Each is pulled for its stored bytes and object count, split into live, noncurrent and soft-deleted, which is what shows reclaimable waste. GCS request counts are deliberately not pulled: they are a per-minute DELTA, and the exporter counts only the newest point of each pull, so at a five-minute interval they read about a fifth of the truth. The Loki, Thanos and persist clients report the same requests exactly. | ||||||||||||||||||||||||
| pipeline | list | [] | Compute Engine regions whose quota to watch, such as `us-east1` — the regions the GKE node pools run in. Each region and its zones are pulled for the CPUs and local SSD each machine family uses against its quota, and for every request a quota refused. A node pool that cannot grow for quota looks exactly like one that cannot grow for capacity until this says which. Usage and limit are published a few times a day, so each pull reaches back a day. | ||||||||||||||||||||||||
| pipeline | list | [] | Forwarding rule names of the passthrough Network Load Balancers to watch, external or internal. A LoadBalancer Service on GKE is one of these, and GKE names its rule `a` followed by the Service's UID without dashes, cut to 32 characters; `gcloud compute forwarding-rules list` shows them, with the Service in each one's description. Each is pulled for the bytes and packets it carried in each direction. Cloud Monitoring publishes no health for these load balancers. | ||||||||||||||||||||||||
| pipeline | list | [] | VPC network names whose passthrough Network Load Balancers to watch, external or internal. Every passthrough load balancer whose backends are in a listed network is pulled, whoever made it, beside any rule in `forwardingRules`. This is the way to reach a rule that cannot be named beforehand: GKE names a rule only once it makes it, so a LoadBalancer Service the same release creates, such as Grafana's, has no name to list. On a network that holds only the cluster, as the Terraform wrappers create it, that is the cluster's load balancers and nothing else. | ||||||||||||||||||||||||
| pipeline | list | [] | Cloud NAT gateway names to watch — the NAT's name, not its Cloud Router's. Each is pulled for whether a VM was refused a source port, the most connections any one VM holds to a single destination, which its share of ports limits, the ports allocated per NAT address, open connections, and packets dropped, by reason. Port exhaustion inside the cluster looks like a source that stalls and reconnects with no in-cluster cause. | ||||||||||||||||||||||||
| pipeline | bool | false | Pull Azure Monitor metrics for the resources listed below. | ||||||||||||||||||||||||
| pipeline | string | "" | Subscription that holds the resources, as its ID. Required when enabled. | ||||||||||||||||||||||||
| pipeline | string | "azurecloud" | Azure cloud the subscription lives in: `azurecloud`, `azurechinacloud` or `azureusgovernmentcloud`. | ||||||||||||||||||||||||
| pipeline | string | "5m" | How often the gateway pulls. Every pull is one Resource Graph query per service, then one metrics call per resource. | ||||||||||||||||||||||||
| pipeline | string | "2m" | Scrape timeout. Must not exceed `scrapeInterval`. | ||||||||||||||||||||||||
| pipeline | string | "extended" | Importance tier assigned to every Azure Monitor family, for destinations that filter by `minMetricImportance`. One of `essential`, `recommended`, `extended`, `diagnostic`. | ||||||||||||||||||||||||
| pipeline | list | [] | PostgreSQL Flexible Server names to watch — the name, not the FQDN or resource ID. Each is pulled for a fixed set: CPU, and the credits of a Burstable tier; memory, storage and connection headroom, and failed connections; disk queue depth, and how much of the disk's provisioned IOPS and throughput is in use, which at 100% throttles every write while every in-database metric stays flat; transaction-ID consumption; and whether Azure can reach the server. A metric Azure does not publish for a server is absent, never zero. | ||||||||||||||||||||||||
| pipeline | list | [] | Storage account names to watch, for their blob service. Each is pulled for its stored bytes and blob count, which Azure refreshes about once a day, and for request availability and latency, at the service and end to end. Transaction counts are deliberately not pulled: the exporter reads the newest five-minute bucket as the scrape ends it, which is a minute or so short. The Loki, Thanos and persist clients report the same requests exactly. | ||||||||||||||||||||||||
| pipeline | list | [] | AKS cluster names to watch — the name, not the resource ID. Each is pulled for its managed cluster autoscaler, which AKS runs in its control plane where nothing can scrape it: pods it cannot place, whether it considers the cluster safe to scale, whether scale-down is cooling down, and nodes it would remove. Its node pools' scale sets, found through the cluster's node resource group, are pulled for Azure's own view of whether each node VM is up. The identity needs Monitoring Reader on the cluster and on its node resource group. | ||||||||||||||||||||||||
| pipeline | list | [] | AKS cluster names whose load balancers to watch — the name, not the resource ID. AKS puts a cluster's LoadBalancer Services behind `kubernetes` and, for internal ones, `kubernetes-internal`, in its node resource group. Every cluster's are named the same, so the cluster is what names them. Each is pulled per frontend address for Azure's own probe of the data path, each backend's health probe results, and the bytes and new connections of the newest minute. The identity needs Monitoring Reader on the node resource group. | ||||||||||||||||||||||||
| pipeline | list | [] | NAT gateway names to watch — the name, not the resource ID. Each is pulled for Azure's own probe of its data path, SNAT connections attempted and failed, and dropped packets. A failed SNAT connection is one the gateway could not give a source port. Names are unique only within a resource group, so two gateways of one name in the subscription are both pulled. | ||||||||||||||||||||||||
| pipeline | list | [] | Denylist of metrics that are excluded from being exported These are |'d in a regex pattern | ||||||||||||||||||||||||
| pipeline | object |
{
"tls": {
"certFile": "",
"clientAuth": "NoClientCert",
"clientCAFile": "",
"enabled": false,
"keyFile": "",
"minVersion": "TLS13",
"reloadInterval": "1m"
}
} | Server-side TLS on this role's listeners.
The other half of the `destination.*.tls` blocks: those configure Alloy
as a *client*, this configures it as a *server*. A configured client
against an unconfigured server is TLS off, which is why the two have to
move together. The gateway has three listeners, not two, and they are split across the two pipeline trees the same way the listeners are:
Unlike Loki’s server, Alloy’s listeners are not probed by the kubelet on
the same port — the readiness probe is on | ||||||||||||||||||||||||
| pipeline | bool | false | Serve TLS on this role's listeners. | ||||||||||||||||||||||||
| pipeline | string | "" | Certificate and key the listener presents. Files rather than inline PEM, because cert-manager renews by rewriting the Secret and Alloy re-reads the file on new connections. | ||||||||||||||||||||||||
| pipeline | string | "" | CA used to verify client certificates. Required by every `clientAuth` value except `NoClientCert`. | ||||||||||||||||||||||||
| pipeline | string | "NoClientCert" | How hard to insist on a client certificate. See the table above. | ||||||||||||||||||||||||
| pipeline | string | "TLS13" | Minimum TLS version the listener accepts. | ||||||||||||||||||||||||
| pipeline | string | "1m" | How often the OTLP listeners re-read their certificate files. `otelcol.receiver.otlp` only — the `loki.source.api` listener has no equivalent and re-reads on new connections instead. This is the best renewal behaviour of anything in the stack, so it is worth setting: without it a long-lived gRPC stream can outlive the certificate that established it. |
Prometheus remote-write destinations.#
Prometheus remote-write destinations, as a map of name to destination.
A map, not a single destination. Each key names one destination and
becomes the label on that destination’s Alloy components, so the name is
visible in component_id on the gateway’s own metrics and in its UI.
The default map holds one entry, thanos, pointing at the bundled
in-cluster Thanos Receive.
Every destination gets its own prometheus.remote_write component
rather than a second endpoint block on a shared one. That costs a WAL
per destination and buys the two things a shared component cannot give:
- Its own
minMetricImportance. The tier filter is aprometheus.relabelupstream of the component, so a destination onessentialwrites a WAL holding only essential series. A shared component can only filter withwrite_relabel_config, which runs on the way out of the WAL — every destination would pay full firehose disk regardless of tier. This is the whole reason the tiers exist: Amazon Managed Prometheus bills per sample ingested and per series. - Its own failure domain. A destination that stops accepting writes backs up its own WAL and nothing else. On a shared component a stuck endpoint holds back WAL truncation for every other endpoint too.
external_labels is per destination for the same reason, though the
default (cluster from CLUSTER_NAME) is what every destination wants.
Names are Alloy component labels, so they must match
[a-zA-Z_][a-zA-Z0-9_]*, and egress is reserved — the fan-out seam
already uses it. A name that breaks either rule fails at render.
Fields are optional. Anything a destination omits falls back to the
per-destination defaults below, so a second destination is usually three
lines. Environment variable names are derived from the destination name
when not given — GATEWAY_PROM_DEST_AMP,
GATEWAY_PROMETHEUS_DEST_AMP_PASSWORD,
GATEWAY_UNFILTERED_PROM_METRICS_AMP — and can be set explicitly where a
caller (such as the Terraform module’s mzmon-alloy-gateway-env Secret)
wants to choose them.
| Field | Default | Meaning |
|---|---|---|
enabled | true | Write to this destination. |
url | — | Remote-write endpoint. Required when enabled. |
minMetricImportance | all | Tier floor: essential, recommended, extended, diagnostic, all. |
unfilteredMetricsEnv | GATEWAY_UNFILTERED_PROM_METRICS_<NAME> | Env var carrying the tier allowlist regex. |
urlEnv | GATEWAY_PROM_DEST_<NAME> | Env var carrying the endpoint URL. |
externalLabels | {cluster: CLUSTER_NAME} | external_labels on the component. Values are Alloy expressions. |
authType | none | none, basicAuth, bearer, oauth2, or sigv4. |
basicAuth / bearer / oauth2 / sigv4 | see below | Credentials for the chosen authType. |
tls | off | Client TLS for this hop. |
A worked two-destination example — full fidelity in Thanos, only the
alerting metrics in AMP — is in
profiles/aws-amp-fanout.values.yaml.
OpenTelemetry/OTLP destinations.#
OpenTelemetry/OTLP destination configuration for metrics (and logging).
This supports several components and allows further customization of endpoints. Multiple exporters can be enabled at once.
| Key | Type | Default | Description |
|---|---|---|---|
| pipeline | bool | false | Enable writing metrics to an OpenTelemetry/OTLP destination. By default, we do not use the OTel destination. WARNING: if logging otel destination is enabled, this block will still be used for configuration! (just not for metrics) |
| pipeline | bool | false | Enable writing metrics to Google Cloud Managed Service for Prometheus. Metrics go over OTLP to Google’s
Telemetry API
( Names. The metric type carries the point kind:
Labels. Cloud Monitoring files each series under a
Auth is the gateway’s ambient Google identity (Workload Identity
on GKE) through |
| pipeline | string | "gzip" | Compression for the OTLP request body. |
| pipeline | string | "recommended" | Only export metrics with the specified importance level. Values are "essential", "recommended", "extended", "diagnostic", "all" |
| pipeline | string | "https://telemetry.googleapis.com" | Telemetry API endpoint. Override for a Private Service Connect endpoint. |
| pipeline | string | "" | Google Cloud project to write to. Empty writes to the project the gateway runs in, as GKE's metadata server reports it. Required off GKE: `otelcol.auth.google` will not start without a project, and nothing else supplies one there. |
| pipeline | string | "" | `location` label for every series: a Google Cloud region or zone. Empty uses the GKE cluster's own. Required off GKE: the Telemetry API refuses a point without one. |
| pipeline | list | [ "otelcol.processor.filter.googleCloud.input" ] | Handlers to use for the Google Cloud exporter: the first component of the chain in `config`. |
| pipeline | string | The chain rendered by the chart's `mzmon.alloyGateway.otelDest.googleCloud` helper. | Raw configuration for the Google Cloud export chain. Override it only to change the chain itself; every component is labelled `googleCloud`, and `handlers` must name its first one. |
| pipeline | bool | false | Enable writing to a Datadog destination. |
| pipeline | string | "gzip" | Compression for logs/metrics Only gzip is supported for Datadog. |
| pipeline | string | "recommended" | Only export metrics with the specified importance level. Values are "essential", "recommended", "extended", "diagnostic", "all" |
| pipeline | bool | false | Whether to include host metadata in the Datadog exporter. FIXME: how do we support this in an Agent->Gateway architecture? |
| pipeline | list | [ "otelcol.exporter.datadog.destination.input" ] | Handlers to use for the Datadog exporter. |
| pipeline | string | An `otelcol.exporter.datadog.destination` definition. | Raw configuration for an otelcol.exporter.datadog block. |
| pipeline | bool | false | Enable writing to an OpenTelemetry/OTLP destination. This is the generic fallback for other destinations. You can also use this if you need to set a custom destination block. |
| pipeline | string | "" | OTLP push endpoint URL. This does not need a protocol prefix (http:// or https://) |
| pipeline | string | "grpc" | Protocol to use for OTLP. Use grpc for gRPC protocol. Use http for HTTP protocol. |
| pipeline | string | "gzip" | Compression to use Use gzip for better compatibility. Use snappy for better performance. |
| pipeline | string | "all" | Only export metrics with the specified importance level. Values are "essential", "recommended", "extended", "diagnostic", "all" |
| pipeline | list | [ "otelcol.exporter.otlp[http].destination.input" ] | Handlers to use for the OTLP exporter. Update this if your config was customized. |
| pipeline | string | {{- $exporterType := ternary "otlp" "otlphttp" ( eq .Values.pipeline.metrics.gateway.destination.otel.otlpExporter.protocol "grpc" ) }}
otelcol.exporter.{{ $exporterType }} "destination" {
client {
endpoint = {{ .Values.pipeline.metrics.gateway.destination.otel.otlpExporter.url | required "destination.otlp.url must be set" | quote }}
compression = {{ .Values.pipeline.metrics.gateway.destination.otel.otlpExporter.compression | quote }}
{{- if ( include "mzmon.alloyGateway.otelDest.authEnabled" $ ) }}
auth = {{ include "mzmon.alloyGateway.otelDest.authHandler" $ }}
{{- end }}
}
}
| Raw configuration for an otelcol.exporter.otlp block. Use this if you need to configure multiple destinations or use an alternative endpoint entirely. |
OpenTelemetry Authentication Configuration#
Configuration for OpenTelemetry/OTLP destinations. This is only needed for destinations that require an auth handler (like otlpExporter).
| Key | Type | Default | Description |
|---|---|---|---|
| pipeline | string | "none" | Type of authentication to use with the OpenTelemetry destination. Valid values are: `none`, `basic`, `bearer`, `headers`, `awsSigv4`, and `custom`. |
| pipeline | string | An `otelcol.auth.basic.oteldest` definition. | Raw configuration for an otelcol.auth.basic block. This uses the GATEWAY_OTEL_DEST_USERNAME/GATEWAY_OTEL_DEST_PASSWORD env vars. |
| pipeline | string | An `otelcol.auth.bearer.oteldest` definition. | Raw configuration for an otelcol.auth.bearer block. This uses the GATEWAY_OTEL_DEST_BEARER_TOKEN env var. |
| pipeline | list | [] | Headers attached to every request to the OpenTelemetry destination. For backends that authenticate with an API-key header rather than a
bearer token — Honeycomb’s Each entry names a header with
Unlike the Loki and Prometheus destinations, there is no
|
| pipeline | string | An `otelcol.auth.headers.oteldest` definition built from `headers`. | Raw configuration for an otelcol.auth.headers block. |
| pipeline | string | "" | Override the region to sign requests for. |
| pipeline | string | "" | Override the role ARN to assume for signing requests. |
| pipeline | string | An `otelcol.auth.sigv4.oteldest` definition. | Raw configuration for an otelcol.auth.sigv4 block. |
| pipeline | string | "{{ fail \"Be sure to set this\" }}" | Handler for a custom auth handler (Advanced escape hatch). This should point to the definition you used. Most auth types use `.handler` for their capsule export. If you need multiple handlers, you can skip this field and instead modify your otlpExporter.config to not use "mzmon.alloyGateway.otelDest.authHandler". |
| pipeline | string | // THIS IS AN EXAMPLE
otelcol.auth.basic "oteldest" {
client_auth {
username = sys.env("GATEWAY_OTEL_DEST_USERNAME")
password = sys.env("GATEWAY_OTEL_DEST_PASSWORD")
}
}
| Raw configuration for a custom auth handler. |
Dashboards#
Where the bundled dashboards are filed.
The dashboards themselves are not in this chart. They ship in three charts
of their own, one per folder below — materialize-monitoring-dashboards,
materialize-monitoring-dashboards-infra, and
materialize-monitoring-dashboards-meta — each installed as a release of its
own, because Helm stores a release in a Kubernetes Secret and a Secret may not
exceed 1 MiB, and the rendered set outgrew that and took helm upgrade with it.
What stays here is what a dashboard is filed into: the Grafana instance,
its datasources, and the folders below. The dashboards charts reference the
folder UIDs this one creates, spelled out in their own values rather than
discovered, since they cannot see this release.
| Key | Type | Default | Description |
|---|---|---|---|
| dashboards | bool | true | Create the Grafana folders the bundled dashboards are filed into. Requires the Grafana operator. |
| dashboards | string | "operator" | Grafana deployment mode, either "standalone" (the bundled Grafana chart) or "operator" (a separate Grafana Operator instance). |
| dashboards | h5 | {"allowCrossNamespaceImport":null, "instanceSelector":{}, "resyncPeriod":"5m"} | Settings shared by the Grafana resources this chart creates. Named `manifest` from when it also covered the dashboard manifests; those moved to the `materialize-monitoring-dashboards*` charts, which carry their own copies of these under `grafana`. What is left applies to the folders. |
| dashboards | string | "5m" | Time to sync the folder from its resource. |
| dashboards | object |
{} | Non-default label selector for a Grafana-operator Grafana instance. Defaults to the labels on the `Grafana` instance this chart creates (see `connections.grafana.labels`), so the two cannot drift. Whatever this resolves to must also be set as `grafana.instanceSelector` in each dashboards chart, none of which can read it from here. |
| dashboards | string | inferred | Allow folders to match a Grafana instance outside their own namespace. Left unset, this is inferred — it turns on only when the `Grafana` resource lands in a different namespace than the folders, as it does under the `split-namespace` profile. Set it explicitly when pointing `instanceSelector` at an instance this chart does not create. Note that the CRDs forbid turning this back off in place; the resource has to be recreated. |
Grafana folders.#
Grafana folders the dashboards are filed into.
A map, not a list. Each key names one folder and becomes both the
GrafanaFolder resource name and, unless existingUid overrides it, the
folder UID: <fullname>-<key>, which is mzmon-<key> at the chart’s
default fullnameOverride.
The keys are the contract with the dashboards. A dashboard carries its
placement as a grafana.app/folder annotation naming a folder — the names
are Folder in packages/mzmon-lib/src/grafana/folder.rs, which spells
infra, materialize and meta-o11y — and the chart rewrites that name
to the UID below on the way to the operator, the same way it rewrites
apiVersion. So the UID is free to move with the release or with
existingUid, and the dashboards follow it without being re-rendered.
What does not move freely is a key: rename one and the dashboards that
name it have their annotation dropped and land at the root instead. Adopt
a folder with existingUid rather than renaming, or re-render the
dashboards against the new name.
Only rendered when mode is operator; the standalone Grafana chart has
no folder resource to create.
| Field | Default | Meaning |
|---|---|---|
create | true | Render a GrafanaFolder for this entry. false still resolves dashboards to its UID, for a folder created out of band. |
title | — | Display name in Grafana. Unlike the key, this is free to change. |
existingUid | "" | Adopt the folder with this UID instead of deriving one from the key. |
parent.folderRef | — | Nest under another key in this map. Refers to that entry’s resource name, so it needs create: true. |
parent.folderUID | — | Nest under a folder UID this chart does not manage. Takes precedence over folderRef. |
Scraper configuration#
Configuration for scrapers
| Key | Type | Default | Description |
|---|---|---|---|
| config | bool | true | Install ServiceMonitors / Alloy scrape configs for Materialize and adjacent components. |
Grafana connection configuration#
How to talk to a grafana instance
| Key | Type | Default | Description |
|---|---|---|---|
| connections | bool | true | Create the `Grafana` resource that grafana-operator reconciles this chart's folders, datasources, and dashboards into. Turn it off when no grafana-operator acts on this release, or when `dashboards.config.grafana.manifest.instanceSelector` points them at a `Grafana` resource you manage. The chart then creates none, whatever the `mode`, and refuses to render folders or datasources that still select it. |
| connections | string | "bundled" | How this establishes its connection to Grafana. `bundled` (default) targets the Grafana deployed by the bundled `grafana` subchart; the URL and admin-credential Secret are derived from it. `external` targets a Grafana you already run — Grafana Cloud, a shared platform Grafana, another cluster — and requires `external.url` plus either `external.apiKey` or `external.adminUser` + `external.adminPassword`. `operator` hands the instance lifecycle to grafana-operator itself, which builds it from the operator's own defaults and is not yet production-ready. |
| connections | object |
{} | Additional labels applied to the Grafana instance, and to the `instanceSelector` of every Grafana resource this chart targets at it. Merged over a static `monitoring.materialize.cloud/grafana-instance: mzmon` label, which is what keeps the selector non-empty — grafana-operator reads an empty `matchLabels` as *every* instance, not none. Add to this to narrow the selector further, e.g. to scope per release when two `materialize-monitoring` releases share a cluster. |
| connections | bool | false | Acknowledge that Grafana is deliberately reachable from outside the cluster's private network.
Left false, the chart refuses to render a `LoadBalancer` (or `NodePort`)
Service with no `grafana.service.loadBalancerSourceRanges`, and an Ingress
with no source-range allowlist it can see. That mirrors the load-balancer
convention the Terraform modules already enforce: internal by default, and
public only against an explicit allowlist. Setting this true is the escape hatch for a deployment whose allowlist
lives somewhere the chart cannot read — a security group, an egress
firewall, a service mesh, an authenticating proxy in front. It suppresses
the error, not the exposure: the render still warns, because a Grafana on
the open internet is protected by nothing but its admin password until you
configure an identity provider under |
| connections | object |
{} | Spec passed through to the `Grafana` resource in `mode: operator`.
Break-glass. In that mode grafana-operator owns the server lifecycle and
builds it from its own stock defaults — unpinned image, no persistence, no
admin secret, no resource requests — none of which this chart models. This
key is the raw `GrafanaSpec` escape hatch for configuring it anyway:
whatever you put here is emitted verbatim as the resource's `spec`. Nothing in it is validated or defaulted, and the production guidance on
the Reference: https://grafana.github.io/grafana-operator/docs/api/#grafanaspec |
| connections | string | "" | External grafana uri |
| connections | object |
{} | Secret for Grafana admin password |
| connections | object |
{} | Secret for Grafana admin user |
| connections | object |
{} | Secret for Grafana API key |
Datasource configuration#
Datasources provisioned into the Grafana instance
Provisioned as GrafanaDatasource resources, pushed into the same instance
the dashboards target. url values are rendered with tpl, so they may
reference chart helpers.
| Key | Type | Default | Description |
|---|---|---|---|
| connections | bool | true | Install the bundled datasources. Requires the Grafana operator. |
| connections | string | "5m" | How often the operator re-pushes each datasource. |
| connections | bool | false | Whether the datasources can be edited in the Grafana UI. Edits are reverted on the next resync either way; this only hides the controls so the reversion is not a surprise. |
| connections | string | follows `thanos.enabled | Provision the Thanos datasource. Unset follows whether the bundled Thanos is enabled. Set it explicitly to point Grafana at metrics storage this chart does not deploy. |
| connections | string | "Thanos" | Datasource name, as shown in Grafana. |
| connections | string | "mzmon-thanos" | Stable datasource UID. |
| connections | string | "http://thanos-query.{{ include \"mzmon.thanos.namespace\" $ }}.svc:9090" | Thanos Query endpoint. Rendered with `tpl`. |
| connections | bool | true | Make this Grafana's default datasource. The bundled dashboards deliberately do not pin a datasource: their `${metricsDatasource}` variable resolves to whichever Prometheus-type datasource is default. With no default, every panel renders empty and Grafana reports no error. Only turn this off if something else in the instance is already the default Prometheus datasource. |
| connections | object |
{
"caPem": "",
"caSecret": {
"key": "ca.crt",
"name": ""
},
"clientCert": {
"certKey": "tls.crt",
"keyKey": "tls.key",
"secretName": ""
},
"enabled": null,
"serverName": ""
} | TLS for Grafana's connection to this backend.
Grafana does not read certificate material from files. It stores it in
its own database as `secureJsonData`, provisioned through the datasource
— which is why this is the one hop in the stack that **does not renew on
its own**. cert-manager rewriting the Secret changes nothing until the
datasource is re-provisioned; grafana-operator does that every
`connections.datasources.resyncPeriod`, so the material is refreshed on
that cadence rather than on the certificate's.
Prefer this over |
| connections | string | follows the URL scheme | Verify the backend's certificate against `caSecret`. Unset follows whether the datasource URL is `https://`, so moving the URL is enough and this does not become a second switch to forget. |
| connections | string | "" | The CA to trust, inline as PEM.
A CA certificate is public material — it is the thing you hand out —
so putting it in values is not the leak that an inline key would be. Takes precedence over |
| connections | object |
{
"key": "ca.crt",
"name": ""
} | Secret holding the CA bundle, referenced rather than inlined.
Rendered as a `valuesFrom` entry on the `GrafanaDatasource` plus a
placeholder for the operator to substitute into. Survives a CA
rotation, which `caPem` does not, so this is the better default
whenever the CA already lives in the cluster — a cert-manager issuer's
own Secret, say. The Secret must be in the Grafana instance’s namespace, which is
not necessarily this release’s under
|
| connections | object |
{
"certKey": "tls.crt",
"keyKey": "tls.key",
"secretName": ""
} | Present a client certificate as well. Only useful against a backend that requires one; Loki cannot (its port is probed by the kubelet), so this is here for an external backend that does. |
| connections | string | "" | SNI to send, when it differs from the URL host. |
| connections | object |
{} | Extra `jsonData`, merged over the chart's defaults (`prometheusType: Thanos`, `httpMethod: POST`, and `timeInterval`).
|
| connections | object |
{} | Inline `secureJsonData`. Prefer `valuesFrom` for real secrets — this renders into the release manifest. |
| connections | list | [] | Secret- or ConfigMap-sourced field injection, passed through to the `GrafanaDatasource`. This is the supported way to supply credentials. |
| connections | string | follows `loki.enabled | Provision the Loki datasource. Unset follows whether the bundled Loki is enabled. |
| connections | string | "Loki" | Datasource name, as shown in Grafana. |
| connections | string | "mzmon-loki" | Stable datasource UID. |
| connections | string | "http://loki-gateway.{{ include \"mzmon.loki.namespace\" $ }}.svc:8080" | Loki read endpoint. Rendered with `tpl`. The Loki gateway, which routes queries to the query frontend and rule state to the ruler. Grafana's alerting UI lists the Loki rules from this same URL, so a datasource pointed at the query frontend shows none. With `loki.gateway.enabled` off, point this at `http://loki-query-frontend. |
| connections | string | follows `pipeline.logging.tenancy.staticTenant | Tenant to read as, sent in the `X-Scope-OrgID` header. The bundled Loki runs `auth_enabled: true`, so reads without this header fail with `no org id`. Unset follows the tenant the pipeline writes to. Set to `""` to send no header, which is only correct against a Loki with `auth_enabled: false`. |
| connections | object |
{
"caPem": "",
"caSecret": {
"key": "ca.crt",
"name": ""
},
"clientCert": {
"certKey": "tls.crt",
"keyKey": "tls.key",
"secretName": ""
},
"enabled": null,
"serverName": ""
} | TLS for Grafana's connection to this backend.
Grafana does not read certificate material from files. It stores it in
its own database as `secureJsonData`, provisioned through the datasource
— which is why this is the one hop in the stack that **does not renew on
its own**. cert-manager rewriting the Secret changes nothing until the
datasource is re-provisioned; grafana-operator does that every
`connections.datasources.resyncPeriod`, so the material is refreshed on
that cadence rather than on the certificate's.
Prefer this over |
| connections | string | follows the URL scheme | Verify the backend's certificate against `caSecret`. Unset follows whether the datasource URL is `https://`, so moving the URL is enough and this does not become a second switch to forget. |
| connections | string | "" | The CA to trust, inline as PEM.
A CA certificate is public material — it is the thing you hand out —
so putting it in values is not the leak that an inline key would be. Takes precedence over |
| connections | object |
{
"key": "ca.crt",
"name": ""
} | Secret holding the CA bundle, referenced rather than inlined.
Rendered as a `valuesFrom` entry on the `GrafanaDatasource` plus a
placeholder for the operator to substitute into. Survives a CA
rotation, which `caPem` does not, so this is the better default
whenever the CA already lives in the cluster — a cert-manager issuer's
own Secret, say. The Secret must be in the Grafana instance’s namespace, which is
not necessarily this release’s under
|
| connections | object |
{
"certKey": "tls.crt",
"keyKey": "tls.key",
"secretName": ""
} | Present a client certificate as well. Only useful against a backend that requires one; Loki cannot (its port is probed by the kubelet), so this is here for an external backend that does. |
| connections | string | "" | SNI to send, when it differs from the URL host. |
| connections | object |
{} | Extra `jsonData`, merged over the chart's defaults (the tenant header name, and `timeout`). |
| connections | object |
{} | Inline `secureJsonData`. Prefer `valuesFrom` for real secrets — this renders into the release manifest. |
| connections | list | [] | Secret- or ConfigMap-sourced field injection, passed through to the `GrafanaDatasource`. This is the supported way to supply credentials. |
| connections | string | follows `alertmanager.enabled | Provision an Alertmanager datasource, which gives Grafana the alert list and the silence editor for the bundled Alertmanager. Unset follows whether the bundled Alertmanager is enabled. Grafana reads and writes Alertmanager's state here and keeps none of its own, so this is not Grafana-managed alerting. Silences created through it gossip to every replica like any other. |
| connections | string | "Alertmanager" | Datasource name, as shown in Grafana. |
| connections | string | "mzmon-alertmanager" | Stable datasource UID. |
| connections | string | "{{ include \"mzmon.alertmanager.url\" $ }}" | Alertmanager endpoint. Rendered with `tpl`. The load-balanced Service is right for a reader: silences and the notification log are gossiped, and both rulers send every alert to every replica, so any replica answers with the same state. The helper's scheme follows `alerting.server.tls.enabled`. |
| connections | object |
{
"caPem": "",
"caSecret": {
"key": "ca.crt",
"name": ""
},
"clientCert": {
"certKey": "tls.crt",
"keyKey": "tls.key",
"secretName": ""
},
"enabled": null,
"serverName": ""
} | TLS for Grafana's connection to Alertmanager. Same shape and caveats as `connections.datasources.thanos.tls`, including that Grafana stores the material rather than reading a file, so it refreshes on `resyncPeriod` rather than on renewal. `profiles/mtls.values.yaml` sets `caSecret` to Grafana's own certificate Secret, and `mtls-phase2` presents it as the client certificate. |
| connections | string | follows the URL scheme | Verify Alertmanager's certificate. Unset follows the URL scheme. |
| connections | string | "" | The CA to trust, inline as PEM. |
| connections | object |
{
"key": "ca.crt",
"name": ""
} | Secret holding the CA bundle, in the Grafana instance's namespace. |
| connections | object |
{
"certKey": "tls.crt",
"keyKey": "tls.key",
"secretName": ""
} | Present a client certificate. |
| connections | string | "" | SNI to send, when it differs from the URL host. |
| connections | object |
{} | Extra `jsonData`, merged over the chart's defaults (`implementation: prometheus`, `handleGrafanaManagedAlerts: false`). |
| connections | list | [] | Secret- or ConfigMap-sourced field injection, passed through to the `GrafanaDatasource`. |
Alerting rules#
Which alerting and recording rules install: the bundled rule set, gated by what this deployment contains.
The bundled rules are rendered at build time from the query registry
(packages/queries/) and installed as PrometheusRule resources, each
labelled with its query language as mzmon.materialize.cloud/flavor. The
Thanos ruler imports the PromQL ones and evaluates them. The alloy-gateway’s
loki.rules.kubernetes writes the LogQL ones into the Loki ruler, so they
install only where this release runs both. Where they go once they fire is
alerting, below.
An alert installs when every capability it requires is present, and it is
either in the default set or named in selected, and it is not named in
disabled. Capabilities name what a deployment contains, never who operates
it: a CockroachDB rule is for a deployment running CockroachDB. Most are
derived from what this chart deploys (materialize, kube-state-metrics,
loki, a provider pull, …); the rest are listed in capabilities. The
generated pre-rendered/rules/_index.yaml lists every rule with its engine
and the capabilities it requires.
A recording rule installs wherever its capabilities are present, with no
selection: the normalized ext:* series are what other rules and panels
read, and recording one costs a few series. selected, disabled and
overrides name alerts only.
| Key | Type | Default | Description | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| rules | bool | true | Install the bundled alerting and recording rules. | ||||||||||||||||||||||||||
| rules | list | [] | Capabilities this deployment has beyond those the chart derives.
The derived ones are | ||||||||||||||||||||||||||
| rules | list | [] | Bundled rules to install beyond the default set: alert names, rule-group names, or `*` for every rule that applies. Selecting a rule does not bypass its capabilities: a selected rule whose capabilities are missing is still not installed, and the render warns. | ||||||||||||||||||||||||||
| rules | list | [] | Alert names never to install, even when they apply and are in the default set. | ||||||||||||||||||||||||||
| rules | object |
{} | Per-alert changes to a bundled rule: how long its condition must hold (`for`), and labels to add or replace. An override never changes a rule’s expression. The usual reason for one is a workload whose normal behaviour a default does not fit: a large cluster can take hours to hydrate when nothing is wrong.
| ||||||||||||||||||||||||||
| rules | list | [] | Loki tenants the log-derived rules are written into. Empty means `pipeline.logging.tenancy.staticTenant`. A Loki rule reads one tenant’s logs, and the ruler does not evaluate across
tenants, so the gateway writes every LogQL Under | ||||||||||||||||||||||||||
| rules | list | [] | Namespaces holding Materialize environments. Rules about an environment’s pods (pending, restarting, OOMKilled) scope
to these. Empty uses | ||||||||||||||||||||||||||
| rules | list | [] | Namespaces holding the Materialize operator. Empty uses `materialize-operator.namespace`. | ||||||||||||||||||||||||||
| rules | list | [] | Namespaces excluded from alerting, such as a scratch environment nobody should be paged for. | ||||||||||||||||||||||||||
| rules | object | common EKS and GKE add-ons and this chart's collectors, per tier below | Which infrastructure workloads the rules treat as core, important, non-essential, or on every node. The infrastructure rules (memory, OOMKills, restarts, file descriptors,
Deployment availability, daemonset budgets) grade a workload by the tier it
is in. Each entry is an RE2 fragment matched against the whole name:
container names, and for the The defaults are the Kubernetes add-ons common on EKS and GKE, plus this
chart’s own collectors. Replace a tier to describe your cluster; an empty
tier matches nothing. Container names shared across unrelated workloads
(Karpenter’s is | ||||||||||||||||||||||||||
| rules | list | [ "aws-load-balancer-controller", "aws-node", "cilium-agent", "cilium-operator", "coredns", "csi-attacher", "csi-node-driver-registrar", "csi-provisioner", "csi-resizer", "csi-snapshotter", "external-dns", "karpenter", "kube-dns", "kube-proxy", "kubedns", "node-cache", "node-driver-registrar", "openebs-lvm.*", "snapshot-controller" ] | Workloads the cluster cannot run without: DNS, networking, storage provisioning, autoscaling. | ||||||||||||||||||||||||||
| rules | list | [ ".*kube-state-metrics", ".*metrics-server.*", "alloy.*", "cert-manager.*", "ebs-csi-controller", "ebs-plugin", "gce-pd-driver", "hubble-relay", "liveness-probe", "node-exporter", "prometheus-adapter" ] | Workloads whose loss degrades the cluster without stopping it. | ||||||||||||||||||||||||||
| rules | list | [ "event-exporter", "grafana.*", "hubble-ui", "loki.*", "lvm-exporter", "memcached" ] | Workloads worth a notice rather than a page. `pod-restart-rate-high` covers every container not listed here. | ||||||||||||||||||||||||||
| rules | list | [ "alloy", "aws-eks-nodeagent", "aws-node", "cilium-agent", "csi-driver-registrar", "csi-node-driver-registrar", "ebs-plugin", "gce-pd-driver", "kube-proxy", "liveness-probe", "lvm-exporter", "node-cache", "node-driver-registrar", "node-exporter", "openebs-lvm-plugin" ] | Containers every node runs, whose requests the `k8s-daemonset-*` rules total against a per-node budget. |
External dependencies#
The services Materialize depends on and does not run: which of the resources the stack watches are which.
A provider pull (pipeline.metrics.provider.*) or the postgres-exporter
watches every database it is told to, and neither can say which of them is
Materialize’s metadata database: the Terraform modules add Grafana’s database
to the same pull. This block says which is which. The normalized ext:consensus_* recording rules
read only the databases named here, so an undeclared database, Grafana’s
among them, is never recorded as a metadata database.
Nothing here is needed for Materialize’s own view of its metadata database,
which the persist adapter records wherever Materialize is.
| Key | Type | Default | Description | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| externalDependencies | list | [] | Metadata (consensus) databases, by flavor and provider resource name.
The resource has to be watched by its provider pull or the exporter as
well, or there is nothing to record, and the render warns. Each recorded series carries the
resource as its |
Alert routing#
Where alerts go: receivers, severity presets, and extra routes.
Rendered into the bundled Alertmanager’s configuration by
templates/alertmanager-config.yaml. A rule says how bad a condition is with
its severity label; this block says what each severity means for this
deployment and who hears about it.
Alert Channels
walks through it.
alerting:
preset: important
receivers:
oncall:
class: page
config:
pagerduty_configs:
- routing_key_file: /etc/alertmanager/secrets/alertmanager-receivers/pagerduty-key
platform:
class: [high, normal, low]
config:
slack_configs:
- channel: "#platform-alerts"
api_url_file: /etc/alertmanager/secrets/alertmanager-receivers/slack-url
send_resolved: trueNothing here applies when the bundled Alertmanager is disabled. A deployment notifying its own Alertmanager configures that one’s routing itself.
| Key | Type | Default | Description | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| alerting | string | "important" | Which entry of `presets` routes alerts: `critical-infrastructure`, `important`, `evaluation`, or one of your own. The three shipped presets say how much this deployment depends on
Materialize. | ||||||||||||||||
| alerting | object |
{
"critical-infrastructure": {
"critical": "page",
"notice": "normal",
"warning": "high"
},
"evaluation": {
"critical": "normal",
"notice": "suppressed",
"warning": "normal"
},
"important": {
"critical": "high",
"notice": "low",
"warning": "normal"
}
} | Severity-to-class mappings, one per preset. A class names a kind of delivery —
This is a map, so one cell can be changed without restating the rest, a preset can carry a severity of its own, and a preset of your own is one more key. Class names are free-form. The render fails when the selected preset names a class no receiver serves, since an unroutable severity is otherwise discovered during the incident it should have reported. | ||||||||||||||||
| alerting | string | "warning" | Severity an alert is routed as when its `severity` label is missing or is not in the preset. An alert that matches no severity route still reaches somebody, rather than the null receiver. | ||||||||||||||||
| alerting | object |
{} | Notification receivers, keyed by name. Empty means no alert reaches anybody.
The chart does not model receiver types, so every integration Alertmanager
documents works as written in its
configuration reference.
Credentials are referenced through a Several receivers can serve one class, and every one of them is notified. | ||||||||||||||||
| alerting | object |
{
"group_by": [
"alertname",
"cluster",
"namespace"
],
"group_interval": "5m",
"group_wait": "30s",
"repeat_interval": "4h"
} | The top-level route's grouping and timing. Every route below inherits them.
| ||||||||||||||||
| alerting | list | [] | Routes placed ahead of the preset's severity routes, in Alertmanager's route format. An alert is tested against these first, so a specific match wins and the
preset remains the fallback. A route that should also reach the preset sets
| ||||||||||||||||
| alerting | list | [] | Alertmanager `inhibit_rules`, verbatim. | ||||||||||||||||
| alerting | list | [] | Alertmanager `time_intervals`, verbatim. Routes reference them by name from `mute_time_intervals` and `active_time_intervals`. | ||||||||||||||||
| alerting | bool | true | Make the chart's notification templates every receiver's default. The chart ships its templates as
A template under | ||||||||||||||||
| alerting | string | "" | Grafana's browser-facing URL, for the links in notifications. Empty derives it. Notifications link to the alert group and to a pre-filled silence in
Grafana’s alerting pages, which read the bundled Alertmanager through the
Set this when that address is not the one people reach Grafana at: an
external Grafana addressed in-cluster, or a | ||||||||||||||||
| alerting | object |
{} | Notification templates, keyed by file name, loaded by every receiver. Names end in `.tmpl`; anything else is not loaded and fails the render. They load after the chart's `mzmon.gotmpl`, so a name defined in both takes the definition here. See `defaultTemplates`. | ||||||||||||||||
| alerting | object |
{} | Alertmanager's `global` block, verbatim: `resolve_timeout`, SMTP defaults, a shared `http_config`. | ||||||||||||||||
| alerting | object |
{
"certFile": "/etc/mzmon/tls/tls.crt",
"clientAuth": "NoClientCert",
"clientCAFile": "",
"enabled": false,
"keyFile": "/etc/mzmon/tls/tls.key",
"minVersion": "TLS13"
} | TLS on Alertmanager's API port (9093), rendered as its `--web.config.file`. Off by default.
The certificate, the client-auth policy and the CA are re-read on every
connection, so a renewal or a move between
| ||||||||||||||||
| alerting | bool | true | Fail the render when a receiver or `global` carries an inline credential. Leave this on. Values files are committed, diffed, pasted into support
threads and rendered into Terraform plans, so a token written into one is a
token published. Nearly every Alertmanager credential field has a |
Uninstall cleanup#
Deleting resources this chart cannot delete on its own.
grafana-operator puts operator.grafana.com/finalizer on the custom
resources it reconciles, and clears it only after removing the corresponding
object from the Grafana instance. Helm’s uninstall does not order that
against the operator’s own removal, so the ordinary teardown races: the
operator Deployment goes away with everything else, nobody is left to process
the finalizers, and the GrafanaManifest / GrafanaDatasource objects sit in
Terminating forever. The namespace then will not delete either, and the next
install adopts the leftovers.
A pre-delete hook is the fix because of when it runs: before Helm removes
anything, so the operator is still up and still watching. kubectl delete
blocks until the objects are actually gone, which is the point — it returns
only once the finalizers have been processed, and Helm proceeds from there.
| Key | Type | Default | Description |
|---|---|---|---|
| cleanup | bool | true | Run the pre-delete cleanup hook. Turning this off restores the hang described above; the manual recovery is to delete the resources yourself before `helm uninstall`, or to clear the finalizers by hand afterwards. |
| cleanup | string | "instance" | Which resources the hook deletes: `instance` or `release`.
That deliberately reaches beyond this release. The dashboards ship in the
Only |
| cleanup | list | [ "grafanamanifests.grafana.integreatly.org", "grafanadatasources.grafana.integreatly.org", "grafanafolders.grafana.integreatly.org" ] | Resource types to delete, as ` |
| cleanup | string | "2m" | How long `kubectl delete` waits for the finalizers to clear. This bounds the hook rather than the uninstall: on expiry the objects are already marked for deletion, the hook fails, and Helm stops before removing the operator — which leaves the cluster recoverable instead of wedged. |
| cleanup | int | 1 | Number of times to retry the job before failing the uninstall. |
| cleanup | int | 420 | Hard ceiling on the Job, including retries. Sized above `timeout` × (`backoffLimit` + 1) so the kubectl timeout is what normally fires; this only catches a pod that never gets that far. |
| cleanup | object |
{
"helm.sh/hook": "pre-delete",
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded",
"helm.sh/hook-weight": "0"
} | Job specific annotations. The default makes this a pre-delete hook; setting any annotation replaces that, which is how you would move it to `post-delete` or set an argocd sync wave. |
| cleanup | object |
{
"pullPolicy": "IfNotPresent",
"pullSecrets": [],
"registry": "registry.k8s.io",
"repository": "kubectl",
"tag": "v1.36.5"
} | Image for the cleanup job. Upstream's own kubectl build: distroless, multi-arch, and published beside Kubernetes itself, so it tracks patch releases without a third-party rebuild. Its entrypoint is `/bin/kubectl` and there is no shell in the image — the hook runs one argv and needs nothing else. Keep `tag` within one minor of your API server, per Kubernetes' version-skew policy. |
| cleanup | object |
{
"limits": {
"cpu": "100m",
"memory": "64Mi"
},
"requests": {
"cpu": "50m",
"memory": "32Mi"
}
} | Resources for the cleanup job. It issues one API call and waits, so this is deliberately small. |
| cleanup | object |
{
"runAsGroup": 65532,
"runAsNonRoot": true,
"runAsUser": 65532,
"seccompProfile": {
"type": "RuntimeDefault"
}
} | Security context for the cleanup job pod. |
| cleanup | object |
{
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": [
"ALL"
]
},
"readOnlyRootFilesystem": true,
"runAsGroup": 65532,
"runAsNonRoot": true,
"runAsUser": 65532
} | Security context for the cleanup job container. The image ships `USER 0`; kubectl needs no privileges to call an API server, so it is dropped to nonroot here rather than trusted to the image. |
| cleanup | object |
{} | Node selector for the cleanup job. |
| cleanup | list | [] | Tolerations for the cleanup job. An uninstall has to work even when the ordinary pools are tainted or full, so this is worth widening if scheduling it is ever the thing that fails. |
Bundled subchart configurations#
Configuration for bundled subcharts
Alloy Agent#
Alloy collector instance running close to scrape targets. Pre-egress shaping happens here.
Upstream reference:
- https://github.com/grafana/alloy/tree/main/operations/helm/charts/alloy
- https://github.com/grafana/alloy/blob/main/operations/helm/charts/alloy/values.yaml
| Key | Type | Default | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| alloy-agent | string | "alloy-agent" | Standard Helm full-name override. We use a static name for deterministic relations. | |||||||||
| alloy-agent | string | nil | Namespace override. | |||||||||
| alloy-agent | object |
{
"create": false
} | Control for the PodLogs crd. | |||||||||
| alloy-agent | object |
{
"fsGroup": 473,
"runAsGroup": 473,
"runAsUser": 473
} | Security context for the alloy agent pods. | |||||||||
| alloy-agent | string | "generally-available" | Stability level of alloy components. | |||||||||
| alloy-agent | list |
[
{
"name": "GOMEMLIMIT",
"value": "240MiB"
}
] | Extra environment variables to pass to the alloy agent pod.
| |||||||||
| alloy-agent | list |
[
{
"configMapRef": {
"name": "mzmon-alloy-agent-env"
}
},
{
"secretRef": {
"name": "mzmon-alloy-agent-env",
"optional": true
}
}
] | Environment variable configmaps/secrets to pass to the alloy agent pod. | |||||||||
| alloy-agent | object |
{
"extra": [
{
"mountPath": "/tmp",
"name": "tmp"
},
{
"mountPath": "/run/log/journal",
"name": "runlogjournal",
"readOnly": true
},
{
"mountPath": "/etc/machine-id",
"name": "machineid",
"readOnly": true
},
{
"mountPath": "/etc/mzmon/tls",
"name": "mzmon-tls",
"readOnly": true
}
],
"varlog": true
} | Volume mounts to expose to alloy agent.
Three mounts feed `loki.source.journal`, and journal collection needs all
three. Every way of getting it wrong fails the same silent way: the
component starts, reports healthy, and reads nothing. Where the journal lives is host-specific, so both roots have to be covered. Observed:
Bottlerocket also has an empty Deliberately not mounting
Measured on a Bottlerocket node, controlling for the restart that a mount
change forces: with the mount, A kind node happens to work without it, which makes this a bad thing to
test on kind alone — the two differ in which directory holds the journal
( | |||||||||
| alloy-agent | object |
{
"mountPath": "/etc/mzmon/tls",
"name": "mzmon-tls",
"readOnly": true
} | Certificate material from `certificates`, when it is enabled.
`optional: true` is what lets this be unconditional. The Secret does
not exist until `certificates.enabled` is on and cert-manager has
signed, and an optional secret volume that is missing mounts empty
rather than blocking the pod — so the same values work before, during
and after issuance. That holds only while nothing reads the material, which is why the
mTLS profiles flip it to The kubelet refreshes the contents in place on renewal, which is why
the | |||||||||
| alloy-agent | object |
{
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": [
"ALL"
]
},
"readOnlyRootFilesystem": true,
"runAsGroup": 473,
"runAsNonRoot": false,
"runAsUser": 0,
"seccompProfile": {
"type": "RuntimeDefault"
}
} | Security context for the alloy agent containers. The agent MUST run as root in order to be able to read container logs. No capabilities are added and none are needed: everything it reads is reachable by uid 0 under ordinary DAC. | |||||||||
| alloy-agent | object |
{
"limits": {
"cpu": "250m",
"memory": "300Mi"
},
"requests": {
"cpu": "100m",
"memory": "300Mi"
}
} | Resources for the alloy agent containers. Sized for the logs-only agent. This needed ~750Mi while an in-process cAdvisor lived here; that moved to the gateway, so the envelope is back to what the log path actually costs, with headroom over the ~200Mi it ran at before. The CPU limit stays above the request: the agent is bursty, and a limit equal to the request turns that into CFS throttling on the busiest nodes. | |||||||||
| alloy-agent | object |
{} | Extra annotations to apply to the alloy agent pod. If you are using pulumi, be sure to add `config.kubernetes.io/depends-on: job/mzmon-validate-agent` | |||||||||
| alloy-agent | list |
[
{
"effect": "NoSchedule",
"operator": "Exists"
}
] | Taints the agent tolerates out of the box: every `NoSchedule` one. Coverage is correctness for this workload, not a placement preference. A node the DaemonSet never lands on produces no logs and no error, and nothing shows a hole where a node should be — so the default is the same blanket rule `node-exporter` already ships, for the same reason. Enumerating keys instead would make coverage depend on this chart knowing your taints, which it does not: a node pool tainted with anything the list does not name goes silently uncollected. That is the failure this replaced. It is not always silent, either. A bootstrap gate — a `NoSchedule` taint applied to every node at boot and lifted once the DaemonSets are running, which is what `node.materialize.com/daemonsets-not-scheduled` is — deadlocks outright against an agent that does not tolerate it: the taint waits on a pod that is waiting on the taint, and the node never admits any workload. `NoExecute` is deliberately not tolerated here — a node being drained for a problem should shed the agent too. The two exceptions are not the chart's to make: the DaemonSet controller adds `node.kubernetes.io/not-ready` and `node.kubernetes.io/unreachable` (both `NoExecute`) to every DaemonSet pod unconditionally, so the agent keeps running on a node that goes unreachable whatever is written here. Add other `NoExecute` taints through the Terraform module's `tolerations`, which appends to this list. To narrow this — a node pool whose per-node budget genuinely cannot absorb the agent — replace the list rather than adding to it, and record which pools you gave up. Helm overwrites lists, so setting this key at all replaces the whole default. | |||||||||
| alloy-agent | string | "monitoring-critical" | Scheduling priority. See the Priority classes section. Critical: this is a per-node singleton, so an eviction is a log gap on that node with no replica to cover it. | |||||||||
| alloy-agent | object |
{
"egress": null,
"enabled": true,
"flavor": "kubernetes",
"ingress": [
{
"from": [
{
"podSelector": {
"matchLabels": {
"app.kubernetes.io/name": "alloy-gateway"
}
}
}
],
"ports": [
{
"port": 12345,
"protocol": "TCP"
}
]
}
],
"policyTypes": [
"Ingress"
]
} | NetworkPolicy for the agent. Ingress is one port from one peer. Egress is not restricted, and the The default | |||||||||
| alloy-agent | string | nil | Null rather than `[]`: an empty list still merges as "no rules", but null is what the subchart documents for "do not render this direction", and it keeps `egress` out of a spec whose `policyTypes` omits `Egress`. |
Alloy Gateway#
Alloy gateway instance. Cardinality reduction and backend-specific egress happen here.
Upstream reference:
- https://github.com/grafana/alloy/tree/main/operations/helm/charts/alloy
- https://github.com/grafana/alloy/blob/main/operations/helm/charts/alloy/values.yaml
| Key | Type | Default | Description |
|---|---|---|---|
| alloy-gateway | string | "alloy-gateway" | Standard Helm full-name override. We use a static name for deterministic relations. |
| alloy-gateway | string | nil | Namespace override. |
| alloy-gateway | object |
{
"create": false
} | Control for the PodLogs crd. |
| alloy-gateway | object |
{
"fsGroup": 473,
"runAsGroup": 473,
"runAsUser": 473
} | Security context for the alloy gateway pods. |
| alloy-gateway | string | "experimental" | Stability level of alloy components.
A lower level renders an error rather than a gateway that crashloops:
|
| alloy-gateway | list |
[
{
"name": "AUTOMEMLIMIT",
"value": "0.8"
}
] | Extra environment variables to pass to the alloy gateway pod. `AUTOMEMLIMIT` sets `GOMEMLIMIT` to 80% of the container's memory limit when Alloy starts, for the same reason as the agent's `GOMEMLIMIT`. Deriving it means raising `resources` is the whole change: the GC ceiling follows, and so do the memory limiter's thresholds, which are percentages of the same limit. The ratio has to stay below the limiter's 85%. The GC should be holding the heap down well before the limiter starts refusing scrapes, and a refused scrape is lost. An explicit `GOMEMLIMIT` here takes precedence and is not kept in step with `resources`. Replacing this list without either leaves Alloy's own default of 90%, which sits above the limiter. |
| alloy-gateway | list |
[
{
"configMapRef": {
"name": "mzmon-alloy-gateway-env"
}
},
{
"secretRef": {
"name": "mzmon-alloy-gateway-env",
"optional": true
}
}
] | Environment variable configmaps/secrets to pass to the alloy gateway pod. |
| alloy-gateway | list |
[
{
"name": "loki",
"port": 3100,
"protocol": "TCP",
"targetPort": 3100
},
{
"name": "otlp-grpc",
"port": 4317,
"protocol": "TCP",
"targetPort": 4317
},
{
"name": "otlp-http",
"port": 4318,
"protocol": "TCP",
"targetPort": 4318
},
{
"name": "prom",
"port": 9090,
"protocol": "TCP",
"targetPort": 9090
}
] | Ports to expose from alloy-gateway. |
| alloy-gateway | object |
{
"extra": [
{
"mountPath": "/tmp",
"name": "tmp"
},
{
"mountPath": "/etc/mzmon/tls",
"name": "mzmon-tls",
"readOnly": true
}
],
"varlog": false
} | Volume mounts to expose to alloy gateway. |
| alloy-gateway | object |
{
"mountPath": "/etc/mzmon/tls",
"name": "mzmon-tls",
"readOnly": true
} | Certificate material from `certificates`, when it is enabled.
`optional: true` is what lets this be unconditional. The Secret does
not exist until `certificates.enabled` is on and cert-manager has
signed, and an optional secret volume that is missing mounts empty
rather than blocking the pod — so the same values work before, during
and after issuance. That holds only while nothing reads the material, which is why the
mTLS profiles flip it to The kubelet refreshes the contents in place on renewal, which is why
the |
| alloy-gateway | object |
{
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": [
"ALL"
]
},
"readOnlyRootFilesystem": true,
"runAsGroup": 473,
"runAsNonRoot": true,
"runAsUser": 473,
"seccompProfile": {
"type": "RuntimeDefault"
}
} | Security context for the alloy gateway containers. The locked-down context the `mzmon-alloy` image documents, `RuntimeDefault` seccomp included. No pipeline component here needs a syscall that profile blocks; the eBPF-based ones, such as `beyla` and `pyroscope.ebpf`, would. |
| alloy-gateway | object |
{
"limits": {
"cpu": "500m",
"memory": "2Gi"
},
"requests": {
"cpu": "500m",
"memory": "2Gi"
}
} | Resources for the alloy gateway containers. Memory is the gateway’s binding constraint. Its live heap is about 200MiB
of fixed cost plus about 2.6KiB for every series the pod holds. Each series
is held three times: by the scrape cache, the OTLP-to-Prometheus bridge
and the remote-write queue. The gateways split the scrape targets, so at
Keep that live heap under about half of The default fits a medium install. It was measured on 9 nodes and ten
Materialize replicas, a quarter of a million series in all. At 2 replicas a
pod there holds 120k to 160k series, about 500 to 600MiB live, against a
1.6GiB |
| alloy-gateway | object |
{} | Extra annotations to apply to the alloy gateway pod. If you are using pulumi, be sure to add `config.kubernetes.io/depends-on: job/mzmon-validate-gateway` |
| alloy-gateway | string | "monitoring-critical" | Scheduling priority. See the Priority classes section. Critical despite being a Deployment: every signal in the stack leaves through here, so losing it stops logs and metrics at once. |
| alloy-gateway | int | 0 | Memory scaling is deliberately OFF (`0` is the subchart's disable value; it renders the metric away rather than setting it to zero). Not a tuning choice — memory utilization does not say whether a gateway is short of memory. The Go runtime lets the heap grow toward `GOMEMLIMIT` before it collects hard, so a healthy pod's working set reads high too, and on a small cluster most of it is fixed per-process cost that a new replica only duplicates. On a 7-node cluster, going from 3 replicas to 6 moved per-pod memory from 370Mi to 341Mi while total consumption went from 1.1Gi to 2.0Gi. It also cannot stabilize. Idle sat at ~62% of the request, so a 60% target was below the floor: the HPA scaled up, the metric did not move, and it flapped against maxReplicas indefinitely. No target value fixes that, because the control loop is open — the action does not change the measurement. Size gateway memory vertically instead (`resources`, above). A pod that is short of it shows on CPU first, because collecting more often is what it spends the CPU on. |
| alloy-gateway | object |
{
"policies": [
{
"periodSeconds": 600,
"type": "Pods",
"value": 1
}
],
"selectPolicy": "Max",
"stabilizationWindowSeconds": 3600
} | Scale down slowly: only after an hour of low CPU, and one pod at a time. Every scale event reshards the scrape targets. A scale-down hands each remaining pod a larger share, and every pod keeps the series of the targets it gave up until its write-ahead log next checkpoints, hours later. A gateway that scales down and back up within the hour pays for both shares at once. The scale-up path stays fast, because it relieves load rather than concentrating it. |
| alloy-gateway | bool | true | Create a service account for alloy-gateway. |
| alloy-gateway | object |
{} | Extra annotations to set on the alloy-gateway service account. Use `eks.amazonaws.com/role-arn` to set up IRSA. Use `iam.gke.io/gcp-service-account` to set up Workload Identity Federation. |
| alloy-gateway | object |
{
"egress": null,
"enabled": true,
"flavor": "kubernetes",
"ingress": [
{
"from": [
{
"namespaceSelector": {}
}
],
"ports": [
{
"port": 3100,
"protocol": "TCP"
},
{
"port": 4317,
"protocol": "TCP"
},
{
"port": 4318,
"protocol": "TCP"
},
{
"port": 9090,
"protocol": "TCP"
}
]
},
{
"from": [
{
"podSelector": {
"matchLabels": {
"app.kubernetes.io/name": "alloy-gateway"
}
}
}
],
"ports": [
{
"port": 12345,
"protocol": "TCP"
}
]
}
],
"policyTypes": [
"Ingress"
]
} | NetworkPolicy for the gateway. Two ingress rules, because the gateway’s ports fall into two groups with very different audiences. The ingest ports —
Egress is not restricted, and cannot usefully be. The gateway scrapes the
kubelet cAdvisor endpoint on every node — port |
| alloy-gateway | string | nil | See the note above: unrestricted, deliberately. |
Loki#
Bundled Loki backend for logs.
Upstream reference:
- https://github.com/grafana-community/helm-charts/tree/main/charts/loki
- https://github.com/grafana-community/helm-charts/blob/main/charts/loki/values.yaml
| Key | Type | Default | Description | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| loki | string | "loki" | Standard Helm full-name override. We use a static name for deterministic relations. | ||||||||||||||||||
| loki | string | nil | Namespace override. | ||||||||||||||||||
| loki | string | "monitoring-scalable" | Scheduling priority for Loki. See the Priority classes section. `global` covers every component that renders through the chart's `_pod.tpl` — but *not* the two memcached StatefulSets, which read only their own component key. They are set explicitly further down. This is the same asymmetry `terraform/modules/materialize-monitoring/scheduling.tf` calls out for nodeSelector and tolerations; rendering is what catches it. | ||||||||||||||||||
| loki | string | "Distributed" | How loki is deployed. We prefer to run in Distributed/Microservices mode. | ||||||||||||||||||
| loki | bool | true | Whether to enable a network policy for loki In production, this is recommended to be enabled. | ||||||||||||||||||
| loki | object |
{
"namespaceSelector": {}
} | Selector for incoming traffic to metric endpoints. This must be configured manually (usually set to `kubernetes.io/metadata.name: monitoring`). | ||||||||||||||||||
| loki | object |
{
"namespaceSelector": {}
} | Selector for incoming traffic to the read/write endpoints. This must be configured manually (usually set to `kubernetes.io/metadata.name: monitoring`). | ||||||||||||||||||
| loki | object |
{
"cidrs": [
"0.0.0.0/0"
],
"ports": [
443,
80
]
} | Outgoing traffic from loki to the object store. This is usually unrestricted, even in many production settings. Adjust if you have a VPCEndpoint in front of S3/STS or are using a non-standard port. | ||||||||||||||||||
| loki | object |
{
"registry": "docker.io",
"repository": "grafana/loki",
"tag": "3.7.9"
} | Loki image, pinned here rather than inherited from the subchart's `appVersion`. So Renovate bumps Loki on its own cadence, with Loki’s own release notes,
instead of only when a chart release happens to carry a new In the Loki chart, | ||||||||||||||||||
| loki | object |
{
"chunks": "\u003cREPLACE-ME\u003e",
"ruler": "\u003cREPLACE-ME\u003e"
} | Bucket names for object storage. These are required to be populated. | ||||||||||||||||||
| loki | bool | true | Use the thanos object store client | ||||||||||||||||||
| loki | object |
{
"s3": {
"endpoint": "s3.amazonaws.com"
},
"type": "s3"
} | Object storage configuration. Modify as needed. Only the block named by `type` is read; it is handed to Loki verbatim, with `bucket_name` filled in from `bucketNames` above. | ||||||||||||||||||
| loki | object |
{
"configs": [
{
"from": "2024-01-01",
"index": {
"period": "24h",
"prefix": "loki_index_"
},
"object_store": "s3",
"schema": "v13",
"store": "tsdb"
}
]
} | Schema configuration for the loki TSDB. This is append-only and MUST be copied into projects and mutated on upgrades (if v13 stops being in use). | ||||||||||||||||||
| loki | object |
{
"replication_factor": 3
} | Cluster-wide replication factor. Three is our HA floor and the reason at least three ingesters are required. (This matches the upstream default; surfaced here because it is load-bearing.) | ||||||||||||||||||
| loki | object |
{
"ingestion_burst_size_mb": 20,
"ingestion_rate_mb": 10,
"max_global_streams_per_user": 10000,
"reject_old_samples": true,
"reject_old_samples_max_age": "168h",
"retention_period": "30d",
"volume_enabled": true
} | Per-tenant limits, sized for a medium install. These are protective caps (per environment), not the expected volume — see Operating > Production Best Practices for the throughput envelope. | ||||||||||||||||||
| loki | string | "30d" | Default retention before the compactor deletes logs. Upstream defaults to infinite retention; we set a real bound. | ||||||||||||||||||
| loki | int | 10 | Per-tenant ingestion rate / burst, in MB. Per environment, not the fleet aggregate. | ||||||||||||||||||
| loki | int | 10000 | Active-stream ceiling per tenant; a cardinality guard. | ||||||||||||||||||
| loki | bool | true | Reject writes too far in the past. | ||||||||||||||||||
| loki | bool | true | Enable the log-volume endpoints Grafana's logs drilldown relies on. | ||||||||||||||||||
| loki | object |
{
"delete_request_store": "s3",
"retention_delete_delay": "8h",
"retention_enabled": true
} | Compactor *configuration* (distinct from the compactor deployment below). Retention is enforced by the compactor and is OFF in upstream defaults. | ||||||||||||||||||
| loki | string | "s3" | Where delete requests are stored; matches the object-store backend. | ||||||||||||||||||
| loki | string | "8h" | Grace period before retention/deletes actually remove data. | ||||||||||||||||||
| loki | object |
{
"alert_relabel_configs": [
{
"action": "replace",
"regex": "",
"replacement": "${CLUSTER_NAME}",
"source_labels": [
"cluster"
],
"target_label": "cluster"
}
],
"alertmanager_refresh_interval": "30s",
"alertmanager_url": "http://_http._tcp.alertmanager-headless.{{ .Release.Namespace }}.svc.{{ include \"mzmon.clusterDomain\" . }}",
"enable_alertmanager_discovery": true,
"enable_alertmanager_v2": true,
"enable_sharding": true,
"evaluation_interval": "1m",
"poll_interval": "1m",
"remote_write": {
"clients": {
"gateway": {
"queue_config": {
"batch_send_deadline": "5s",
"capacity": 2500,
"max_samples_per_send": 500,
"max_shards": 10,
"min_shards": 1
},
"remote_timeout": "30s",
"url": "http://alloy-gateway.{{ .Release.Namespace }}.svc.{{ include \"mzmon.clusterDomain\" . }}:9090/api/v1/metrics/write",
"write_relabel_configs": [
{
"regex": "",
"replacement": "loki-ruler",
"source_labels": [
"job"
],
"target_label": "job"
},
{
"regex": "",
"replacement": "loki-ruler",
"source_labels": [
"instance"
],
"target_label": "instance"
}
]
}
},
"enabled": true
},
"rule_path": "/var/loki/ruler-rules"
} | Ruler *configuration* (distinct from the ruler deployment below). The ruler deployment has been on since this chart had one. What it has
never had is somewhere to send an alert: without Rule storage is configured and is not here. With
Everything here is | ||||||||||||||||||
| loki | bool | true | Divide the rule groups between the ruler replicas, so each group is evaluated by one of them. Without it every replica loads every group: each alert is sent once per replica, and each recording-rule sample is written once per replica. The replicas divide the groups through a hash ring. Loki puts every ring
on memberlist when
| ||||||||||||||||||
| loki | object |
{
"clients": {
"gateway": {
"queue_config": {
"batch_send_deadline": "5s",
"capacity": 2500,
"max_samples_per_send": 500,
"max_shards": 10,
"min_shards": 1
},
"remote_timeout": "30s",
"url": "http://alloy-gateway.{{ .Release.Namespace }}.svc.{{ include \"mzmon.clusterDomain\" . }}:9090/api/v1/metrics/write",
"write_relabel_configs": [
{
"regex": "",
"replacement": "loki-ruler",
"source_labels": [
"job"
],
"target_label": "job"
},
{
"regex": "",
"replacement": "loki-ruler",
"source_labels": [
"instance"
],
"target_label": "instance"
}
]
}
},
"enabled": true
} | Remote-write for recording-rule samples. This is what the ruler’s PVC exists for: the WAL buffers derived samples
when the metric store is unreachable, which is exactly the run-up to an
incident. Same destination as the Thanos ruler — the alloy-gateway’s
| ||||||||||||||||||
| loki | bool | true | Run the Loki gateway, the nginx reverse proxy Grafana reads Loki through. Grafana’s alerting UI lists a datasource’s rules from the datasource’s own URL. In distributed mode the query frontend answers queries and the ruler answers for rules, so a datasource pointed at either one sees half of Loki. The gateway routes by path and gives Grafana one address for both. The routing is this chart’s own (
Every request resolves its backend’s name through | ||||||||||||||||||
| loki | int | 2 | Replicas. Two, for availability. The subchart's required per-node anti-affinity is kept, so each replica needs a node of its own. | ||||||||||||||||||
| loki | int | 1 | Keep one gateway through voluntary disruption. | ||||||||||||||||||
| loki | object |
{
"pullPolicy": "IfNotPresent",
"registry": "docker.io",
"repository": "nginxinc/nginx-unprivileged",
"tag": "1.31.7-alpine-slim"
} | The nginx image. The `-alpine-slim` variant carries the SSL and stub-status modules this config uses, at a quarter of the full image's size. | ||||||||||||||||||
| loki | object |
{
"requests": {
"cpu": "50m",
"memory": "64Mi"
}
} | Resource requests. | ||||||||||||||||||
| loki | bool | false | Log only failed requests (4xx and 5xx). Grafana reads through the gateway constantly, and every successful request's line would be ingested back into the Loki it is proxying. | ||||||||||||||||||
| loki | int | 8080 | The Service port, equal to the container port. A TLS profile then changes only the scheme of the URL that dials it. | ||||||||||||||||||
| loki | string | "{{ include \"mzmon.loki.gateway.nginxConf\" . }}" | The nginx.conf, rendered by `mzmon.loki.gateway.nginxConf`. Its header comment lists what it routes and what it refuses. | ||||||||||||||||||
| loki | desc | every path empty | TLS for the gateway's listener and for its connections to Loki. Read by this chart’s nginx.conf; the subchart does not know the key.
Requiring a client certificate is not offered. The kubelet’s readiness probe dials the same listener and cannot present one, the same limit that holds Loki’s own HTTP port at phase 2. | ||||||||||||||||||
| loki | object |
{
"pullPolicy": "IfNotPresent",
"registry": "ghcr.io",
"repository": "jkroepke/access-log-exporter",
"tag": "1.0.1"
} | The access-log exporter sidecar, which turns the gateway's request log into Prometheus metrics. | ||||||||||||||||||
| loki | list | [ "--nginx.scrape-url=http://127.0.0.1:8081/stub_status" ] | Scrape `stub_status` from the loopback-only listener the nginx.conf serves on 8081, rather than from the client port. The flag is repeated after the subchart's own and the later one wins, so turning on TLS for clients does not break the scrape. | ||||||||||||||||||
| loki | object |
{
"monitoring.materialize.cloud/scrape-scheme": "plaintext",
"prometheus.io/service-monitor": "false"
} | Keep the exporter out of the subchart's ServiceMonitor and into this chart's plaintext one. It serves `/metrics` in the clear whatever the gateway's listener does. See `monitoring.serviceMonitor` below. |
Loki Microservice Configuration#
Configuration for each loki microservice.
https://grafana.com/docs/loki/latest/get-started/components/
| Key | Type | Default | Description |
|---|---|---|---|
| loki | bool | true | Enable the distributor microservice. Distributor is required. The Distributor is the stateless front door for writes. It validates incoming streams against per-tenant limits, enforces rate limits, and normalizes labels, then splits the batch into individual streams and forwards each to the owning ingesters. |
| loki | string | nil | Number of replicas for the distributor microservice. If autoscaling is enabled, this should be set to null. |
| loki | string | "Deployment" | Type of workload for the distributor. |
| loki | object |
{
"enabled": true,
"maxReplicas": 4,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 60,
"targetMemoryUtilizationPercentage": 75
} | Configuration for autoscaling of distributor For production, this is recommended to be enabled. We provide opinionated defaults. |
| loki | string | "ClusterIP" | Service type for the distributor microservice. Without a gateway, this is the exposed write component. |
| loki | object |
{
"enabled": true,
"minAvailable": 1
} | Configuration for pod disruption budget for the distributor microservice. |
| loki | object |
{
"requests": {
"cpu": "150m",
"memory": "256Mi"
}
} | Resource requests (medium install). Tune per profile. |
| loki | bool | true | Enable the ingester microservice. Ingester is required. The Ingester is the stateful heart of the write path, and it also serves the most recent reads. It buffers incoming entries into per-stream in-memory chunks, compresses them, and periodically flushes those chunks and their index to object storage. |
| loki | int | 3 | Number of ingester replicas. Three is the floor for replication_factor 3. Scale UP on memory / stream-cardinality (so streams shard across the ring), not on bytes — with replicas == replication_factor every ingester holds every stream. |
| loki | string | "StatefulSet" | Ingesters are stateful. |
| loki | bool | false | Run ingesters ephemerally: no PVC, node-local emptyDir for the WAL and not-yet-flushed chunks. Durability comes from replication_factor 3, not from disk — a rescheduled ingester starts fresh and the ring backfills from its peers. This avoids EBS zonal pinning and the slow volume detach/attach that PVCs incur during node replacement. |
| loki | int | 60 | Termination grace period for ingesters. flush-on-shutdown is best-effort; if the node force-kills sooner, the other replicas still flush their copies, so do not depend on a long grace period for durability. Kept modest to survive enterprise force-kill windows. |
| loki | object |
{
"podAntiAffinity": {
"requiredDuringSchedulingIgnoredDuringExecution": null
}
} | Pod affinity for ingesters. Drop the chart's default *hard* per-host anti-affinity so host spread can be soft (see topologySpreadConstraints); zone spread stays hard. We null the nested list rather than the whole `affinity` map: `affinity: {}` is a no-op against the subchart default, and `affinity: null` clears it but makes helm-unittest log a noisy "cannot overwrite table" warning — nulling the list avoids both. |
| loki | list |
[
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "ingester",
"app.kubernetes.io/instance": "{{ .Release.Name }}",
"app.kubernetes.io/name": "{{ include \"loki.name\" . }}"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"minDomains": 2,
"nodeTaintsPolicy": "Honor",
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "DoNotSchedule"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "ingester",
"app.kubernetes.io/instance": "{{ .Release.Name }}",
"app.kubernetes.io/name": "{{ include \"loki.name\" . }}"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
] | Topology spread for ingesters: hard across zones, soft across hosts. A pod that cannot satisfy the hard zone rule goes Pending, which is the signal Karpenter (or the cluster-autoscaler) uses to add a node in the deficient zone — soft rules cannot summon capacity that way. Host spread is soft so pods still schedule when nodes are momentarily scarce. |
| loki | bool | false | Enable zone-aware replication for the ingester microservice. Not needed at our size: topologySpreadConstraints give the AZ spread without the complexity. |
| loki | object |
{
"enabled": true,
"maxUnavailable": 1
} | PDB for the ingester. Protect ingest quorum across rollouts and node drains. Do not set maxUnavailable >= 2 to avoid potential quorum loss. |
| loki | object |
{
"requests": {
"cpu": "500m",
"memory": "1Gi"
}
} | Resource requests (medium install). The memory *limit* is intentionally left unset: an OOM-kill drops in-memory / WAL-buffered logs, so we alert on usage rather than cap hard. |
| loki | bool | true | Enable the querier microservice. Querier is required. It executes LogQL, reading recent data from ingesters and historical data from object storage. |
| loki | string | nil | Replicas are managed by autoscaling; leave null. |
| loki | object |
{
"enabled": true,
"maxReplicas": 4,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 60,
"targetMemoryUtilizationPercentage": 75
} | Autoscale the stateless read workers with query load. |
| loki | object |
{
"enabled": true,
"minAvailable": 1
} | PDB for querier. |
| loki | object |
{
"requests": {
"cpu": "250m",
"memory": "512Mi"
}
} | Resource requests. This assumes a medium install by default. |
| loki | bool | true | Enable the query-frontend microservice. Query-frontend is required. It queues and splits queries and caches results. |
| loki | string | nil | Static replicas for query-frontend when autoscaling is not enabled. |
| loki | string | "Deployment" | Kind of workload for query-frontend. Deployments are recommended for stateless components. |
| loki | object |
{
"enabled": true,
"maxReplicas": 4,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 60,
"targetMemoryUtilizationPercentage": 75
} | Configure autoscaling for query-frontend. |
| loki | object |
{
"enabled": true,
"minAvailable": 1
} | PDB for query-frontend. |
| loki | object |
{
"requests": {
"cpu": "100m",
"memory": "256Mi"
}
} | Resource requests for query-frontend. |
| loki | bool | true | Enable the query-scheduler microservice. Recommended for scaled deployments: it decouples the read queue from the query-frontend so the two scale independently. Two replicas for availability. |
| loki | object |
{
"enabled": true,
"minAvailable": 1
} | PDB for query-scheduler. |
| loki | object |
{
"requests": {
"cpu": "100m",
"memory": "256Mi"
}
} | Resources for the query-scheduler microservice. |
| loki | bool | true | Enable the compactor. Compactor is required and MUST be a singleton: it compacts the index and enforces retention against shared object storage. |
| loki | int | 1 | Compactor replicas. This MUST be one, since it runs as a singleton. |
| loki | string | "StatefulSet" | Kind of workload for the compactor. This must be a statefulset. |
| loki | bool | false | Run the compactor ephemerally (no PVC, node-local emptyDir). Its local dir is just a working copy of the object-store index, and compaction is idempotent and off the critical path — losing it only restarts the current cycle. Ephemeral also lets the singleton float freely between zones instead of being pinned by a PVC. Our production index is ~30MB, so re-downloading each cycle is negligible. |
| loki | object |
{
"requests": {
"cpu": "250m",
"memory": "512Mi"
}
} | Resources for the compactor. |
| loki | bool | true | Enable the index-gateway. Index-gateway is required: it serves index lookups so queriers do not each download the whole index from object storage. |
| loki | object |
{
"enabled": false
} | Run the index-gateway ephemerally (no PVC, node-local emptyDir). The local index is a read-through cache of object storage, not authoritative — a fresh pod just re-downloads what it queries. This keeps the read path off EBS zonal pinning; the only cost is a little cold-start warmup and a burst of index reads after a reschedule. |
| loki | object |
{
"enabled": true,
"minAvailable": 1
} | PDB for index-gateway. |
| loki | object |
{
"requests": {
"cpu": "200m",
"memory": "512Mi"
}
} | Resources for the index-gateway. |
| loki | bool | true | Enable the ruler. The ruler evaluates LogQL alerting and recording rules. Recording-rule samples are remote-written back through alloy-gateway to the metric store. The evaluation and notification wiring lives in `loki.loki.rulerConfig`; this switch only decides whether the component is deployed. |
| loki | int | 2 | Replicas. Two, for availability. They divide the rule groups between them rather than each evaluating all of them; see `loki.loki.rulerConfig.enable_sharding`. |
| loki | bool | true | Keep a PVC for the ruler (unlike the other components, which are ephemeral). Rule definitions come from object storage, but the ruler's remote-write WAL buffers recording-rule samples when the metric store is unreachable — genuinely useful durability in the run-up to an incident, exactly when you don't want to drop derived signals. |
| loki | object |
{
"enabled": true,
"minAvailable": 1
} | PDB for the ruler. |
| loki | object |
{
"requests": {
"cpu": "100m",
"memory": "256Mi"
}
} | Resources for the ruler. |
| loki | list |
[
{
"name": "CLUSTER_NAME",
"valueFrom": {
"configMapKeyRef": {
"key": "CLUSTER_NAME",
"name": "ruler-env"
}
}
}
] | The cluster name, which Loki's `-config.expand-env` substitutes into `rulerConfig`. `CLUSTER_NAME` is stamped on alerts by `rulerConfig.alert_relabel_configs`, read from the `ruler-env` ConfigMap the chart renders in the Loki namespace from `clusterName`. A list: restate it when adding another. |
| loki | h5 | {"allocatedMemory":2048, "priorityClassName":"monitoring-scalable", "service":{"labels":{"monitoring.materialize.cloud/scrape-scheme":"plaintext", "prometheus.io/service-monitor":"false"}}} | Chunk cache (memcached). Default allocation is sized for very large installs; we shrink it to match our volumes. The results cache keeps its upstream default. `priorityClassName` is repeated on both caches because the memcached StatefulSet template reads its component key only — `loki.global` does not reach it. |
| loki | object |
{
"monitoring.materialize.cloud/scrape-scheme": "plaintext",
"prometheus.io/service-monitor": "false"
} | Keep the cache out of the subchart's ServiceMonitor and into this chart's plaintext one. See the `plaintext exporters` note under `monitoring.serviceMonitor` below. |
| loki | h5 | {"priorityClassName":"monitoring-scalable", "service":{"labels":{"monitoring.materialize.cloud/scrape-scheme":"plaintext", "prometheus.io/service-monitor":"false"}}} | Query results cache (memcached). |
| loki | object |
{
"monitoring.materialize.cloud/scrape-scheme": "plaintext",
"prometheus.io/service-monitor": "false"
} | Keep the cache out of the subchart's ServiceMonitor, as above. |
| loki | object |
{
"registry": "docker.io",
"repository": "library/memcached",
"tag": "1.6.45-alpine"
} | Memcached image for both caches, pinned here rather than left at the subchart's default. |
| loki | object |
{
"registry": "docker.io",
"repository": "prom/memcached-exporter",
"tag": "v0.17.0"
} | Memcached exporter image for both caches, pinned here rather than left at the subchart's default. |
| loki | object |
{
"registry": "docker.io",
"repository": "kiwigrid/k8s-sidecar",
"tag": "2.14.1"
} | Rules sidecar image, pinned here rather than left at the subchart's default. It runs only in `SingleBinary` mode (`profiles/loki-test`), where it loads rule ConfigMaps into the single binary's ruler. |
| loki | bool | true | Enable a ServiceMonitor for the loki microservices. Plaintext exporters are excluded from it. The subchart renders a
single ServiceMonitor covering everything it labels, with one Each of the four therefore carries
|
| loki | h5 | {"enabled":true, "image":{"registry":"docker.io", "repository":"grafana/loki-canary", "tag":"3.7.9"}, "kind":"Deployment", "lokiurl":"loki-query-frontend:3100", "priorityClassName":"monitoring-scalable", "push":false, "service":{"labels":{"monitoring.materialize.cloud/scrape-scheme":"plaintext", "prometheus.io/service-monitor":"false"}}} | End-to-end write→read canary for meta-monitoring. On by default upstream; surfaced here because self-monitoring the log store is a first-class requirement for us. |
| loki | object |
{
"registry": "docker.io",
"repository": "grafana/loki-canary",
"tag": "3.7.9"
} | Canary image, pinned beside `loki.loki.image` and grouped with it in Renovate. Loki publishes the two from one release, so the tags match. |
| loki | object |
{
"monitoring.materialize.cloud/scrape-scheme": "plaintext",
"prometheus.io/service-monitor": "false"
} | Keep the canary out of the subchart's ServiceMonitor and into this chart's plaintext one. Its `/metrics` server is plaintext even when `-tls` is set, since that flag configures the client it uses to reach Loki. See `monitoring.serviceMonitor` above. |
| loki | string | "monitoring-scalable" | Scheduling priority. See the Priority classes section. Set explicitly because the canary reaches neither of the two keys that cover the rest of Loki: it renders from its own template rather than `_pod.tpl`, so `loki.global.priorityClassName` does not reach it, and `loki.defaults` does not either. Left unset it runs at priority 0 — *below* ordinary workloads — so the first node under pressure evicts the end-to-end write→read check, which is exactly the signal you want during the incident that caused the pressure. |
Thanos#
Bundled Thanos backend for long-term metrics.
Upstream reference:
- https://github.com/thanos-community/helm-charts/tree/master/charts/thanos
- https://github.com/thanos-community/helm-charts/blob/master/charts/thanos/values.yaml
| Key | Type | Default | Description |
|---|---|---|---|
| thanos | string | "thanos" | Standard Helm full-name override. We use a static name for deterministic relations. |
| thanos | string | nil | Namespace override. |
| thanos | string | "monitoring-scalable" | Scheduling priority for every Thanos pod. See the Priority classes section. |
| thanos | object |
{
"registry": "quay.io",
"repository": "thanos/thanos",
"tag": "v0.42.5"
} | Thanos image for every component, pinned here rather than inherited from the subchart's `appVersion`. So Renovate bumps Thanos on its own cadence, with Thanos’s own release
notes, instead of only when a chart release happens to carry a new
|
| thanos | object |
{
"enabled": true,
"maxUnavailable": 1
} | PodDisruptionBudgets for every Thanos component. Upstream ships these off. One switch turns them on for all components,
and the per-component
This matches the Loki convention (ingester |
| thanos | bool | true | NetworkPolicies for every enabled Thanos component. One switch, no per-component knobs and no selectors — the subchart renders a fixed policy per component and exposes nothing to tune. What each one says is: ingress on that component’s own service ports, from anywhere; egress unrestricted. So this is the weakest policy in the stack, and worth being clear about what it does and does not buy. It does not restrict who may reach Query, Receive or Store Gateway — any pod in the cluster still can. It does close every port that is not a declared service port, which is the surface an unrelated workload would otherwise find on these pods. It is on because it costs nothing to be right about. The rules cannot break a
working install: Receive’s |
| thanos | object |
{
"runAsNonRoot": true,
"seccompProfile": {
"type": "RuntimeDefault"
}
} | Pod security context for Query, Query Frontend and Bucketweb. The subchart ships this empty. Its container context is already read-only,
non-root and drops every capability; this adds It reaches only the components whose own |
| thanos | object |
{
"autoscaling": {
"enabled": true,
"maxReplicas": 5,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 80
},
"enabled": true,
"resources": {
"requests": {
"cpu": "500m",
"memory": "1Gi"
}
},
"topologySpreadConstraints": [
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
]
} | Thanos Query configuration. Query provides a PromQL query endpoint. |
| thanos | object |
{
"enabled": true,
"maxReplicas": 5,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 80
} | Horizontal autoscaling for Query. Query is a stateless PromQL fan-out, so it is the natural place to autoscale: no local state, no ring membership, no PVC. |
| thanos | list |
[
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
] | Topology spread for Query: soft on both axes.
Stateless and autoscaled, so an unbalanced placement costs query capacity
rather than correctness — soft is sufficient, and it keeps an HPA scale-up
from stalling on a hard rule when the newest zone has no room yet.
|
| thanos | object |
{
"requests": {
"cpu": "500m",
"memory": "1Gi"
}
} | Resource requests for Thanos Query. Medium sizing; see the `thanos-small` / `thanos-large` profiles for the other two columns. A CPU request is load-bearing here beyond scheduling: `targetCPUUtilization` is a percentage *of the request*, so without one the HPA above has no denominator and never scales. No memory limit — a fan-out query's peak scales with the series it touches, and an OOM-kill mid-query is a worse failure than a slow one. |
| thanos | bool | true | Enable Thanos receiver. Receive provides a Prometheus remote_write-compatible endpoint. |
| thanos | string | "standalone" | Whether to split receive distributors from ingesters. mode=split is not very stable in the helm chart |
| thanos | int | 3 | Number of receive replicas. |
| thanos | object |
{
"retention": "6h"
} | How long Receive keeps blocks on local disk before they age out.
**6h, overriding the subchart's 24h**, because local retention is what sets
Receive's disk requirement — and on an `emptyDir` that disk is the node's, a
far scarcer and much smaller pool than a provisioned volume was. This is a recent-query cache, not a durability window: blocks ship to object storage every 2h and the Store Gateway serves everything older, so all that shortens here is how far back a query is answered from local TSDB rather than from the bucket. 6h covers the common dashboard ranges and leaves 4h of margin after a block closes, uploads, and the Store Gateway syncs it. Raising it back is a real cost rather than just a number. At the medium envelope (1.5M series) 24h is ~7.3Gi per pod, and a GKE node with a 47Gi boot disk offers only ~18.8Gi of allocatable ephemeral storage — one pod would claim 40% of the node’s budget, and Loki’s ingesters draw on the same pool. |
| thanos | list | [ "--receive.replication-factor=3" ] | Extra CLI arguments for Receive. **This is a list, and Helm overwrites lists rather than merging them.** Anything that sets `receive.extraArgs` replaces this entry wholesale, so the replication factor must be restated or it silently falls back to Thanos's default of 1 — write quorum 1, no losses tolerated. The shipped profiles restate it; so must yours. See Production Best Practices > Metrics (Thanos) for the quorum table. |
| thanos | object |
{
"enabled": false
} | VerticalPodAutoscaler for Receive.
Off, overriding the subchart's `true`. Three reasons, in order:
If you want its recommendations without its actions, set
|
| thanos | object |
{
"limits": {
"ephemeral-storage": "6Gi"
},
"requests": {
"cpu": "500m",
"ephemeral-storage": "4Gi",
"memory": "4Gi"
}
} | Resource requests for Receive. Medium sizing.
Memory is the cell to get right: budget ~3 KB per active series held, and
note that at `replicaCount == replicationFactor` **every pod holds every
series** — sharding begins only above RF. No memory limit, deliberately, and for a sharper reason than elsewhere in this chart: an OOM-kill drops the un-uploaded block window, and unlike Loki’s ingesters that window has nothing protecting it but the replication factor. Alert on usage instead.
Size these against a node’s allocatable ephemeral storage, not its disk
size. The two are nowhere near each other: GKE reserves most of the boot
disk for the image filesystem, so a 47Gi disk offers roughly 18.8Gi
allocatable. A request above that cannot schedule anywhere, and the
cluster-autoscaler will not rescue it — no node of that shape would fit
either, so the pod sits Pending indefinitely on kubectl get nodes -o custom-columns=‘NODE:.metadata.name,EPH:.status.allocatable.ephemeral-storage’ 4Gi holds 6h of the medium envelope (~1.8Gi) with room for the WAL and
compaction churn, and leaves the rest of the node for everything sharing that
pool — including Loki’s ingesters, which are also |
| thanos | list |
[
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "receive",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"minDomains": 2,
"nodeTaintsPolicy": "Honor",
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "DoNotSchedule"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "receive",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
] | Topology spread for Receive: hard across zones, soft across hosts.
This is the constraint that makes `--receive.replication-factor=3` mean
what the quorum table says. Without it the scheduler is free to place all
three replicas in one zone, and RF 3 buys nothing against the failure it
exists for — losing a zone takes every copy and, since write quorum is 2,
takes writes with it. Hard ( This became viable when Receive stopped using a PVC (see
The selector names both The release name is deliberately absent: the subchart renders this through
Note this cannot move to |
| thanos | object |
{
"enabled": false
} | Local TSDB storage for Receive: **ephemeral, not a PVC.**
This is the same call the chart makes for Loki's ingesters, for the same
reason, and it is worth spelling out because Receive looks stateful. Durability comes from A PVC makes an AZ failure worse, not merely less flexible: an EBS volume cannot be attached from another zone, so a pod whose zone is gone stays Pending until the zone returns rather than rescheduling into a healthy one. On the write path that turns a recoverable event into an outage that waits on the cloud provider — with RF 3 write quorum is 2, so two pods stuck Pending block writes outright. The trade accepted in exchange: Thanos Receive has no peer hand-off, so unlike Loki it will not backfill an emptied pod from its neighbours. That window stays at two copies instead of three until the next block ships. |
| thanos | object |
{
"enabled": true,
"extraArgs": [
"--store.limits.request-series=5000000",
"--store.limits.request-samples=200000000"
],
"persistence": {
"enabled": true,
"size": "10Gi"
},
"podSecurityContext": {
"runAsNonRoot": true,
"seccompProfile": {
"type": "RuntimeDefault"
}
},
"resources": {
"requests": {
"cpu": "500m",
"memory": "3Gi"
}
},
"topologySpreadConstraints": [
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "storegateway",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "storegateway",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
]
} | Thanos Store Gateway configuration. Store Gateway provides historical block querying. Autoscaling is available upstream but left off deliberately. Store Gateway is a PVC-backed StatefulSet that syncs the bucket’s block index on startup, so scale-up is slow to become useful (it serves nothing until the index is warm) and CPU-triggered scaling reacts long after the load that triggered it. Scale-down also leaves orphaned PVCs behind, since StatefulSet volumes are not reclaimed. Size it deliberately instead. |
| thanos | object |
{
"enabled": true,
"size": "10Gi"
} | Index-header cache for Store Gateway.
One of two Thanos components that keep a PVC — the Compactor is the other,
and Receive alone is `emptyDir`-backed. The reason here is startup cost
rather than durability or size: the on-disk binary index-headers are a
read-through cache of the bucket and lose nothing when discarded, but
rebuilding them means re-downloading from object storage, and the component
serves no historical query until that finishes. On a large bucket that is
minutes of degraded long-range dashboards on every restart. Kept on at every size deliberately, rather than flipping to ephemeral
for small installs. A volume that appears and disappears with the profile
is a surprise during an incident, and the AZ-pinning argument that drives
Receive to The |
| thanos | object |
{
"requests": {
"cpu": "500m",
"memory": "3Gi"
}
} | Resource requests for Store Gateway. Medium sizing. There is a floor here that is easy to trip over. Thanos defaults `--chunk-pool-size=2GB` and `--index-cache-size=250MB`, and the subchart passes neither, so a stock Store Gateway wants ~2.5Gi before it serves a single query. A request below that is an OOM, not a small install — the `thanos-small` profile shrinks the pools through `extraArgs` instead. |
| thanos | list |
[
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "storegateway",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "storegateway",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
] | Topology spread for Store Gateway: soft on both axes.
**Soft** (`ScheduleAnyway`) where Receive is hard, and the difference is
the point rather than an inconsistency. Two things make it the right trade
here:
|
| thanos | list | [ "--store.limits.request-series=5000000", "--store.limits.request-samples=200000000" ] | Extra CLI arguments for Store Gateway.
Read-path protection. Thanos has no per-tenant write limit to set; the
realistic failure is one query fanning out across the bucket and taking
this component down, which takes historical reads with it. These two caps
make that query fail instead. The subchart ships this empty, so unlike |
| thanos | object | enabled with default retention policies | Thanos Compactor configuration. Compactor provides block compaction and downsampling. |
| thanos | object |
{
"resolution1h": "365d",
"resolution5m": "90d",
"resolutionRaw": "30d"
} | Retention policies for Thanos Compactor downsampled data
The medium row of the retention table in Production Best Practices. Raw retention has a floor set by the downsampling thresholds: Thanos produces 5m downsamples only from blocks spanning 40h or more, and 1h downsamples only from blocks spanning 10d or more. Below those the tier is never created at all and long-range queries silently fall back to reading raw blocks — slower and more expensive, which is the opposite of the intent. 30d clears both comfortably. |
| thanos | list | [ "--log.level=info", "--log.format=logfmt", "--consistency-delay=30m", "--compact.enable-vertical-compaction", "--deduplication.replica-label=receive_replica" ] | Extra CLI arguments for the Compactor.
**This is a list, and Helm overwrites lists rather than merging them.**
The first three entries restate the subchart's defaults, which are also
Thanos's own, so an override that drops them changes nothing. An override
that drops either flag below brings back the failure it prevents.
Both flags are irreversible for the blocks they touch. The replica label
alone also turns on vertical compaction (Thanos |
| thanos | object |
{
"enabled": false
} | VerticalPodAutoscaler for the Compactor. Off for the same reasons as `receive.vpa`, minus the block-loss argument — the Compactor's local volume is an idempotent working copy, so an eviction costs a restarted compaction rather than data. It still overwrites the requests below, and still behaves differently depending on whether the VPA CRD happens to be installed. |
| thanos | object |
{
"requests": {
"cpu": 1,
"memory": "2Gi"
}
} | Resource requests for the Compactor. Medium sizing.
The Compactor is a **singleton by necessity** — concurrent compactors
against one block set corrupt data — so it is the one component here with
no horizontal escape hatch. Vertical is the only lever, which makes
compaction falling behind something to alert on rather than discover. Downsampling is the memory-heavy phase, and No |
| thanos | object |
{
"enabled": true,
"size": "50Gi"
} | Working directory for the Compactor: **a PersistentVolume**, unlike Receive.
Scratch space for the block group under compaction. Nothing here is
authoritative — the bucket is — and a Compactor killed mid-compaction simply
redoes the work, so there is no *durability* argument for a volume. The
argument is size. Compaction works on whole block groups, not on the recent window, so the
requirement scales with days of data rather than hours. At the medium
envelope a 2d group is roughly six 8h blocks at ~2.4Gi each, and the
Compactor needs the sources and the output at once — on the order of 30Gi.
A GKE node with a 47Gi boot disk offers about 18.8Gi of allocatable
ephemeral storage, so that never fits: the pod stays Pending on
This is the reverse of the call made for Receive, and deliberately so.
Receive holds hours of small, replicated data and is the better trade on
|
| thanos | object |
{
"autoscaling": {
"enabled": true,
"maxReplicas": 5,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 80
},
"enabled": false,
"resources": {
"requests": {
"cpu": "250m",
"memory": "512Mi"
}
},
"topologySpreadConstraints": [
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query-frontend",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query-frontend",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
]
} | Thanos Query Frontend configuration. Query Frontend provides query parallelization and result caching. Only required for production. Note that enabling this is not sufficient on its own: point
|
| thanos | object |
{
"enabled": true,
"maxReplicas": 5,
"minReplicas": 2,
"targetCPUUtilizationPercentage": 80
} | Horizontal autoscaling for Query Frontend. Stateless like Query, so the same reasoning applies. Inert until `queryFrontend.enabled` is true. |
| thanos | list |
[
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query-frontend",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "query-frontend",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"pod-template-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
] | Topology spread for Query Frontend: soft on both axes, same reasoning as Query. Inert until `enabled` is true. |
| thanos | object |
{
"requests": {
"cpu": "250m",
"memory": "512Mi"
}
} | Resource requests for Query Frontend. Inert until it is enabled, which `thanos-large` does. Sized for splitting and result caching rather than query execution — the work happens in Query behind it. |
| thanos | object |
{
"alertmanagers": {
"config": "alertmanagers:\n - static_configs:\n - dns+alertmanager-headless.{{ .Release.Namespace }}.svc.{{ include \"mzmon.clusterDomain\" . }}:9093\n scheme: http\n api_version: v2\n timeout: 10s\n"
},
"autoImportPrometheusRules": {
"enabled": true,
"labelSelector": {
"mzmon.materialize.cloud/flavor!": "logql"
},
"sidecar": {
"image": {
"registry": "docker.io",
"repository": "alpine/kubectl",
"tag": "1.36.4"
},
"resources": {
"requests": {
"cpu": "25m",
"memory": "64Mi"
}
}
}
},
"enabled": true,
"extraArgs": [
"--label=cluster=\"$(CLUSTER_NAME)\""
],
"extraEnv": [
{
"name": "CLUSTER_NAME",
"valueFrom": {
"configMapKeyRef": {
"key": "CLUSTER_NAME",
"name": "ruler-env"
}
}
}
],
"extraVolumeMounts": [
{
"mountPath": "/etc/mzmon/tls",
"name": "mzmon-tls",
"readOnly": true
}
],
"extraVolumes": [
{
"name": "mzmon-tls",
"secret": {
"optional": true,
"secretName": "mzmon-thanos-tls"
}
}
],
"persistence": {
"enabled": false
},
"podSecurityContext": {
"runAsNonRoot": true,
"seccompProfile": {
"type": "RuntimeDefault"
}
},
"query": {
"urls": [
"http://thanos-query:9090"
]
},
"remoteWrite": {
"enabled": true,
"secretName": "thanos-ruler-remote-write-v2"
},
"replicaCount": 2,
"resources": {
"requests": {
"cpu": "100m",
"memory": "256Mi"
}
},
"rules": {
"example-alerts.yaml": "groups: []\n"
},
"topologySpreadConstraints": [
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "ruler",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "ruler",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
]
} | Thanos Ruler configuration. Ruler provides alerting and recording rules evaluation. This is the switch that makes PromQL alerting exist. The chart installs
the Prometheus Operator CRDs but runs no Prometheus and no operator, and
Alloy — the one component that consumes Evaluation is a PromQL query over the network to Thanos Query. There is no local TSDB to fall back on, which means an outage in the query path stops alert evaluation, and stopped evaluation looks exactly like nothing being wrong. Size Query with that in mind. |
| thanos | int | 2 | Replica count. Both replicas evaluate every rule; Alertmanager deduplicates the resulting notifications, and `--alert.label-drop` strips the `ruler_replica` label that would otherwise make them distinct alerts. |
| thanos | object |
{
"urls": [
"http://thanos-query:9090"
]
} | Query endpoints the Ruler evaluates against. Query, not Query Frontend, deliberately. The frontend splits and caches, which is right for a dashboard and wrong for an evaluator: a cached range served to a rule is an alert firing — or failing to fire — on data up to a cache TTL stale, with nothing in either component saying so. Grafana’s datasource follows the frontend when it is enabled; this does not. The name is bare rather than fully qualified because Ruler and Query are
components of one subchart and always land in one namespace, so it stays
correct under |
| thanos | object |
{
"config": "alertmanagers:\n - static_configs:\n - dns+alertmanager-headless.{{ .Release.Namespace }}.svc.{{ include \"mzmon.clusterDomain\" . }}:9093\n scheme: http\n api_version: v2\n timeout: 10s\n"
} | Alertmanager routing, in Thanos's own format.
|
| thanos | object |
{
"example-alerts.yaml": "groups: []\n"
} | Inline rule files, keyed by filename. This empties the subchart’s It is emptied rather than removed because the subchart’s JSON Schema
lists The render fails if the example rule comes back. The chart’s own rules do not arrive here. They arrive as |
| thanos | object |
{
"enabled": true,
"labelSelector": {
"mzmon.materialize.cloud/flavor!": "logql"
},
"sidecar": {
"image": {
"registry": "docker.io",
"repository": "alpine/kubectl",
"tag": "1.36.4"
},
"resources": {
"requests": {
"cpu": "25m",
"memory": "64Mi"
}
}
}
} | Import `PrometheusRule` resources from the cluster into the Ruler. A
The exclusion is load-bearing. The chart’s log-derived rules are
The image is pinned rather than left on the upstream It cannot be the distroless The minor tracks the cleanup hook’s kubectl, and the same version-skew advice applies: keep it within one minor of your API server. |
| thanos | object |
{
"enabled": true,
"secretName": "thanos-ruler-remote-write-v2"
} | Run stateless: remote-write rule results, keep no TSDB. Three reasons stateless is the right mode here, in ascending order of how
much they matter. It removes a stateful workload. It puts rule results on
the same path as every other series in this stack, rather than making the
Ruler a second writer into object storage with its own compaction
interaction. And it is what makes alert state forwardable: a Ruler shipping
blocks puts With
Writing elsewhere means naming another Secret here, or setting
|
| thanos | list | [ "--label=cluster=\"$(CLUSTER_NAME)\"" ] | Extra Ruler flags.
|
| thanos | list |
[
{
"name": "CLUSTER_NAME",
"valueFrom": {
"configMapKeyRef": {
"key": "CLUSTER_NAME",
"name": "ruler-env"
}
}
}
] | The cluster name, for `--label=cluster` in `extraArgs`. |
| thanos | list |
[
{
"name": "mzmon-tls",
"secret": {
"optional": true,
"secretName": "mzmon-thanos-tls"
}
}
] | The Thanos certificate, for the Ruler's TLS clients.
|
| thanos | object |
{
"enabled": false
} | No PVC. The data directory holds the remote-write WAL and nothing else. The Loki ruler keeps a volume for the same WAL, on the argument that
buffering derived samples through a metric-store outage is worth a disk.
That argument applies here too and is deliberately not taken: the
recording rules this Ruler evaluates are the |
| thanos | object |
{
"requests": {
"cpu": "100m",
"memory": "256Mi"
}
} | Resource requests for the Ruler. Evaluation is a fan-out of PromQL to Query, so the work happens there; the Ruler holds rule state and a WAL. |
| thanos | list |
[
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "ruler",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "ScheduleAnyway"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/component": "ruler",
"app.kubernetes.io/name": "thanos"
}
},
"matchLabelKeys": [
"controller-revision-hash"
],
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
] | Topology spread for the Ruler: soft on both axes. Soft rather than
|
Grafana Operator#
Bundled Grafana-operator for managing Grafana instances.
Upstream references:
- https://grafana.github.io/grafana-operator/docs/installation/helm/#values
- https://github.com/grafana/grafana-operator/blob/master/deploy/helm/grafana-operator/values.yaml
| Key | Type | Default | Description |
|---|---|---|---|
| grafana-operator | string | "monitoring-scalable" | Scheduling priority. See the Priority classes section. Scalable tier: while the operator is down, dashboard and datasource reconciliation stops, but Grafana keeps serving what it already has. |
| grafana-operator | string | "grafana-operator" | Standard Helm full-name override. We use a static name for deterministic relations. |
| grafana-operator | string | nil | Namespace override. |
| grafana-operator | object |
{
"registry": "ghcr.io",
"repository": "grafana/grafana-operator",
"tag": "v5.25.0"
} | Operator image, pinned here rather than inherited from the subchart's `appVersion`. Unlike the other pins, this one is not meant to move on its own. The
operator’s CRDs are vendored from the subchart ( |
| grafana-operator | object |
{
"immutable": true
} | CRD behavior. The Grafana Operator CRDs are owned by the `materialize-monitoring-crds` chart, which vendors a deflated copy of them. The operator chart offers no way to skip its own CRDs outright — `immutable` only chooses where they come from — so keep this `true`: that keeps them out of this chart's release manifest and leaves them install-only, which `helm install --skip-crds` drops entirely. Setting it `false` makes this chart template and upgrade the CRDs itself, fighting the CRDs chart for ownership. |
| grafana-operator | object |
{
"runAsNonRoot": true,
"seccompProfile": {
"type": "RuntimeDefault"
}
} | Pod security context. The subchart ships an empty one. Its container context is already read-only, non-root and drops every capability; this adds `RuntimeDefault` seccomp, and keeps the whole pod non-root. No UID is set, so the image's own non-root user stands, which is what a registry profile swap needs. |
| grafana-operator | object |
{
"limits": {
"memory": "512Mi"
},
"requests": {
"cpu": "10m",
"memory": "64Mi"
}
} | Resource requests and limits for the operator. The subchart sets none, which makes the operator BestEffort. Under node pressure the kubelet evicts it ahead of every pod still within its requests, whatever `priorityClassName` says. The operator idles at a few millicores and a few tens of MiB. The memory limit leaves room for many more dashboards than this chart ships; raise it if a cluster declares far more of its own. |
Grafana Instance#
Bundled Grafana for dashboard rendering.
Upstream reference:
The defaults here are the safe shape, not the production shape, and the two differ in three places you have to close yourself:
- State. Grafana stores users, service accounts and tokens, annotations,
dashboard versions and permissions, preferences, and alert-rule state in a
database of its own. The default is SQLite on an
emptyDir, so all of it is lost on every restart, upgrade, and reschedule. The dashboards this chart installs come back on their own (grafana-operator re-pushes them everyresyncPeriod); anything a human created does not. Apply thegrafana-postgresprofile, orgrafana-pvcif you have no database. - Reachability. The Service is
ClusterIP, so the only access path iskubectl port-forward. Setingressorservice.type— and readconnections.grafana.allowPublicAccessbefore you do. - Authentication. The only account is the generated admin. Configure an
identity provider under
grafana.inibefore exposing it to anyone but yourself.
Everything under grafana.ini is passed through to Grafana’s own config file
verbatim, so any section Grafana understands can be set here — see the
[auth.generic_oauth] example on that key.
See Production Best Practices > Grafana for the full checklist.
| Key | Type | Default | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| grafana | string | "grafana" | Standard Helm full-name override. We use a static name for deterministic relations. `connections.grafana.mode: bundled` derives the URL it hands grafana-operator from this, so a release-name-derived name would leave the operator dialing a host that does not resolve. | |||||||||
| grafana | string | nil | Namespace override. | |||||||||
| grafana | string | "monitoring-scalable" | Scheduling priority. See the Priority classes section. | |||||||||
| grafana | object |
{
"pullPolicy": "IfNotPresent",
"registry": "docker.io",
"repository": "grafana/grafana",
"tag": "13.3.0"
} | Grafana server image.
Pinned here rather than left to track the subchart's `appVersion`, so
Renovate can bump the server on its own cadence instead of only when a new
chart release happens to carry one. A hardened rebuild is a drop-in swap: point
Both ship no shell and no package manager, which is the point — and which
also means Bitnami also publishes a hardened Grafana, but its images are built for
Bitnami’s own charts — Bitnami entrypoints, | |||||||||
| grafana | int | 1 | Number of Grafana replicas. More than one requires a shared database (`grafana.ini.database`) — each replica otherwise carries its own divergent SQLite file, which is a correctness bug rather than availability. A render-time check enforces this. | |||||||||
| grafana | object |
{
"enabled": false,
"maxReplicas": 5,
"minReplicas": 1,
"targetCPU": "60",
"targetMemory": ""
} | Horizontal autoscaling for the Grafana Deployment.
Off by default because it is meaningless on the default SQLite backend:
every replica would carry its own database. With `grafana.ini.database`
pointed at PostgreSQL, Grafana is stateless and scales horizontally, so
enable it then — `minReplicas` becomes the effective replica count and
`replicas` stops being rendered at all.
| |||||||||
| grafana | object |
{
"maxUnavailable": 1
} | PodDisruptionBudget for Grafana. `maxUnavailable` rather than `minAvailable`, matching the convention the Loki and Thanos blocks use: it scales with the replica count, and on a single replica `minAvailable: 1` would permit no voluntary eviction at all and hang node drains. | |||||||||
| grafana | object |
{
"limits": {
"memory": "1Gi"
},
"requests": {
"cpu": "100m",
"memory": "256Mi"
}
} | Resource requests and limits for the Grafana container. Requests are what the scheduler and the HPA both read — without a CPU request, `autoscaling.targetCPU` has no denominator and the HPA never scales. No CPU limit: query rendering is bursty and throttling it makes the UI feel broken. Raise the memory limit if you run many large dashboards or heavy plugins. | |||||||||
| grafana | object |
{
"readOnlyRootFilesystem": true
} | Container security context for Grafana.
The subchart's own already drops every capability, forbids privilege
escalation, sets `RuntimeDefault` seccomp and makes the root filesystem
read-only. The chart restates the read-only root because the validators
and the production checklist depend on it. A read-only root filesystem needs two things, and Grafana breaks quietly without either:
Everything else Grafana writes lands on the A second mount at Validators fail the render on the plugin setting, a missing | |||||||||
| grafana | object |
{
"accessModes": [
"ReadWriteOnce"
],
"enabled": false,
"size": "10Gi"
} | Persistent volume for Grafana's SQLite database and plugin directory.
Off by default. Turning it on survives restarts but caps you at one replica
(SQLite tolerates one writer) and, on a `ReadWriteOnce` volume, requires
`deploymentStrategy.type: Recreate` — a rolling update otherwise deadlocks
with the new pod waiting for a volume the old pod has not released. Both are
enforced at render time. The `grafana-pvc` profile is the assembled version. Prefer PostgreSQL ( | |||||||||
| grafana | object |
{
"registry": "docker.io",
"repository": "library/busybox",
"tag": "1.38.0"
} | Image for the chown init container, pinned here rather than left at the subchart's default. It renders only alongside `persistence.enabled` (`profiles/grafana-pvc`). Pinned so Renovate sees it: Renovate reads this file and never the vendored subchart's `values.yaml`. | |||||||||
| grafana | object |
{
"type": "RollingUpdate"
} | Deployment update strategy. Must be `Recreate` alongside a `ReadWriteOnce` PersistentVolume; see `persistence`. | |||||||||
| grafana | object |
{
"annotations": {},
"enabled": true,
"loadBalancerSourceRanges": [],
"port": 80,
"targetPort": 3000,
"type": "ClusterIP"
} | Service exposing Grafana.
`ClusterIP` is reachable only from inside the cluster, which is the safe
default and the reason a fresh install needs `kubectl port-forward`. Prefer
`ingress` over a `LoadBalancer` Service: Grafana is ordinary HTTP that wants
host-based routing and a certificate, which is what Ingress is for. A | |||||||||
| grafana | object |
{
"annotations": {},
"enabled": false,
"hosts": null,
"ingressClassName": "",
"labels": {},
"path": "/",
"pathType": "Prefix",
"tls": null
} | Ingress for Grafana.
The preferred way to make Grafana reachable. Terminate TLS here: Grafana
authenticates with a session cookie and, without TLS, that cookie and the
admin password cross the network in the clear. A render-time check warns
when an Ingress carries no `tls` block. Set | |||||||||
| grafana | object |
{
"analytics": {
"check_for_updates": false,
"reporting_enabled": false
},
"date_formats": {
"default_timezone": "UTC"
},
"plugins": {
"preinstall_auto_update": false
}
} | Grafana's own configuration file, as YAML.
Rendered verbatim into `grafana.ini`, so any section Grafana understands can
be set here: authentication, SMTP, feature toggles, unified alerting, user
provisioning. The chart sets only the handful below and merges anything you
add over them. Secrets must NOT be inlined — this block renders into a ConfigMap, so a
value here lands in plaintext in the release manifest, in OIDC single sign-on, as an example — this is the shape, not a default: Every provider, the role-mapping rules, and the break-glass path are in Dashboards > Grafana > Authentication. | |||||||||
| grafana | object |
{
"allowExternal": true,
"egress": {
"enabled": false
},
"enabled": true,
"explicitIpBlocks": [],
"explicitNamespacesSelector": {},
"ingress": true
} | NetworkPolicy for Grafana. The subchart’s policy is rigid: it emits exactly one ingress rule, on
Ingress: What it closes is the rest of the pod: You do not have to turn this off to get HA alerting. The chart renders the
missing rule itself, as Egress is left unrestricted ( | |||||||||
| grafana | bool | true | Emit the ingress rule. False renders a policy with no rules at all, which — with egress off too — is an empty spec, not a deny. | |||||||||
| grafana | bool | true | Accept from any source. See the note above before turning this off. | |||||||||
| grafana | object |
{} | Namespaces allowed in when `allowExternal` is false. The ingress controller's namespace goes here. | |||||||||
| grafana | list | [] | CIDRs allowed in when `allowExternal` is false. Load-balancer client and health-check ranges go here. | |||||||||
| grafana | bool | false | Leave off. See the note above: a partial egress allowlist for Grafana fails as empty panels rather than as an error. | |||||||||
| grafana | list | [] | Grafana plugins to install at container start.
Each entry is downloaded from grafana.com when the pod starts, so this needs
egress to `grafana.com` on 443 and adds that download to every start and
every restart — a startup dependency on a third-party service. For anything load-bearing, bake plugins into the image instead and point
| |||||||||
| grafana | object |
{
"enabled": false
} | Grafana Image Renderer, for server-side PNG rendering of panels. Deliberately off, and it should stay off in production: the renderer is a headless Chromium that fetches arbitrary URLs on Grafana's behalf, which makes it both a large attack surface and a server-side request forgery pivot into the cluster network. A render-time check warns when it is enabled. | |||||||||
| grafana | object |
{
"enabled": false
} | Helm test hooks shipped by the subchart. Off: it pulls a `bats` image this chart does not otherwise use or pin, and the e2e suite covers what it asserts. | |||||||||
| grafana | bool | true | Fail the render when a secret is inlined into `grafana.ini`. Leave this on. It is the guard that keeps database and OAuth credentials out of the ConfigMap; see the note on `grafana.ini`. | |||||||||
| grafana | object |
{
"enabled": true
} | Scrape Grafana's own metrics. |
Alertmanager#
Bundled Alertmanager: the one notification surface for both rulers.
This block shapes the workload — replicas, gossip, storage, scheduling and
hardening. Where alerts go is configured under alerting, which the chart
renders into this Alertmanager’s configuration. See
Alert Architecture
for how the two fit together.
Upstream reference:
| Key | Type | Default | Description | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| alertmanager | string | "alertmanager" | Resource names, pinned rather than derived from the release name. Matches Loki, Thanos, Grafana and Alloy, whose names are pinned the same
way. Everything that addresses Alertmanager — both rulers, the Grafana
datasource, the certificate SANs — can then name it literally, and the
subchart’s rule of collapsing | ||||||||||
| alertmanager | object |
{
"repository": "quay.io/prometheus/alertmanager",
"tag": "v0.34.2"
} | Alertmanager image, pinned here rather than inherited from the subchart's `appVersion`. So Renovate bumps Alertmanager on its own cadence, with Alertmanager’s own
release notes, instead of only when a chart release happens to carry a new
| ||||||||||
| alertmanager | string | "monitoring-scalable" | Scheduling priority. See the Priority classes section. | ||||||||||
| alertmanager | int | 2 | Replica count. Two replicas gossiping is the default, not a hardening step. A single notifier is lost to an ordinary node drain, holds the only copy of every silence, and cannot report its own absence. Two replicas gossip silences and the notification log to each other and deduplicate, so each notification goes out once and either replica can send it. Two rather than three. Gossip tolerates a partition by notifying from both sides rather than by electing a leader, so a third replica adds little availability, and it adds a third copy of every notification whenever gossip is broken. Above one, the subchart passes | ||||||||||
| alertmanager | object |
{
"cluster.label": "materialize-monitoring",
"config.file": "/etc/alertmanager/config/alertmanager.yml",
"data.retention": "120h",
"web.route-prefix": "/"
} | Flags passed to Alertmanager as `--
Changing | ||||||||||
| alertmanager | object |
{
"enabled": false
} | Use the configuration the chart renders, not the subchart's. The subchart renders | ||||||||||
| alertmanager | list |
[
{
"name": "mzmon-config",
"secret": {
"secretName": "alertmanager-config"
}
},
{
"name": "mzmon-amtool",
"secret": {
"items": [
{
"key": "amtool.yml",
"path": "config.yml"
}
],
"secretName": "alertmanager-config"
}
}
] | Mount the configuration the chart renders. Load-bearing, with `extraArgs.config.file`. The second volume puts an | ||||||||||
| alertmanager | list |
[
{
"mountPath": "/etc/alertmanager/secrets/alertmanager-receivers",
"name": "alertmanager-receivers",
"optional": true,
"readOnly": true,
"secretName": "alertmanager-receivers"
},
{
"mountPath": "/etc/mzmon/tls",
"name": "mzmon-tls",
"optional": true,
"readOnly": true,
"secretName": "mzmon-alertmanager-tls"
}
] | Secrets holding receiver credentials, for `*_file` fields to read. Receiver credentials are referenced by path, never inlined; see
It is To mount further Secrets, add entries under The second entry is Alertmanager’s own certificate, which the chart issues as
| ||||||||||
| alertmanager | object |
{
"enabled": true,
"extraArgs": {
"watched-dir": "/etc/alertmanager/config"
},
"extraVolumeMounts": [
{
"mountPath": "/etc/alertmanager/config",
"name": "mzmon-config",
"readOnly": true
}
],
"image": {
"repository": "quay.io/prometheus-operator/prometheus-config-reloader",
"tag": "v0.94.2"
},
"resources": {
"limits": {
"memory": "64Mi"
},
"requests": {
"cpu": "5m",
"memory": "16Mi"
}
},
"securityContext": {
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": [
"ALL"
]
},
"readOnlyRootFilesystem": true,
"runAsGroup": 65534,
"runAsNonRoot": true,
"runAsUser": 65534
}
} | Reload the configuration in place when `alerting` changes. The subchart rolls its pods on a checksum of the ConfigMap it renders, and it
no longer renders one, so without this sidecar a routing change would reach
the running pods only at their next restart. The sidecar watches the mounted
directory and POSTs The image is pinned here, like Alertmanager’s, so Renovate tracks it
directly. Every profile under | ||||||||||
| alertmanager | object |
{
"kubectl.kubernetes.io/default-container": "alertmanager"
} | Pod annotations. The subchart puts the reloader sidecar first, so this makes `kubectl logs` and `kubectl exec` land in Alertmanager rather than in the sidecar. | ||||||||||
| alertmanager | object |
{
"fsGroup": 65534,
"fsGroupChangePolicy": "OnRootMismatch",
"runAsGroup": 65534,
"runAsNonRoot": true,
"runAsUser": 65534,
"seccompProfile": {
"type": "RuntimeDefault"
}
} | Pod security context. `fsGroupChangePolicy: OnRootMismatch` skips the recursive `chown` of the volume on every start once its ownership is already right. | ||||||||||
| alertmanager | object |
{
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": [
"ALL"
]
},
"readOnlyRootFilesystem": true,
"runAsGroup": 65534,
"runAsNonRoot": true,
"runAsUser": 65534
} | Container security context. A read-only root filesystem is safe: Alertmanager writes only under
| ||||||||||
| alertmanager | bool | false | Mount no ServiceAccount token. Alertmanager reads nothing from the Kubernetes API. Cloud identity does not need it: the EKS webhook behind IRSA projects a token volume of its own, whatever this says. | ||||||||||
| alertmanager | object |
{} | Annotations on Alertmanager's ServiceAccount (`alertmanager`): the IRSA role for `sns_configs`. Amazon SNS is the one Alertmanager integration that authenticates with the
pod’s own cloud identity; everything else, including email through SES or
Azure Communication Services, takes a credential from a Secret. On EKS,
| ||||||||||
| alertmanager | object |
{
"httpGet": {
"path": "/-/healthy",
"port": "http"
}
} | Liveness on `/-/healthy` rather than the subchart's `/`, which serves the UI. | ||||||||||
| alertmanager | object |
{
"httpGet": {
"path": "/-/ready",
"port": "http"
}
} | Readiness on `/-/ready`. A starting replica joins the mesh and pulls its peer’s silences and notification log before it serves, so a Ready replica already holds the replicated state. A StatefulSet rollout replaces one replica at a time and waits on this, which is what keeps a rolling update from losing silences. | ||||||||||
| alertmanager | object |
{
"limits": {
"memory": "256Mi"
},
"requests": {
"cpu": "10m",
"memory": "64Mi"
}
} | Resource requests and limits. Alertmanager holds active alerts, silences and the notification log in memory, and a quiet install idles near 15Mi. Two replicas carry the alert volume a Materialize deployment produces, so there is no sizing profile. No CPU limit, following the rest of the chart. The memory limit is the one setting that can take both replicas down together during an alert storm, so it is set well above the request; raise it for very large alert or silence populations rather than lowering it. | ||||||||||
| alertmanager | object |
{
"maxUnavailable": 1
} | PodDisruptionBudget, `maxUnavailable: 1`, matching Loki, Thanos and Grafana. A drain evicts one replica at a time, and the other keeps notifying. | ||||||||||
| alertmanager | list |
[
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/name": "alertmanager"
}
},
"maxSkew": 1,
"nodeTaintsPolicy": "Honor",
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "DoNotSchedule"
},
{
"labelSelector": {
"matchLabels": {
"app.kubernetes.io/name": "alertmanager"
}
},
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway"
}
] | Topology spread: hard across zones, soft across hosts. Two replicas in one zone are one notifier as far as a zone outage is
concerned, and a zone outage is the failure a second replica exists to
survive. So the zone rule is Each replica’s volume is zonal, and that is compatible with a hard rule only
under Two deliberate differences from Receive:
The selector names | ||||||||||
| alertmanager | bool | true | Keep a volume per replica for silences and the notification log. Gossip replicates both between the replicas, so the volume is what survives losing both at once — an uninstall and reinstall, a namespace-wide restart, a cluster-wide outage. Silences are human-created and exist nowhere else. The notification log is what stops the first evaluation after such an event from re-sending every notification that already went out. This and | ||||||||||
| alertmanager | string | "4Gi" | Volume size. Sized by cloud disk minimums, not by Alertmanager, which needs kilobytes. The subchart default of 50Mi is below the 4 GiB floor on GCP Hyperdisk and Azure managed disks, so provisioning fails there. | ||||||||||
| alertmanager | object |
{
"enabled": true,
"relabelings": [
{
"action": "drop",
"regex": ".*-headless",
"sourceLabels": [
"__meta_kubernetes_service_name"
]
}
]
} | Scrape Alertmanager's own metrics through the Alloy gateway. Delivery failures ( Both Services the subchart renders carry identical labels, so the monitor selects the headless one too and would scrape every replica twice. The relabeling drops the headless Service’s targets. | ||||||||||
| alertmanager | string | "" | Alertmanager's own external URL (`--web.external-url`). Leave it empty unless Alertmanager itself is exposed. Alertmanager’s built-in notification templates link to its alert list
( Do not point it at Grafana. Grafana serves neither path, so every link
lands on Grafana’s home page. Set Unset, Alertmanager’s own links name the pod’s address, which is
unreachable from outside the cluster, and the chart’s templates leave them
out. Set this when Alertmanager is exposed, behind authentication, to the
address operators reach it at. A path is fine:
|
Kube State Metrics#
kube-state-metrics for Kubernetes resource-state metrics consumed by Materialize-adjacent dashboards.
Upstream reference:
| Key | Type | Default | Description | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| kube-state-metrics | string | "monitoring-scalable" | Scheduling priority. See the Priority classes section. | ||||||||||
| kube-state-metrics | object |
{
"registry": "registry.k8s.io",
"repository": "kube-state-metrics/kube-state-metrics",
"tag": "v2.20.0"
} | kube-state-metrics image, pinned here rather than inherited from the subchart's `appVersion`. So Renovate bumps it on its own cadence, with its own release notes. The subchart prefixes `v` to `appVersion`; a pinned tag is used verbatim. `global.imageRegistry` outranks `registry` in this chart. | ||||||||||
| kube-state-metrics | list | [ "nodes=[karpenter.sh/nodepool,eks.amazonaws.com/nodegroup,cloud.google.com/gke-nodepool,kubernetes.azure.com/agentpool,node.kubernetes.io/instance-type,topology.kubernetes.io/zone]", "pods=[cluster.environmentd.materialize.cloud/cluster-id,cluster.environmentd.materialize.cloud/replica-id,cluster.environmentd.materialize.cloud/size,materialize.cloud/environment-id]" ] | Node and pod labels copied onto `kube_node_labels` and `kube_pod_labels`. kube-state-metrics publishes each label as
The Cardinality. Each family has one series per object, whatever the list
names, because the labels are carried on that one series. A broader list
costs index size and series churn rather than series count: a label whose
value changes on a running object ends one series and starts another.
Extending the list. Helm replaces a list rather than merging it, so an
override restates both entries. Leaving out | ||||||||||
| kube-state-metrics | object |
{
"config": {
"kind": "CustomResourceStateMetrics",
"spec": {
"resources": [
{
"groupVersionKind": {
"group": "materialize.cloud",
"kind": "Materialize",
"version": "v1alpha1"
},
"labelsFromPath": {
"environment_id": [
"spec",
"environmentId"
],
"environment_name": [
"metadata",
"name"
],
"environment_namespace": [
"metadata",
"namespace"
]
},
"metricNamePrefix": "kube_mz",
"metrics": [
{
"each": {
"info": {
"labelsFromPath": {
"environment_name": [
"metadata",
"name"
]
}
},
"type": "Info"
},
"help": "1 for each Materialize environment, naming it.",
"name": "environment_info"
},
{
"each": {
"gauge": {
"labelsFromPath": {
"generation": [
"activeGeneration"
]
},
"path": [
"status"
],
"valueFrom": [
"activeGeneration"
]
},
"type": "Gauge"
},
"help": "The deployment generation the Materialize environment serves.",
"name": "environment_active_generation"
}
]
}
]
}
},
"enabled": true
} | Environment facts that kube-state-metrics reads off each `Materialize` resource. kube-state-metrics publishes one series of each per
The active generation is the one the operator last promoted.
During a rollout it is the older generation, until the new one has caught up and been promoted.
A rollout that never promotes leaves the older one serving,
so the highest generation in the environment’s pod names doesn’t stand in for it.
The environment’s namespace is Pinned to kube-state-metrics watches the cluster’s CRDs, so it publishes nothing until the Materialize CRD exists, and picks it up when it does.
| ||||||||||
| kube-state-metrics | list |
[
{
"apiGroups": [
"materialize.cloud"
],
"resources": [
"materializes"
],
"verbs": [
"list",
"watch"
]
}
] | Extra ClusterRole rules for kube-state-metrics: list and watch on `Materialize` resources, for `customResourceState`. | ||||||||||
| kube-state-metrics | object |
{
"egress": [
{
"ports": [
{
"port": 53,
"protocol": "UDP"
},
{
"port": 53,
"protocol": "TCP"
}
],
"to": [
{
"namespaceSelector": {}
}
]
},
{
"ports": [
{
"port": 443,
"protocol": "TCP"
},
{
"port": 6443,
"protocol": "TCP"
}
]
}
],
"enabled": true,
"ingress": [
{
"from": [
{
"podSelector": {
"matchLabels": {
"app.kubernetes.io/name": "alloy-gateway"
}
}
}
],
"ports": [
{
"port": 8080,
"protocol": "TCP"
},
{
"port": 8081,
"protocol": "TCP"
}
]
}
]
} | NetworkPolicy for kube-state-metrics. Ingress is narrowed to the Alloy gateway, which is what scrapes it
(
Egress is restricted, and this is the one component where that is safe to
do, because its destination set is closed: DNS, and the API server. It reads
nothing else — the whole workload is a watch on the API and an exposition
endpoint. Deny egress outright (as
| ||||||||||
| kube-state-metrics | object |
{
"honorLabels": true,
"metricRelabelings": [
{
"regex": "(.+)",
"sourceLabels": [
"label_cluster_environmentd_materialize_cloud_cluster_id"
],
"targetLabel": "cluster_id"
},
{
"regex": "(.+)",
"sourceLabels": [
"label_cluster_environmentd_materialize_cloud_replica_id"
],
"targetLabel": "replica_id"
},
{
"regex": "(.+)",
"sourceLabels": [
"label_cluster_environmentd_materialize_cloud_size"
],
"targetLabel": "replica_size"
},
{
"regex": "(.+)",
"sourceLabels": [
"label_materialize_cloud_environment_id"
],
"targetLabel": "environment_id"
},
{
"action": "labeldrop",
"regex": "label_cluster_environmentd_materialize_cloud_(cluster_id|replica_id|size)|label_materialize_cloud_environment_id"
}
]
} | Keep kube-state-metrics' own labels when they collide with the scrape's. Load-bearing, not a preference. Every The result is not missing data, which is why it went unnoticed: every
series arrives, Both endpoints, so turning Asserted by the e2e suite: | ||||||||||
| kube-state-metrics | list |
[
{
"regex": "(.+)",
"sourceLabels": [
"label_cluster_environmentd_materialize_cloud_cluster_id"
],
"targetLabel": "cluster_id"
},
{
"regex": "(.+)",
"sourceLabels": [
"label_cluster_environmentd_materialize_cloud_replica_id"
],
"targetLabel": "replica_id"
},
{
"regex": "(.+)",
"sourceLabels": [
"label_cluster_environmentd_materialize_cloud_size"
],
"targetLabel": "replica_size"
},
{
"regex": "(.+)",
"sourceLabels": [
"label_materialize_cloud_environment_id"
],
"targetLabel": "environment_id"
},
{
"action": "labeldrop",
"regex": "label_cluster_environmentd_materialize_cloud_(cluster_id|replica_id|size)|label_materialize_cloud_environment_id"
}
] | Rename the Materialize pod labels on `kube_pod_labels` to their canonical names.
Each copy matches |
Node Exporter#
node-exporter for hardware and OS metrics from every node.
Upstream reference:
- https://github.com/prometheus-community/helm-charts/blob/main/charts/prometheus-node-exporter/values.yaml
- https://github.com/prometheus/node_exporter#collectors
Run as its own DaemonSet rather than folded into the Alloy agent’s
prometheus.exporter.unix, so its resource envelope stays known and separate:
a metrics regression cannot then starve log collection out of a shared limit.
See the Terraform modules design doc for the full argument.
| Key | Type | Default | Description |
|---|---|---|---|
| node-exporter | string | "node-exporter" | Standard Helm full-name override. We use a static name for deterministic relations. |
| node-exporter | string | nil | Namespace override. |
| node-exporter | string | "monitoring-critical" | Scheduling priority. See the Priority classes section. Critical: a node with no node-exporter is a node nothing else reports on, and the gap does not backfill. |
| node-exporter | object |
{
"distroless": true,
"registry": "quay.io",
"repository": "prometheus/node-exporter",
"tag": "v1.12.1"
} | Container image. Split into registry / repository / tag so Renovate's `helm-values` manager can bump the exporter independently of the subchart version — the two move on different cadences and a chart release is not a prerequisite for a node_exporter CVE fix. `distroless: true` appends `-distroless` to the tag, giving an image with no shell and no package manager. node_exporter reads the host's `/proc`, `/sys` and `/` — a shell in that container is a materially better foothold than a shell in most others, and nothing in our configuration needs one. |
| node-exporter | object |
{
"limits": {
"memory": "64Mi"
},
"requests": {
"cpu": "10m",
"memory": "64Mi"
}
} | Resource envelope, matching what Materialize runs in production today. Deliberately no CPU limit. This is a DaemonSet that does nothing between scrapes and then bursts for the length of one collection; a CPU limit turns that burst into CFS throttling, which shows up as scrape timeouts on exactly the loaded nodes where you most need the sample. The 10m request is what it averages, not what a scrape costs. Memory is request == limit, which is the opposite call for the opposite
reason: node_exporter’s working set is flat and bounded (~20-30 MiB for this
collector set), so a limit equal to the request costs nothing and makes the
per-node footprint a number the cluster autoscaler can actually plan with.
This lands the pod in Burstable QoS. Guaranteed would require the CPU limit
we just argued against; being evicted a little earlier under node pressure
is the better trade, and |
| node-exporter | object |
{
"rollingUpdate": {
"maxUnavailable": "10%"
}
} | Rolling update budget for the DaemonSet. Upstream's `maxUnavailable: 1` walks a large fleet one node at a time, which makes an image bump take hours. Metrics are gap-tolerant and the pod restarts in seconds, so a percentage keeps rollout time flat as the fleet grows. |
| node-exporter | object |
{
"seccompProfile": {
"type": "RuntimeDefault"
}
} | Pod security context, merged over the subchart's non-root UID 65534. |
| node-exporter | object |
{
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": [
"ALL"
]
}
} | Container security context, merged over the subchart's read-only root. The exporter runs as UID 65534 and needs no capability to read the host's `/proc`, `/sys` and `/`. |
| node-exporter | object |
{
"enabled": false
} | kube-rbac-proxy sidecar, which would put the exporter behind TokenReview/SubjectAccessReview over HTTPS. Off deliberately. It is a second container on every node, and DaemonSet overhead is the constraint we are managing here — the sidecar’s own request is comparable to node_exporter’s, so it roughly doubles the per-node cost of node metrics to protect an endpoint that exposes no secrets. What it would buy, for when that trade changes: authenticated scrapes, and
(via Note that with |
| node-exporter | object |
{
"enabled": true,
"ingress": [
{
"from": [
{
"podSelector": {
"matchLabels": {
"app.kubernetes.io/name": "alloy-gateway"
}
}
}
],
"ports": [
{
"port": 9100,
"protocol": "TCP"
}
]
}
]
} | NetworkPolicy for the exporter. Ingress is narrowed to the Alloy gateway, which is what actually scrapes it
( Read the hostNetwork caveat before relying on this. The pods run with
The default |
| node-exporter | bool | true | Emit a ServiceMonitor. This is how collection actually happens: the Alloy gateway discovers ServiceMonitors cluster-wide and scrapes the endpoints behind them. Without this the DaemonSet runs and is never read. |
| node-exporter | list | [ "--collector.disable-defaults", "--collector.cpu", "--collector.cpufreq", "--collector.loadavg", "--collector.schedstat", "--collector.stat", "--collector.pressure", "--collector.meminfo", "--collector.vmstat", "--collector.swap", "--collector.vmstat.fields=^(oom_kill|pgpg|pswp|pg.*fault|pgscan|pgsteal|workingset).*$", "--collector.diskstats", "--collector.filesystem", "--collector.filesystem.mount-points-exclude=^/(dev|proc|run/credentials/.+|sys|var/lib/docker/.+|var/lib/containers/storage/.+|var/lib/kubelet/(pods|plugins)/.+|run/containerd/.+)($|/)", "--collector.nvme", "--collector.xfs", "--collector.netdev", "--collector.netdev.device-exclude=^(lo|lxc.*|veth.*|cali.*|azv.*|docker.*|br-.*|nodelocal.*|kube-ipvs.*|dummy.*)$", "--collector.netclass", "--collector.netclass.ignored-devices=^(lo|lxc.*|veth.*|cali.*|azv.*|docker.*|br-.*|nodelocal.*|kube-ipvs.*|dummy.*)$", "--collector.netstat", "--collector.sockstat", "--collector.softnet", "--collector.udp_queues", "--collector.conntrack", "--collector.arp", "--collector.os", "--collector.uname", "--collector.time", "--collector.timex", "--collector.filefd", "--collector.entropy", "--collector.hwmon", "--collector.selinux", "--collector.kernel_hung" ] | Extra arguments to node_exporter, which is where the collector allowlist lives.
Every name here must exist on Linux. An unknown Chosen for a network- and memory-hungry Rust database that swaps on purpose:
Left off on purpose, with the reason, since each is one line to re-add:
|
Metrics Server#
metrics-server for pod and node resource usage; only needed when the cluster does not already ship one.
Upstream reference:
| Key | Type | Default | Description |
|---|---|---|---|
| metrics-server | int | 1 | Number of replicas for metrics-server. |
| metrics-server | object |
{
"repository": "registry.k8s.io/metrics-server/metrics-server",
"tag": "v0.9.1"
} | metrics-server image, pinned here rather than inherited from the subchart's `appVersion`. So Renovate bumps it on its own cadence, with its own release notes. Like Alertmanager, this chart's `repository` carries the registry host, and the subchart prefixes `v` to `appVersion` where a pinned tag is used verbatim. |
| metrics-server | object |
{
"runAsNonRoot": true,
"seccompProfile": {
"type": "RuntimeDefault"
}
} | Pod security context. The subchart ships an empty one beside an already locked-down container context; this makes the pod-level half say the same thing. |
PostgreSQL Exporter#
postgres_exporter, watching the PostgreSQL databases this deployment depends on from the inside.
Upstream reference:
- https://github.com/prometheus-community/helm-charts/blob/main/charts/prometheus-postgres-exporter/values.yaml
- https://github.com/prometheus-community/postgres_exporter#multi-target-support-beta
Off by default, and in no tag group: it watches databases you name, with
credentials you create. Turn it on with tags.postgres-exporter: true (or the
postgres-exporter.enabled circuit breaker) and list each database under
targets.
One Deployment serves every target. The alloy-gateway scrapes it once per
target, on /probe, and the exporter opens a connection to that database for
the length of the scrape. So the replica count is not a multiplier on the
connections each database sees, and adding a target never restarts the metric
pipeline.
The credentials are a Secret you create, named by
config.existingSecret.name (default postgres-exporter) in the release
namespace, holding one key, postgres_exporter.yml. It carries one
auth_modules entry per target, named after the target:
auth_modules:
consensus:
type: userpass
userpass:
username: mzmon_exporter
password: <password>
options:
sslmode: require
grafana:
type: userpass
userpass:
username: mzmon_exporter
password: <password>
options:
sslmode: requirepostgres_exporter reads credentials for a multi-target scrape from this file and from nowhere else, so the whole file is the Secret rather than a password referenced from it. Create the role it names with the least-privilege grant on the PostgreSQL exporter page.
Until the Secret exists, the pod waits in ContainerCreating with an event
naming it.
| Key | Type | Default | Description | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| postgres-exporter | list | [] | The databases to watch, one scrape each.
Point A target is scraped whether or not it is a metadata database. To record it
as one, name it in | ||||||||||||||||||
| postgres-exporter | string | "postgres-exporter" | Standard Helm full-name override. We use a static name for deterministic relations. | ||||||||||||||||||
| postgres-exporter | string | "monitoring-scalable" | Scheduling priority. See the Priority classes section. Scalable: a missed scrape is a gap in a slow-moving series, and the Deployment comes back on its own. | ||||||||||||||||||
| postgres-exporter | object |
{
"registry": "quay.io",
"repository": "prometheuscommunity/postgres-exporter",
"tag": "v0.20.1"
} | Container image, pinned here rather than inherited from the subchart's `appVersion`. So Renovate bumps the exporter on its own cadence, with its own release notes. | ||||||||||||||||||
| postgres-exporter | object |
{
"limits": {
"memory": "64Mi"
},
"requests": {
"cpu": "10m",
"memory": "32Mi"
}
} | Resource envelope. The exporter is idle between scrapes and holds no state. Memory is request == limit because its working set is small and flat; there is no CPU limit, so a scrape is never throttled into a timeout. | ||||||||||||||||||
| postgres-exporter | object |
{
"enabled": true,
"name": "postgres-exporter"
} | The Secret holding `postgres_exporter.yml`, which you create. See the section introduction for its shape. | ||||||||||||||||||
| postgres-exporter | string | "warn" | Exporter log level. `warn`, because at `info` every probe logs a new connection and the server's version, two lines per target per scrape. A target that cannot connect is logged at `error`, so nothing worth reading is lost. | ||||||||||||||||||
| postgres-exporter | list | [ "--collector.database_wraparound", "--collector.stat_activity_autovacuum" ] | Collectors to turn on beyond the exporter's defaults.
The defaults already cover what the dashboards read besides these:
connections by state and the oldest open transaction, | ||||||||||||||||||
| postgres-exporter | object |
{
"enabled": true
} | NetworkPolicy for the exporter. The subchart's policy admits ingress on the exporter's port and leaves egress unrestricted, because the databases it connects to are wherever you put them. |