Expose Prometheus metrics from your Kubernetes pods to Mimir
You want to monitor your application and you don't want to run a Prometheus. Good news: on France Nuage Kubernetes, Alloy, Mimir and Grafana are already there. You add three annotations on your pod, and your metrics flow into Mimir automatically. No Helm chart to install, no scraper to configure, no tenant to create.
What you need
- A namespace on your France Nuage Kubernetes cluster
- A France Nuage Grafana instance (see the Grafana Stack guide)
- An application that exposes — or can expose — a
/metricsendpoint in Prometheus format
That's it. No observability component to deploy on your side.
Prometheus vs Mimir: what's the difference?
Prometheus is the standard for collecting and storing metrics. It's excellent as a single instance, but it becomes awkward at scale: long retention, high availability, multi-tenancy — each of those takes work.
Mimir is the scalable flavor of Prometheus. Same query language (PromQL), same metric format, but built for long-term retention, high availability and multi-tenancy. Your dashboards and alerts keep working exactly the same — Mimir looks like Prometheus from the client side. For more details, read the Mimir guide.
On France Nuage Kubernetes, we run:
- Grafana Alloy as the collector: it auto-discovers which pods to scrape via annotations
- Mimir as the storage backend: one tenant per namespace, hard isolation
- Grafana already wired to Mimir with your tenant pre-configured
Step 1: expose a /metrics endpoint
If your app already exposes /metrics, skip to step 2.
Otherwise, here's a minimal example with Node.js and prom-client:
npm install express prom-client
// server.js
const express = require('express');
const client = require('prom-client');
const register = new client.Registry();
register.setDefaultLabels({ app: 'my-service' });
client.collectDefaultMetrics({ register });
const httpRequests = new client.Counter({
name: 'http_requests_total',
help: 'Total HTTP requests received',
labelNames: ['method', 'status'],
});
register.registerMetric(httpRequests);
const app = express();
app.get('/', (req, res) => {
httpRequests.inc({ method: 'GET', status: '200' });
res.send('hello');
});
app.get('/metrics', (req, res) => {
register.metrics().then((metrics) => {
res.set('Content-Type', register.contentType);
res.end(metrics);
});
});
app.listen(9090, () => console.log('listening on :9090'));
You now have an app listening on port 9090 and exposing its metrics at /metrics. Test it locally:
curl http://localhost:9090/metrics
You should see a bunch of default metrics (process_cpu_seconds_total, nodejs_heap_size_used_bytes, etc.) followed by your http_requests_total.
Step 2: add annotations to your Pod
This is the heart of the tutorial. Alloy continuously watches the cluster and scrapes every pod that carries these annotations:
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9090" # port of /metrics
prometheus.io/path: "/metrics" # optional, defaults to /metrics
Important: these annotations must live on the Pod, not on the Deployment or Service. In practice, that means spec.template.metadata.annotations inside a Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-service
namespace: my-app
spec:
replicas: 2
selector:
matchLabels:
app: my-service
template:
metadata:
labels:
app: my-service
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9090"
prometheus.io/path: "/metrics"
spec:
containers:
- name: app
image: registry.gitlab.com/my-group/my-service:latest
ports:
- name: http
containerPort: 9090
Apply it:
kubectl apply -f deployment.yaml -n my-app
If you deploy through a CI/CD pipeline, check out the Deploy to Kubernetes from GitLab CI tutorial to wire helm upgrade to every push.
Alloy picks up the new pod, starts scraping /metrics every 15 seconds, and pushes the samples to Mimir with the header X-Scope-OrgID: my-app. The Mimir tenant is derived from your namespace name — you have nothing to configure.
Step 3: query your metrics in Grafana
Open your France Nuage Grafana instance. The Mimir datasource is already configured and wired to your tenant.
- Go to Explore
- Pick the Mimir datasource
- Type a PromQL query, for example:
http_requests_total{app="my-service"}
Or to see the request rate over 5 minutes:
sum(rate(http_requests_total{app="my-service"}[5m])) by (status)
You can also check the scrape is healthy by looking at the up metric:
up{namespace="my-app"}
A value of 1 means scrape succeeded. A value of 0 means Alloy can't reach your endpoint — jump to the troubleshooting section.
Troubleshooting
The up metric returns 0
Alloy sees your pod but can't scrape the endpoint. Check, in order:
- The port is right: the
prometheus.io/portannotation must point to the container's actual listening port, not the Service port - The path is right: if your endpoint isn't
/metrics, setprometheus.io/path: "/your-path" - The container listens on all interfaces:
0.0.0.0:9090, not127.0.0.1:9090, otherwise Alloy can't reach it from outside the pod
The up metric doesn't show up at all
Alloy isn't seeing your pod. This is almost always an annotation issue:
- Annotations are on the Pod (
spec.template.metadata.annotations), not on the Deployment itself - Values are strings:
"true","9090", nottrueor9090 - The
prometheus.io/scrapeannotation is exactly"true"(not"1"or"yes")
Check with:
kubectl get pod -n my-app -l app=my-service -o jsonpath='{.items[0].metadata.annotations}'
My metrics don't show up in Grafana
If up works but your custom metrics don't come through:
- Make sure you're on the right datasource (Mimir, not some local Prometheus)
- Remove any overly restrictive filters from your PromQL query
- Wait 15-30 seconds: that's the scrape interval
ERR cardinality limit exceeded
Mimir protects tenants against cardinality explosions. If you see this error, one of your metrics has too many distinct label combinations. See the next section.
Best practices
Keep label cardinality under control
This is pitfall number one with Prometheus and Mimir. Each unique label combination creates a distinct time series, and every series consumes memory.
Absolutely avoid:
user_id,request_id,trace_idas labels: infinite cardinality, your tenant will blow up- Raw
url:/user/42,/user/43... templatize it into/user/:id timestampor any time-derived value
Prefer:
- Bounded-cardinality labels:
method,status,endpoint(templatized),env - A consistent
appandenvacross all your metrics, so queries stay simple
Name your metrics per Prometheus conventions
_totalsuffix for counters_secondssuffix for durations (not_ms)_bytessuffix for sizessnake_case, no uppercase
One app, one port, one endpoint
Exposing /metrics on the same port as your main app is simpler than using a dedicated port. If you have a reason to separate them (security, firewalling), open a second port in your container and adjust prometheus.io/port.
Going further
- Mimir guide — architecture, multi-tenancy, comparison, best practices
- Grafana Stack guide — build dashboards, configure Alertmanager, ship logs to Loki
- Deploy to Kubernetes from GitLab CI — automate deployment of your annotated pods
- PromQL documentation — the query language
Questions? [email protected]