Deployment & Operations¶
This exporter is distributed as a container image, plus an official Helm chart for Kubernetes. Use the Getting Started guide for initial setup and the provided docker-compose.yml as a baseline for production deployments.
Distribution¶
For v1, the exporter ships as a container image + Helm chart only — there is no PyPI package, and pip install meraki-dashboard-exporter is not a supported install path. If you need to run it outside a container, clone the repository and run it with uv as described in Getting Started.
Kubernetes (Helm)¶
A Helm chart (charts/meraki-dashboard-exporter) is published to the GHCR OCI registry on every release, alongside the container image:
helm install meraki-dashboard-exporter \
oci://ghcr.io/rknightion/charts/meraki-dashboard-exporter \
--version <exporter-version> \
--set meraki.apiKey=your_api_key_here
Chart versions track exporter release versions (e.g. 0.31.0); an edge chart tracking main is also published on every push, versioned 0.0.0-main.*. The chart defaults to a hardened securityContext (non-root, read-only root filesystem) and fails render-time validation unless exactly one of meraki.apiKey / meraki.existingSecret is set — prefer existingSecret in production. See values.yaml for the full set of configurable settings.
The exporter is a single-writer singleton (no leader election), so replicaCount must stay 1 — the chart hard-fails the render otherwise, and its Deployment uses the Recreate strategy so a rollout never briefly runs two pods. Optional resources, all off by default: ingress.enabled (webhook TLS termination, see below), networkPolicy.enabled (locks egress to DNS + Meraki API 443 + the OTLP port), serviceMonitor.enabled (Prometheus Operator), and autoscaling.enabled (an HPA that manages the single pod; maxReplicas is capped at 1). Read the resources: sizing guidance in values.yaml before deploying at scale — the 512Mi default is sized for small orgs only. See the Scaling Guide for the API-budget sizing formula, per-scale resource tiers, and shard-by-org / HA recipes.
Shutdown behaviour and grace period¶
On SIGTERM, the exporter starts an orderly shutdown: in-flight HTTP requests are allowed to finish and running collector work is given a chance to wind down before the process exits. This is best-effort, not guaranteed, because collector fetches run the synchronous Meraki SDK on a background thread pool (asyncio.to_thread, sized by executor_workers) — a thread genuinely blocked inside an SDK HTTP call cannot be cancelled mid-flight from the asyncio event loop, so shutdown has to wait for that call to return (either normally or via its own timeout) rather than being able to kill it instantly.
Two settings bound how long a single blocked fetch can hold things up:
| Setting | Default | Description |
|---|---|---|
single_request_timeout (MERAKI_EXPORTER_API__TIMEOUT) | 30s | Bounds one HTTP request to the Meraki API. |
per_fetch_deadline_seconds | 120s (config.apiPerFetchDeadlineSeconds in Helm) | Bounds how long the exporter awaits a whole logical fetch, including pagination. It cancels the awaiting coroutine, but Python cannot interrupt a synchronous SDK thread that is already in HTTP or pagination work. |
Kubernetes only gives a pod terminationGracePeriodSeconds after SIGTERM before force-killing it with SIGKILL. If that grace period is shorter than the worst-case blocked fetch, Kubernetes kills the pod mid-shutdown, which is harmless (the exporter is stateless and safely restartable) but shows up as noisy SIGKILL/terminated: Error events instead of a clean exit. The Helm chart's terminationGracePeriodSeconds value defaults to 150s — comfortably above the per_fetch_deadline_seconds default (120s) with a fixed 30s margin for the deadline to actually fire and the fetch to unwind before Kubernetes gives up. Set longer fetches with Helm's config.apiPerFetchDeadlineSeconds; the chart rejects a terminationGracePeriodSeconds below per_fetch_deadline_seconds + 30s, and rejects an extraEnv override of that environment variable so the relationship cannot be bypassed.
That render-time rule prevents a known contradictory configuration; it is not a hard process-exit guarantee. An SDK pagination call or operating-system DNS lookup already running in a thread cannot observe coroutine cancellation and may outlive the deadline. During orderly shutdown, DNS, SDK and registry-serving executors share one 5-second drain budget. Their blocking joins run on daemon coordinator threads, so the asyncio event loop and uvicorn shutdown stay responsive. Queued work is cancelled immediately; the SDK HTTP session closes only after its running workers actually stop. If the shared budget expires, cleanup remains deferred in the background and the stateless pod relies on Kubernetes' existing SIGKILL grace boundary rather than freezing the event loop indefinitely.
Endpoints¶
The exporter exposes endpoints for metrics (/metrics), liveness (/health), readiness (/ready), an exporter self-health dashboard (/status), cardinality reports (/cardinality), and optional client (/clients) and webhook (POST /api/webhooks/meraki) features. See the HTTP Endpoints reference for the authoritative list and enablement notes.
/ready is a startup-only gate: it returns 200 after the initial required collection has succeeded and remains ready during later upstream failures. This keeps the pod in Service endpoints so Prometheus can scrape the metrics needed to diagnose the outage. /health is the liveness dead-man check; alert on time() - meraki_exporter_scheduler_group_success_timestamp_seconds for per-endpoint-group freshness and on meraki_exporter_scheduler_over_budget for deferred work.
Webhook receiver (HTTPS / TLS termination)¶
The exporter can receive Meraki alert webhooks (config.webhooksEnabled: true, endpoint POST /api/webhooks/meraki). Meraki only delivers webhooks over HTTPS — it rejects plain http:// receiver URLs — but the exporter itself serves plain HTTP. So you must terminate TLS in front of it and forward HTTP to the exporter. The receiver URL you configure in the Meraki Dashboard (Network-wide → Alerts → Webhooks) must therefore be https://…/api/webhooks/meraki, never http://. Always set the shared secret (MERAKI_EXPORTER_WEBHOOKS__SHARED_SECRET) so payloads are validated — in the Helm chart, inject it via extraEnv (sourced from a Secret) rather than a plain value.
Pick whichever terminator fits your environment; all three terminate TLS and proxy plain HTTP to the exporter's service port (9099 by default).
Kubernetes Ingress (bundled in the Helm chart)¶
The chart ships an optional Ingress (ingress.enabled: true) that fronts the Service. Terminate TLS at the ingress (e.g. via cert-manager) and it forwards HTTP to the exporter:
# values.yaml
config:
webhooksEnabled: "true"
extraEnv:
- name: MERAKI_EXPORTER_WEBHOOKS__SHARED_SECRET
valueFrom:
secretKeyRef:
name: meraki-webhook-secret
key: secret
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: meraki-exporter.example.com
paths:
- path: /api/webhooks/meraki
pathType: Prefix
tls:
- secretName: meraki-exporter-tls
hosts:
- meraki-exporter.example.com
Meraki receiver URL: https://meraki-exporter.example.com/api/webhooks/meraki.
nginx (standalone reverse proxy)¶
server {
listen 443 ssl;
server_name meraki-exporter.example.com;
ssl_certificate /etc/nginx/tls/fullchain.pem;
ssl_certificate_key /etc/nginx/tls/privkey.pem;
# Only expose the webhook receiver publicly; scrape /metrics in-cluster/privately.
location /api/webhooks/meraki {
proxy_pass http://meraki-dashboard-exporter:9099;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Traefik (IngressRoute / dynamic config)¶
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: meraki-exporter-webhook
spec:
entryPoints:
- websecure # the TLS entrypoint
routes:
- match: Host(`meraki-exporter.example.com`) && PathPrefix(`/api/webhooks/meraki`)
kind: Rule
services:
- name: meraki-dashboard-exporter
port: 9099 # plain HTTP to the exporter
tls:
certResolver: letsencrypt
In every case Meraki talks HTTPS to the terminator, and the terminator talks HTTP to the exporter.
Monitoring¶
Prometheus and Grafana integration examples live in the Integration & Dashboards guide.
Updating¶
Pull the latest image and restart the container:
Troubleshooting¶
- Check container logs with
docker compose logs meraki_dashboard_exporter. - Verify the API key and network connectivity.
- Metrics
meraki_exporter_collector_errors_totalhelp identify failing collectors. - Open
/statusfor an at-a-glance view of tier health, last collection durations, and the effectiveNetworkFilterstate (parsed rules plus per-org total/resolved network counts, read from the samemeraki_network_filter_*gauges — see Network Filter below). - See Troubleshooting for a symptom-driven decision tree covering auth errors, empty
/metrics, readiness gating, per-org backoff, rate-limit storms, cardinality shedding, and webhook misconfiguration.
Alerting on partial collection failures¶
A collector cycle can succeed overall — /ready stays 200, and the coordinator does not raise — while one or more sub-phases inside it fail and are silently tolerated. This is deliberate resilience (a single broken endpoint, e.g. one MX VPN fetch or one org's config sub-collection, must never abort the rest of that cycle's collection), but it means the honest top-level failure signals introduced for #509 (a raised _collect_impl(), /health staleness, meraki_exporter_org_collection_status) can stay green for cycles that are nonetheless degraded.
Every one of those tolerated sub-phase failures increments the same counter used for full collector failures — meraki_exporter_collector_errors_total (labels: collector, tier, error_type) — so it is the only signal that surfaces this class of degradation. Alert on its rate rather than waiting for a full-cycle failure:
The bounded coordinator-category values are api_rate_limit, api_auth_error, api_client_error, api_server_error, api_not_available, connectivity, timeout, parsing, validation, and unknown; fatal top-level failures use their fixed exception class name (for example NothingCollectedError). unknown is reserved for exceptions that genuinely cannot be classified; alert on it separately because a rise means the classifier needs extending.
# Any tolerated sub-phase failure in the last 15 minutes, broken out by
# collector/tier/error_type so you can see which sub-system is degraded.
sum by (collector, tier, error_type) (
rate(meraki_exporter_collector_errors_total[15m])
) > 0
# Sustained partial failure: a collector/tier has logged errors continuously
# for the last hour. Good candidate for a paging alert (vs. the query above,
# which is better suited to a dashboard panel or a low-severity notification).
min_over_time(
(sum by (collector, tier) (rate(meraki_exporter_collector_errors_total[15m])) > 0)[1h:]
) == 1
NothingCollectedError now means the collector genuinely obtained no usable result, rather than one optional endpoint being unavailable. Treat more than five such failures in 15 minutes as a warning (the supplied MerakiCollectorErrorsHigh rule uses this same > 5 threshold across each collector); page only when the org status is also 0 or /health is stale.
Because this counter increments for tolerated and fatal failures alike, corroborate a firing alert against the #509 health signals before treating it as critical:
up{job="meraki-dashboard-exporter"}//health- process liveness (unaffected either way)./ready- startup-only confirmation that the initial required collection completed. It does not trip on later failures; use/healthand the scheduler success timestamps for ongoing health.meraki_exporter_org_collection_status{org_id="..."}- per-organization gauge,0only when every sub-collection failed for that org this cycle (seeOrganizationCollector/OrgHealthTrackerincore/org_health.py).
If meraki_exporter_collector_errors_total is climbing but /ready is 200 and meraki_exporter_org_collection_status is 1, the cycle is completing with partial data loss for the affected sub-phase (e.g. one MR/MS/MX sub-collection, one config sub-collection, or one org's sensor gateway connections) - worth investigating, but not an outage. If /health becomes stale or the org status gauge drops to 0, treat it as the higher-severity failure case instead.
Network Filter¶
For large organisations, restrict scraping to a subset of networks via the MERAKI_EXPORTER_NETWORK_FILTER__* settings (include/exclude by name glob, ID, or tag). The filter is inactive by default; if a filter is configured but resolves to zero networks across all configured orgs at startup, the exporter exits with an error so typos fail loudly. The refusal requires a successful API response that proves the filtered result is empty; a transient verification failure is logged and collection starts normally so the exporter can recover when Meraki does. See .env.example and the Configuration guide for details.
Log Aggregation¶
The exporter outputs structured logs via structlog, rendered by MERAKI_EXPORTER_LOGGING__LOG_FORMAT — json (the default, suitable for Loki, Datadog, ELK, or CloudWatch) or opt-in logfmt. Both formats carry the same fields (level, event, timestamp, plus any structured kwargs the log call attaches); only the wire encoding differs. See Configuration for the setting.
Grafana Alloy Configuration¶
To ship logs to Loki using Grafana Alloy, add this to your Alloy configuration:
local.file_match "meraki_exporter" {
path_targets = [{"__path__" = "/var/log/meraki-exporter/*.log"}]
}
loki.source.file "meraki_exporter" {
targets = local.file_match.meraki_exporter.targets
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}
For Docker or Kubernetes deployments, use the container log discovery instead:
discovery.docker "meraki" {
host = "unix:///var/run/docker.sock"
filter {
name = "name"
values = ["meraki-dashboard-exporter"]
}
}
loki.source.docker "meraki" {
host = "unix:///var/run/docker.sock"
targets = discovery.docker.meraki.targets
forward_to = [loki.write.default.receiver]
}
Example LogQL Queries¶
These example queries assume the default MERAKI_EXPORTER_LOGGING__LOG_FORMAT=json, where the | json parser stage extracts each structured field (e.g. collector, duration) as a label you can filter or group on. The line-filter (|=/|~) portion of every query below also works unchanged against opt-in logfmt output — the raw text still contains the same substrings — but the | json parser stage does not understand logfmt, so swap it for | logfmt if you opt in to log_format=logfmt.
Collector failures:
With opt-inlog_format=logfmt instead: Rate limit events:
Slow collections (utilization warnings, logged when a collector uses >80% of its cadence):
Error summary by collector:
sum by (collector) (count_over_time({container="meraki-dashboard-exporter"} |= "error" | json [1h]))
For configuration options see the Configuration guide. A list of exported metrics is available in the Metrics Reference.