Multi-tenant monitoring voor MSP's: veertig klanten scheiden zonder veertig installaties
Eén Zabbix per klant schaalt niet; één Zabbix voor iedereen lekt. Dit is de architectuur die wél werkt met open-source bouwstenen — Prometheus per klant, Grafana-organisaties, Alertmanager-routing op een tenant-label, één naamgeving en één offboarding-procedure — en de eerlijke rekening van wat het aan onderhoud kost bij tien, veertig en honderd klanten.
Inhoud
- Stap 0: de vijf eisen die een MSP anders maakt
- Stap 1: naamgeving en tagging — het fundament dat iedereen overslaat
- Stap 2: verzamelen — één Prometheus per klant, één centrale Thanos of Mimir
- Stap 3: dashboards — Grafana-organisaties, geen folders
- Stap 4: alerts — één regelset, routing op tenant
- Stap 5: klant-rapporten en bewijs
- Stap 6: offboarding — één script, aantoonbaar
- De rekening
- Valkuilen
- Wat je hiermee nog niet hebt
- Zo doet monsys het
- FAQ
Een MSP met veertig klanten heeft twee slechte opties en één moeilijke. Slecht: veertig losse monitoring-installaties, elk met een eigen versie, eigen alert-regels en eigen wachtwoord dat één collega kent. Ook slecht: één grote installatie waarin klant A in principe de hosts van klant B kan zien als iemand een filter vergeet. Moeilijk: één platform met échte scheiding — per klant eigen data, eigen toegang, eigen alerts, eigen rapporten — en toch één werkwijze. Dit artikel bouwt die derde optie met open-source bouwstenen, en is eerlijk over waar het pijn doet.
Stap 0: de vijf eisen die een MSP anders maakt
Voor je iets installeert, de lijst waar elke keuze aan getoetst wordt:
| Eis | Waarom | Wat het technisch betekent |
|---|---|---|
| Isolatie | Klant A mag nooit iets van klant B zien, ook niet per ongeluk | Scheiding op data-niveau, niet alleen in de UI |
| Per-klant toegang | De IT-verantwoordelijke van klant A wil zelf kijken | Read-only accounts per klant, zonder dat jij per klant een installatie beheert |
| Eén werkwijze | Je team moet elke klant kunnen overnemen | Dezelfde alert-regels, dezelfde naamgeving, dezelfde runbooks — overal |
| Bewijs per klant | NIS2-klanten vragen rapporten; jij bent hun leverancier (art. 21 §2 d) | Exporteerbaar, per klant, met datum |
| Offboarding | Contracten eindigen; GDPR vraagt dat data dan weg is | Eén procedure die alles van klant X verwijdert, aantoonbaar |
Stap 1: naamgeving en tagging — het fundament dat iedereen overslaat
Alles wat volgt, hangt aan één label: de klant. Kies het formaat nu en wijk er nooit van af.
# Elke host, elke metric, elke alert krijgt deze labels:
tenant: acme # korte, stabiele klantcode; nooit de bedrijfsnaam (die verandert)
env: prod # prod | staging | dev
role: web # web | db | app | edge | backup
site: brussels-dc1 # of cloud-region
Op de host zelf staat de klantcode op één plek, en elke tool leest hem daar:
sudo tee /etc/monitoring-identity >/dev/null <<'EOF'
TENANT=acme
ENV=prod
ROLE=web
SITE=brussels-dc1
EOF
# Hostnaam volgt de conventie: <tenant>-<env>-<role>-<nn>
hostnamectl set-hostname acme-prod-web-01
Klinkt bureaucratisch. Maar het is het verschil tussen {tenant="acme"} in elke query en een spreadsheet met "welke IP hoort bij wie".
Stap 2: verzamelen — één Prometheus per klant, één centrale Thanos of Mimir
De eenvoudigste harde scheiding: elke klant krijgt een eigen Prometheus (op een kleine VM bij jou, of on-prem bij de klant), en die Prometheussen schrijven door naar één centrale opslag met de tenant-label als sleutel.
# /etc/prometheus/prometheus.yml op de Prometheus van klant acme
global:
external_labels:
tenant: acme # wordt aan ELKE metric gehangen vóór hij het gebouw verlaat
scrape_configs:
- job_name: node
file_sd_configs:
- files: ['/etc/prometheus/targets/*.yml'] # hosts van acme, beheerd via Ansible
remote_write:
- url: https://metrics.msp.example.be/api/v1/push
basic_auth:
username: acme
password_file: /etc/prometheus/remote-write.pass
headers:
X-Scope-OrgID: acme # Mimir/Cortex tenant-header; Thanos gebruikt het external_label
Centraal draait Grafana Mimir (of Thanos Receive) met multi-tenancy aan: de X-Scope-OrgID bepaalt in welke fysiek gescheiden set van blokken de data terechtkomt. Een query zonder tenant-header krijgt niets. Dat is isolatie op data-niveau, niet op UI-niveau.
De prijs: per klant een Prometheus-VM (1 vCPU, 2 GB is genoeg tot ~50 hosts), plus een centrale Mimir-cluster die je moet begrijpen. Bij tien klanten is dat een middag per maand; bij veertig een halve FTE.
Stap 3: dashboards — Grafana-organisaties, geen folders
Grafana kent teams en folders (scheiding in de UI) en organisaties (scheiding van datasources, gebruikers en dashboards). Voor klant-toegang gebruik je organisaties. Een gebruiker in org "acme" ziet geen datasource van org "globex", punt.
# Per klant een org, met een datasource die de tenant-header meegeeft
curl -s -u admin:$GRAFANA_ADMIN_PW -X POST https://grafana.msp.example.be/api/orgs \
-H 'Content-Type: application/json' -d '{"name":"acme"}'
ORG_ID=$(curl -s -u admin:$GRAFANA_ADMIN_PW https://grafana.msp.example.be/api/orgs/name/acme | jq .id)
curl -s -u admin:$GRAFANA_ADMIN_PW -X POST https://grafana.msp.example.be/api/datasources \
-H "X-Grafana-Org-Id: $ORG_ID" -H 'Content-Type: application/json' -d '{
"name":"metrics","type":"prometheus","url":"https://metrics.msp.example.be/prometheus",
"access":"proxy","jsonData":{"httpHeaderName1":"X-Scope-OrgID"},"secureJsonData":{"httpHeaderValue1":"acme"}}'
De dashboards zelf beheer je als code (JSON in git, provisioning per org) zodat elke klant hetzelfde "Fleet overview"-dashboard krijgt en een fix in één keer overal landt. Jouw eigen team krijgt een aparte "MSP"-org met een datasource zonder tenant-filter (Mimir: X-Scope-OrgID: acme|globex|... voor cross-tenant queries) — dat is de enige plek waar alles samen zichtbaar is, en die org heeft MFA verplicht.
Stap 4: alerts — één regelset, routing op tenant
Alert-regels schrijf je één keer en worden bij elke klant-Prometheus geprovisioned. Alertmanager centraal routeert op het tenant-label naar het juiste kanaal, en naar de juiste on-call.
# alertmanager.yml (centraal)
route:
receiver: msp-default
group_by: ['tenant', 'alertname']
routes:
- matchers: ['tenant="acme"']
receiver: acme
continue: true # én naar de klant én naar ons
- matchers: ['tenant="globex"']
receiver: globex
continue: true
- matchers: ['severity="critical"']
receiver: msp-oncall
receivers:
- name: msp-default
webhook_configs: [{ url: 'https://ntfy.msp.example.be/all' }]
- name: msp-oncall
webhook_configs: [{ url: 'https://ntfy.msp.example.be/oncall' }]
- name: acme
email_configs: [{ to: 'it@acme.example', from: 'monitoring@msp.example.be', smarthost: 'mail.msp.example.be:587' }]
- name: globex
webhook_configs: [{ url: 'https://ntfy.msp.example.be/globex' }]
De valkuil: een receiver die naar een gedeeld kanaal wijst waar meerdere klanten in zitten. Eén Teams-kanaal met "alle klant-alerts" is een datalek in wording. Per klant een eigen kanaal of e-mailadres, altijd.
Stap 5: klant-rapporten en bewijs
Een NIS2-klant vraagt je om aan te tonen dat hun servers gepatcht, gemonitord en geback-upt zijn — jij bent hun leverancier onder art. 21 §2 (d). Dat rapport moet per klant, met datum, en zonder dat er iets van een andere klant in zit.
# Maandelijks per tenant: beschikbaarheid, open alerts, hosts, patch-status uit Prometheus
TENANT=acme; MONTH=$(date -d 'last month' +%Y-%m)
Q='https://metrics.msp.example.be/prometheus/api/v1/query'
H="X-Scope-OrgID: $TENANT"
{
echo "# Rapport $TENANT — $MONTH — $(date -Is)"
echo "hosts: $(curl -s -H "$H" "$Q" --data-urlencode 'query=count(up{job="node"})' | jq -r '.data.result[0].value[1]')"
echo "uptime%: $(curl -s -H "$H" "$Q" --data-urlencode 'query=avg_over_time(up{job="node"}[30d])*100' | jq -r '.data.result[0].value[1]')"
echo "reboot-required: $(curl -s -H "$H" "$Q" --data-urlencode 'query=count(node_reboot_required == 1)' | jq -r '.data.result[0].value[1] // 0')"
echo "critical alerts (30d): $(curl -s -H "$H" "$Q" --data-urlencode 'query=count_over_time(ALERTS{severity="critical",alertstate="firing"}[30d])' | jq -r '[.data.result[].value[1]|tonumber]|add // 0')"
} > "/srv/reports/$TENANT/$MONTH.txt"
sha256sum "/srv/reports/$TENANT/$MONTH.txt" >> "/srv/reports/$TENANT/MANIFEST"
Onderteken de manifest-file zoals in de bewijs-how-to; dan kan de klant (of zijn auditor) controleren dat het rapport niet is aangepast.
Stap 6: offboarding — één script, aantoonbaar
Als het contract eindigt, moet alles van die klant weg: metrics, dashboards, gebruikers, alert-routes, en de hosts uit je inventaris. Schrijf het script vóór je de eerste klant onboardt.
#!/usr/bin/env bash
# offboard.sh <tenant> — verwijdert alles van een klant, logt elke stap
set -euo pipefail
T=$1; LOG=/srv/offboarding/$T-$(date +%F).log
log(){ echo "$(date -Is) $*" | tee -a "$LOG"; }
log "start offboarding $T"
# 1. Grafana-org (neemt dashboards, datasources en org-gebruikers mee)
ORG=$(curl -sf -u admin:$GRAFANA_ADMIN_PW https://grafana.msp.example.be/api/orgs/name/$T | jq .id) && \
curl -sf -u admin:$GRAFANA_ADMIN_PW -X DELETE https://grafana.msp.example.be/api/orgs/$ORG >/dev/null && log "grafana org $ORG deleted"
# 2. Metrics: Mimir tenant-deletion (asynchroon; markeert alle blokken voor verwijdering)
curl -sf -X POST -H "X-Scope-OrgID: $T" https://metrics.msp.example.be/compactor/delete_tenant && log "mimir tenant deletion requested"
# 3. Alertmanager-route + Prometheus-VM
sed -i "/tenant=\"$T\"/,+2d" /etc/alertmanager/alertmanager.yml && systemctl reload alertmanager && log "alert route removed"
log "TODO handmatig: Prometheus-VM $T-prom verwijderen; remote-write-credentials intrekken; hosts uit Ansible-inventory"
# 4. Rapporten: bewaren volgens contract (meestal 1-3 jaar), daarna verwijderen — apart gepland
log "reports retained until $(date -d '+3 years' +%F) per contract"
log "done"
Het logbestand is het bewijs dat je aan de klant geeft: "op datum X is alles verwijderd, behalve de rapporten die we contractueel tot Y bewaren".
De rekening
| 10 klanten | 40 klanten | 100 klanten | |
|---|---|---|---|
| Prometheus-VM's | 10 | 40 | 100 |
| Onderhoud (updates, schijfruimte, regels uitrollen) | ~4 u/maand | ~20 u/maand | ~1 FTE |
| Mimir/Thanos-cluster | 1 node volstaat | 3 nodes, iemand die het begrijpt | dedicated |
| Grafana-orgs + provisioning | script | script + tests | script + tests + een eigenaar |
| Risico op tenant-lek door menselijke fout | laag | middel (elke nieuwe collega) | reëel |
Het model werkt. Het kost bij veertig klanten een halve persoon om het te laten werken, en die persoon moet Prometheus, Mimir, Grafana-provisioning én Alertmanager-routing echt kennen.
Valkuilen
- Label-lek via
external_labels. Vergeet jetenantop één Prometheus, dan landen die metrics zonder tenant en verschijnen ze — afhankelijk van je Mimir-config — bij de default-tenant of nergens. Test elke nieuwe klant-Prometheus met een query vóór je hem in productie neemt. - Gedeelde agent-tokens. Eén
node_exporter-basic-auth voor alle klanten betekent dat een gecompromitteerde host van klant A metrics van klant B kan pushen (of lezen). Per tenant eigen credentials, en rotatie bij offboarding. - Grafana-admin die overal in kan. Handig, tot een collega vertrekt. De MSP-org met cross-tenant toegang heeft MFA, een eigen audit-log en een kwartaal-review van wie erin zit.
- Klantnaam in de hostnaam.
acmeis een code. "Acme Industries NV" verandert bij een fusie en staat dan in 400 dashboards. Codes veranderen niet. - Verwerkersovereenkomst vergeten. Je monitort hun systemen: dat is verwerking van persoonsgegevens (IP's, usernames in logs). Elke klant tekent een DPA, en de offboarding-procedure verwijst ernaar.
Wat je hiermee nog niet hebt
- Alles wat geen metric is. Prometheus is voor getallen. CVE's per klant, inventaris, wie-heeft-sudo, back-up-versheid, certificaten: dat zijn aparte pijplijnen met aparte tenant-scheiding — en die moet je elk opnieuw bouwen met dezelfde vijf eisen.
- Ingrijpen. "Herstart die service bij klant A" is een ssh-sessie, met de vraag wie dat mag, wie het deed, en of klant A dat mag zien. Een ondertekend spoor daarvan is er niet.
- Bewijs dat de scheiding werkt. Een klant die vraagt "hoe weet ik dat mijn concurrent mijn data niet ziet?" krijgt een architectuurtekening. Een auditor wil meer.
Zo doet monsys het
monsys is vanaf de eerste migratie multi-tenant: elke tabel heeft een tenant_id, elke query pint die expliciet én PostgreSQL row-level security is de backstop. Eén hub, veertig tenants, nul extra VM's. Per tenant: eigen agents met eigen tokens en mTLS-certificaten, eigen gebruikers met scope-gebaseerde rollen (viewer op klant A, editor op klant B), eigen branding en eigen ntfy-topic. Voor jou als MSP: een cross-customer overzicht over alle tenants waar je staff bent, een handover-rapport per klant (Ed25519-ondertekend, offline verifieerbaar) en on-call-rotaties per groep. Ingrijpen gaat via Emergency Action Tokens die per tenant gelogd zijn, zodat klant A in zijn eigen audit-log ziet wat jij op zijn hosts deed — en niets van klant B.
FAQ
Kan ik niet gewoon één Prometheus met een tenant-label gebruiken?
Technisch wel, maar dan is de scheiding een filter in de UI en in elke query. Eén vergeten {tenant="acme"} in een dashboard-variabele en klant A ziet klant B. Voor een MSP onder NIS2 is dat een onaanvaardbaar risico; de scheiding moet op opslag-niveau zitten.
Hoeveel klanten kan ik aan voor ik dit nodig heb?
Tot vijf klanten werkt "per klant een kleine installatie" prima. Vanaf tien wordt het onderhoud van losse installaties duurder dan een centrale opzet. Vanaf veertig is de centrale opzet zelf een halve baan.
Wat vraagt een klant onder NIS2 concreet van zijn MSP?
Aantoonbaarheid: dat hun systemen gemonitord, gepatcht en geback-upt zijn, met datum en zonder data van andere klanten. Plus een contract (DPA en een beveiligingsbijlage) en een incidentprocedure: als jij een inbraak ziet bij klant A, moet klant A binnen 24 uur bij het CCB kunnen melden, dus jij moet het hen nog sneller melden.
Geschreven door het monsys-team — sysadmins die dit dagelijks doen.
Zelf gedaan? Laat monsys het bijhouden.
Alles uit deze how-to draait in monsys als doorlopende check, met historie, alerts en audit-bewijs. 5 servers gratis, EU-gehost in België, geïnstalleerd in 60 seconden.