nandi/atop-analyzepublic Fork 0
e5a43e4
Commits
Clone
git clone https://git.rickub.com/nandi/atop-analyze.git
git clone ssh://git@rickub.com/nandi/atop-analyze.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Make ClickHouse ingestion idempotent and expose Grafana through proxy

nandithebull committed 2026-09-21T20:37:33-07:00 Browse files
e5a43e4 parent: 78ee9e9
modified atop_analyze.py +27 -4
@@ -22,6 +22,7 @@ from __future__ import annotations
2222
2323 import argparse
2424 import base64
25+import hashlib
2526 import json
2627 import logging
2728 import os
@@ -213,6 +214,16 @@ def clickhouse_identifier(value: str, option: str) -> str:
213214 return value
214215
215216
217+def ingest_version(row: dict[str, object]) -> int:
218+ """Return a stable UInt64 version for one immutable source row.
219+
220+ The value is derived from canonical JSON rather than wall-clock time, so a
221+ retried export of the same atop interval has exactly the same version.
222+ """
223+ encoded = json.dumps(row, sort_keys=True, separators=(",", ":")).encode()
224+ return int.from_bytes(hashlib.blake2b(encoded, digest_size=8).digest(), "big")
225+
226+
216227 def clickhouse_request(
217228 url: str, query: str, body: str, user: str | None, password: str | None,
218229 modal_key: str | None, modal_secret: str | None,
@@ -280,14 +291,22 @@ def write_clickhouse(
280291 ncpu UInt16, user_pct Float32, sys_pct Float32, nice_pct Float32,
281292 idle_pct Float32, wait_pct Float32, irq_pct Float32, softirq_pct Float32,
282293 steal_pct Float32, busy_pct Float32, load1 Float32, load5 Float32,
283- load15 Float32, ctxsw UInt64, intr UInt64
284- ) ENGINE = ReplacingMergeTree ORDER BY (host, epoch)""", "", user, password,
294+ load15 Float32, ctxsw UInt64, intr UInt64, ingest_version UInt64
295+ ) ENGINE = ReplacingMergeTree(ingest_version) ORDER BY (host, epoch)""", "", user, password,
285296 modal_key, modal_secret)
286297 clickhouse_request(url, f"""CREATE TABLE IF NOT EXISTS {processes_target} (
287298 host LowCardinality(String), epoch DateTime, pid UInt32,
288- name LowCardinality(String), cpu_pct Float32, interval_seconds UInt32
289- ) ENGINE = ReplacingMergeTree ORDER BY (host, epoch, pid, name)""", "", user, password,
299+ name LowCardinality(String), cpu_pct Float32, interval_seconds UInt32,
300+ ingest_version UInt64
301+ ) ENGINE = ReplacingMergeTree(ingest_version) ORDER BY (host, epoch, pid, name)""", "", user, password,
290302 modal_key, modal_secret)
303+ # Existing installations used ReplacingMergeTree without a version
304+ # column. Adding it is non-destructive; Grafana's argMax queries below
305+ # make those legacy tables deterministic immediately as well.
306+ clickhouse_request(url, f"ALTER TABLE {samples_target} ADD COLUMN IF NOT EXISTS ingest_version UInt64 DEFAULT 0",
307+ "", user, password, modal_key, modal_secret)
308+ clickhouse_request(url, f"ALTER TABLE {processes_target} ADD COLUMN IF NOT EXISTS ingest_version UInt64 DEFAULT 0",
309+ "", user, password, modal_key, modal_secret)
291310
292311 sample_rows = [
293312 {
@@ -301,11 +320,15 @@ def write_clickhouse(
301320 }
302321 for s in samples
303322 ]
323+ for row in sample_rows:
324+ row["ingest_version"] = ingest_version(row)
304325 process_rows = [
305326 {"host": s.host, "epoch": s.epoch, "pid": pid, "name": name,
306327 "cpu_pct": pct, "interval_seconds": s.interval}
307328 for s in samples for name, pid, pct in s.procs
308329 ]
330+ for row in process_rows:
331+ row["ingest_version"] = ingest_version(row)
309332 clickhouse_request(url, f"INSERT INTO {samples_target} FORMAT JSONEachRow",
310333 "\n".join(json.dumps(row) for row in sample_rows), user, password,
311334 modal_key, modal_secret)
@@ -22,6 +22,7 @@ from __future__ import annotations
22 22
23 import argparse23 import argparse
24 import base6424 import base64
25+import hashlib
25 import json26 import json
26 import logging27 import logging
27 import os28 import os
@@ -213,6 +214,16 @@ def clickhouse_identifier(value: str, option: str) -> str:
213 return value214 return value
214 215
215 216
217+def ingest_version(row: dict[str, object]) -> int:
218+ """Return a stable UInt64 version for one immutable source row.
219+
220+ The value is derived from canonical JSON rather than wall-clock time, so a
221+ retried export of the same atop interval has exactly the same version.
222+ """
223+ encoded = json.dumps(row, sort_keys=True, separators=(",", ":")).encode()
224+ return int.from_bytes(hashlib.blake2b(encoded, digest_size=8).digest(), "big")
225+
226+
216 def clickhouse_request(227 def clickhouse_request(
217 url: str, query: str, body: str, user: str | None, password: str | None,228 url: str, query: str, body: str, user: str | None, password: str | None,
218 modal_key: str | None, modal_secret: str | None,229 modal_key: str | None, modal_secret: str | None,
@@ -280,14 +291,22 @@ def write_clickhouse(
280 ncpu UInt16, user_pct Float32, sys_pct Float32, nice_pct Float32,291 ncpu UInt16, user_pct Float32, sys_pct Float32, nice_pct Float32,
281 idle_pct Float32, wait_pct Float32, irq_pct Float32, softirq_pct Float32,292 idle_pct Float32, wait_pct Float32, irq_pct Float32, softirq_pct Float32,
282 steal_pct Float32, busy_pct Float32, load1 Float32, load5 Float32,293 steal_pct Float32, busy_pct Float32, load1 Float32, load5 Float32,
283- load15 Float32, ctxsw UInt64, intr UInt64294+ load15 Float32, ctxsw UInt64, intr UInt64, ingest_version UInt64
284- ) ENGINE = ReplacingMergeTree ORDER BY (host, epoch)""", "", user, password,295+ ) ENGINE = ReplacingMergeTree(ingest_version) ORDER BY (host, epoch)""", "", user, password,
285 modal_key, modal_secret)296 modal_key, modal_secret)
286 clickhouse_request(url, f"""CREATE TABLE IF NOT EXISTS {processes_target} (297 clickhouse_request(url, f"""CREATE TABLE IF NOT EXISTS {processes_target} (
287 host LowCardinality(String), epoch DateTime, pid UInt32,298 host LowCardinality(String), epoch DateTime, pid UInt32,
288- name LowCardinality(String), cpu_pct Float32, interval_seconds UInt32299+ name LowCardinality(String), cpu_pct Float32, interval_seconds UInt32,
289- ) ENGINE = ReplacingMergeTree ORDER BY (host, epoch, pid, name)""", "", user, password,300+ ingest_version UInt64
301+ ) ENGINE = ReplacingMergeTree(ingest_version) ORDER BY (host, epoch, pid, name)""", "", user, password,
290 modal_key, modal_secret)302 modal_key, modal_secret)
303+ # Existing installations used ReplacingMergeTree without a version
304+ # column. Adding it is non-destructive; Grafana's argMax queries below
305+ # make those legacy tables deterministic immediately as well.
306+ clickhouse_request(url, f"ALTER TABLE {samples_target} ADD COLUMN IF NOT EXISTS ingest_version UInt64 DEFAULT 0",
307+ "", user, password, modal_key, modal_secret)
308+ clickhouse_request(url, f"ALTER TABLE {processes_target} ADD COLUMN IF NOT EXISTS ingest_version UInt64 DEFAULT 0",
309+ "", user, password, modal_key, modal_secret)
291 310
292 sample_rows = [311 sample_rows = [
293 {312 {
@@ -301,11 +320,15 @@ def write_clickhouse(
301 }320 }
302 for s in samples321 for s in samples
303 ]322 ]
323+ for row in sample_rows:
324+ row["ingest_version"] = ingest_version(row)
304 process_rows = [325 process_rows = [
305 {"host": s.host, "epoch": s.epoch, "pid": pid, "name": name,326 {"host": s.host, "epoch": s.epoch, "pid": pid, "name": name,
306 "cpu_pct": pct, "interval_seconds": s.interval}327 "cpu_pct": pct, "interval_seconds": s.interval}
307 for s in samples for name, pid, pct in s.procs328 for s in samples for name, pid, pct in s.procs
308 ]329 ]
330+ for row in process_rows:
331+ row["ingest_version"] = ingest_version(row)
309 clickhouse_request(url, f"INSERT INTO {samples_target} FORMAT JSONEachRow",332 clickhouse_request(url, f"INSERT INTO {samples_target} FORMAT JSONEachRow",
310 "\n".join(json.dumps(row) for row in sample_rows), user, password,333 "\n".join(json.dumps(row) for row in sample_rows), user, password,
311 modal_key, modal_secret)334 modal_key, modal_secret)
modified grafana/Dockerfile +2 -2
@@ -4,9 +4,9 @@ FROM grafana/grafana:11.6.0
44 # Python and the Grafana plugin can be installed, then run Grafana unprivileged.
55 USER root
66 RUN if [ -f /etc/alpine-release ]; then \
7- apk add --no-cache python3; \
7+ apk add --no-cache python3 socat; \
88 else \
9- apt-get update && apt-get install --no-install-recommends -y python3 && rm -rf /var/lib/apt/lists/*; \
9+ apt-get update && apt-get install --no-install-recommends -y python3 socat && rm -rf /var/lib/apt/lists/*; \
1010 fi \
1111 && ln -sf "$(command -v python3)" /usr/local/bin/python \
1212 && grafana cli plugins install grafana-clickhouse-datasource \
@@ -4,9 +4,9 @@ FROM grafana/grafana:11.6.0
4 # Python and the Grafana plugin can be installed, then run Grafana unprivileged.4 # Python and the Grafana plugin can be installed, then run Grafana unprivileged.
5 USER root5 USER root
6 RUN if [ -f /etc/alpine-release ]; then \6 RUN if [ -f /etc/alpine-release ]; then \
7- apk add --no-cache python3; \7+ apk add --no-cache python3 socat; \
8 else \8 else \
9- apt-get update && apt-get install --no-install-recommends -y python3 && rm -rf /var/lib/apt/lists/*; \9+ apt-get update && apt-get install --no-install-recommends -y python3 socat && rm -rf /var/lib/apt/lists/*; \
10 fi \10 fi \
11 && ln -sf "$(command -v python3)" /usr/local/bin/python \11 && ln -sf "$(command -v python3)" /usr/local/bin/python \
12 && grafana cli plugins install grafana-clickhouse-datasource \12 && grafana cli plugins install grafana-clickhouse-datasource \
modified grafana/provisioning/dashboards/json/atop-overview.json +3 -3
@@ -7,7 +7,7 @@
77 "fieldConfig": {"defaults": {"unit": "percent"}, "overrides": []},
88 "gridPos": {"h": 9, "w": 12, "x": 0, "y": 0},
99 "id": 1,
10- "targets": [{"format": 1, "query": "SELECT epoch AS time, busy_pct AS busy, wait_pct AS iowait FROM atop_samples WHERE $__timeFilter(epoch) ORDER BY epoch", "refId": "A"}],
10+ "targets": [{"format": 1, "query": "SELECT epoch AS time, argMax(busy_pct, ingest_version) AS busy, argMax(wait_pct, ingest_version) AS iowait FROM atop_samples WHERE $__timeFilter(epoch) GROUP BY host, epoch ORDER BY epoch", "refId": "A"}],
1111 "title": "CPU busy and I/O wait",
1212 "type": "timeseries"
1313 },
@@ -16,7 +16,7 @@
1616 "fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
1717 "gridPos": {"h": 9, "w": 12, "x": 12, "y": 0},
1818 "id": 2,
19- "targets": [{"format": 1, "query": "SELECT epoch AS time, load1 AS load_1m, load5 AS load_5m, load15 AS load_15m FROM atop_samples WHERE $__timeFilter(epoch) ORDER BY epoch", "refId": "A"}],
19+ "targets": [{"format": 1, "query": "SELECT epoch AS time, argMax(load1, ingest_version) AS load_1m, argMax(load5, ingest_version) AS load_5m, argMax(load15, ingest_version) AS load_15m FROM atop_samples WHERE $__timeFilter(epoch) GROUP BY host, epoch ORDER BY epoch", "refId": "A"}],
2020 "title": "Load average",
2121 "type": "timeseries"
2222 },
@@ -26,7 +26,7 @@
2626 "gridPos": {"h": 9, "w": 24, "x": 0, "y": 9},
2727 "id": 3,
2828 "options": {"showHeader": true},
29- "targets": [{"format": 1, "query": "SELECT name AS process, round(avg(cpu_pct), 2) AS avg_cpu_pct, round(max(cpu_pct), 2) AS peak_cpu_pct FROM atop_processes WHERE $__timeFilter(epoch) GROUP BY name ORDER BY avg_cpu_pct DESC LIMIT 15", "refId": "A"}],
29+ "targets": [{"format": 1, "query": "SELECT name AS process, round(avg(cpu_pct), 2) AS avg_cpu_pct, round(max(cpu_pct), 2) AS peak_cpu_pct FROM (SELECT host, epoch, pid, name, argMax(cpu_pct, ingest_version) AS cpu_pct FROM atop_processes WHERE $__timeFilter(epoch) GROUP BY host, epoch, pid, name) GROUP BY name ORDER BY avg_cpu_pct DESC LIMIT 15", "refId": "A"}],
3030 "title": "Top processes",
3131 "type": "table"
3232 }
@@ -7,7 +7,7 @@
7 "fieldConfig": {"defaults": {"unit": "percent"}, "overrides": []},7 "fieldConfig": {"defaults": {"unit": "percent"}, "overrides": []},
8 "gridPos": {"h": 9, "w": 12, "x": 0, "y": 0},8 "gridPos": {"h": 9, "w": 12, "x": 0, "y": 0},
9 "id": 1,9 "id": 1,
10- "targets": [{"format": 1, "query": "SELECT epoch AS time, busy_pct AS busy, wait_pct AS iowait FROM atop_samples WHERE $__timeFilter(epoch) ORDER BY epoch", "refId": "A"}],10+ "targets": [{"format": 1, "query": "SELECT epoch AS time, argMax(busy_pct, ingest_version) AS busy, argMax(wait_pct, ingest_version) AS iowait FROM atop_samples WHERE $__timeFilter(epoch) GROUP BY host, epoch ORDER BY epoch", "refId": "A"}],
11 "title": "CPU busy and I/O wait",11 "title": "CPU busy and I/O wait",
12 "type": "timeseries"12 "type": "timeseries"
13 },13 },
@@ -16,7 +16,7 @@
16 "fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},16 "fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
17 "gridPos": {"h": 9, "w": 12, "x": 12, "y": 0},17 "gridPos": {"h": 9, "w": 12, "x": 12, "y": 0},
18 "id": 2,18 "id": 2,
19- "targets": [{"format": 1, "query": "SELECT epoch AS time, load1 AS load_1m, load5 AS load_5m, load15 AS load_15m FROM atop_samples WHERE $__timeFilter(epoch) ORDER BY epoch", "refId": "A"}],19+ "targets": [{"format": 1, "query": "SELECT epoch AS time, argMax(load1, ingest_version) AS load_1m, argMax(load5, ingest_version) AS load_5m, argMax(load15, ingest_version) AS load_15m FROM atop_samples WHERE $__timeFilter(epoch) GROUP BY host, epoch ORDER BY epoch", "refId": "A"}],
20 "title": "Load average",20 "title": "Load average",
21 "type": "timeseries"21 "type": "timeseries"
22 },22 },
@@ -26,7 +26,7 @@
26 "gridPos": {"h": 9, "w": 24, "x": 0, "y": 9},26 "gridPos": {"h": 9, "w": 24, "x": 0, "y": 9},
27 "id": 3,27 "id": 3,
28 "options": {"showHeader": true},28 "options": {"showHeader": true},
29- "targets": [{"format": 1, "query": "SELECT name AS process, round(avg(cpu_pct), 2) AS avg_cpu_pct, round(max(cpu_pct), 2) AS peak_cpu_pct FROM atop_processes WHERE $__timeFilter(epoch) GROUP BY name ORDER BY avg_cpu_pct DESC LIMIT 15", "refId": "A"}],29+ "targets": [{"format": 1, "query": "SELECT name AS process, round(avg(cpu_pct), 2) AS avg_cpu_pct, round(max(cpu_pct), 2) AS peak_cpu_pct FROM (SELECT host, epoch, pid, name, argMax(cpu_pct, ingest_version) AS cpu_pct FROM atop_processes WHERE $__timeFilter(epoch) GROUP BY host, epoch, pid, name) GROUP BY name ORDER BY avg_cpu_pct DESC LIMIT 15", "refId": "A"}],
30 "title": "Top processes",30 "title": "Top processes",
31 "type": "table"31 "type": "table"
32 }32 }
modified justfile +4 -0
@@ -23,6 +23,10 @@ test-today:
2323 logs:
2424 journalctl -u atop-modal-export.service --no-pager -n 100
2525
26+# Inspect recent logs emitted by the remote Modal ingestion function.
27+logs-modal:
28+ modal app logs atop-analyze --since 24h --tail 100 --timestamps
29+
2630 # Show the Modal app's local-entrypoint arguments.
2731 help:
2832 modal run modal_atop_analyze.py --help
@@ -23,6 +23,10 @@ test-today:
23 logs:23 logs:
24 journalctl -u atop-modal-export.service --no-pager -n 10024 journalctl -u atop-modal-export.service --no-pager -n 100
25 25
26+# Inspect recent logs emitted by the remote Modal ingestion function.
27+logs-modal:
28+ modal app logs atop-analyze --since 24h --tail 100 --timestamps
29+
26 # Show the Modal app's local-entrypoint arguments.30 # Show the Modal app's local-entrypoint arguments.
27 help:31 help:
28 modal run modal_atop_analyze.py --help32 modal run modal_atop_analyze.py --help
modified modal_grafana.py +20 -5
@@ -54,7 +54,7 @@ image = (
5454 },
5555 )
5656 @modal.concurrent(max_inputs=100)
57-@modal.web_server(3000, startup_timeout=120)
57+@modal.web_server(8080, startup_timeout=120)
5858 def grafana() -> None:
5959 """Run a public Grafana login page; its data-source credentials stay server-side."""
6060 process = subprocess.Popen(
@@ -63,15 +63,30 @@ def grafana() -> None:
6363 # environment, since the web-server proxy reaches the service over IPv4.
6464 env={**os.environ, "GF_SERVER_HTTP_ADDR": "0.0.0.0"},
6565 )
66- # Grafana takes a few seconds to bind. Do not return from the Modal web
67- # function until its port is accepting connections; otherwise the proxy can
68- # route the first request before a backend exists and leave it hanging.
66+ # Start the external listener only after Grafana accepts connections.
6967 for _ in range(60):
7068 if process.poll() is not None:
7169 raise RuntimeError(f"Grafana exited during startup ({process.returncode})")
7270 with socket.socket() as probe:
7371 probe.settimeout(0.25)
7472 if probe.connect_ex(("127.0.0.1", 3000)) == 0:
73+ print("Grafana backend ready on port 3000", flush=True)
74+ break
75+ time.sleep(0.5)
76+ else:
77+ raise RuntimeError("Grafana did not listen on port 3000 within 30 seconds")
78+
79+ proxy = subprocess.Popen([
80+ "socat", "TCP-LISTEN:8080,bind=0.0.0.0,reuseaddr,fork",
81+ "TCP:127.0.0.1:3000",
82+ ])
83+ for _ in range(20):
84+ if proxy.poll() is not None:
85+ raise RuntimeError(f"Grafana proxy exited during startup ({proxy.returncode})")
86+ with socket.socket() as probe:
87+ probe.settimeout(0.25)
88+ if probe.connect_ex(("127.0.0.1", 8080)) == 0:
89+ print("Grafana proxy ready on port 8080", flush=True)
7590 return
7691 time.sleep(0.5)
77- raise RuntimeError("Grafana did not listen on port 3000 within 30 seconds")
92+ raise RuntimeError("Grafana proxy did not listen on port 8080 within 10 seconds")
@@ -54,7 +54,7 @@ image = (
54 },54 },
55 )55 )
56 @modal.concurrent(max_inputs=100)56 @modal.concurrent(max_inputs=100)
57-@modal.web_server(3000, startup_timeout=120)57+@modal.web_server(8080, startup_timeout=120)
58 def grafana() -> None:58 def grafana() -> None:
59 """Run a public Grafana login page; its data-source credentials stay server-side."""59 """Run a public Grafana login page; its data-source credentials stay server-side."""
60 process = subprocess.Popen(60 process = subprocess.Popen(
@@ -63,15 +63,30 @@ def grafana() -> None:
63 # environment, since the web-server proxy reaches the service over IPv4.63 # environment, since the web-server proxy reaches the service over IPv4.
64 env={**os.environ, "GF_SERVER_HTTP_ADDR": "0.0.0.0"},64 env={**os.environ, "GF_SERVER_HTTP_ADDR": "0.0.0.0"},
65 )65 )
66- # Grafana takes a few seconds to bind. Do not return from the Modal web66+ # Start the external listener only after Grafana accepts connections.
67- # function until its port is accepting connections; otherwise the proxy can
68- # route the first request before a backend exists and leave it hanging.
69 for _ in range(60):67 for _ in range(60):
70 if process.poll() is not None:68 if process.poll() is not None:
71 raise RuntimeError(f"Grafana exited during startup ({process.returncode})")69 raise RuntimeError(f"Grafana exited during startup ({process.returncode})")
72 with socket.socket() as probe:70 with socket.socket() as probe:
73 probe.settimeout(0.25)71 probe.settimeout(0.25)
74 if probe.connect_ex(("127.0.0.1", 3000)) == 0:72 if probe.connect_ex(("127.0.0.1", 3000)) == 0:
73+ print("Grafana backend ready on port 3000", flush=True)
74+ break
75+ time.sleep(0.5)
76+ else:
77+ raise RuntimeError("Grafana did not listen on port 3000 within 30 seconds")
78+
79+ proxy = subprocess.Popen([
80+ "socat", "TCP-LISTEN:8080,bind=0.0.0.0,reuseaddr,fork",
81+ "TCP:127.0.0.1:3000",
82+ ])
83+ for _ in range(20):
84+ if proxy.poll() is not None:
85+ raise RuntimeError(f"Grafana proxy exited during startup ({proxy.returncode})")
86+ with socket.socket() as probe:
87+ probe.settimeout(0.25)
88+ if probe.connect_ex(("127.0.0.1", 8080)) == 0:
89+ print("Grafana proxy ready on port 8080", flush=True)
75 return90 return
76 time.sleep(0.5)91 time.sleep(0.5)
77- raise RuntimeError("Grafana did not listen on port 3000 within 30 seconds")92+ raise RuntimeError("Grafana proxy did not listen on port 8080 within 10 seconds")