ClickStack scalable rebuild: replicated ClickHouse + telemetry-pipeline hardening
- Date: 2026-07-05
- Bead: hl-uj69 (design); supersedes the upstream-PR framing of hl-p7z
- Status: Approved
Problem
Section titled “Problem”The 2026-07-05 disk-full incident (bd hl-z93i, PR #1604) exposed four systemic weaknesses in the observability stack, beyond the immediate 100Gi PVC:
- Single-node ClickHouse is the availability and scale ceiling. The
2026-05 shard experiment failed because HyperDX’s schema migrator creates
plain
MergeTreetables only — sharded queries went shard-local, extra replicas received zero writes (bd hl-p7z). That constraint is unchanged upstream through HyperDX 2.29.0 (ClickStack-helm-charts#92, dormant since 2025-08). - The collector’s OOM protection cannot work as deployed. The ClickStack
image ships
memory_limiter: limit_mib: 1500— sized for a 2Gi container — while ours runs a 1Gi limit, so the limiter sits above the cgroup OOM threshold and never engages. A ClickHouse write outage therefore OOM-crashloops the collector (188 restarts during the incident) instead of buffering or shedding load. - The stack amplifies its own failures. ClickHouse logs a ~20-frame stack
trace per failed insert; Vector ships
clickhouse-serverlogs back into ClickHouse unfiltered and un-throttled;system.text_log(25.9 GiB, no TTL) additionally re-stores every log line in-table. - Backups are the wrong tool for this data. Velero captures the 200Gi CH
volume via Longhorn CSI snapshots + kopia data-mover
(
snapshotMoveData: trueinargocd/app-configs/velero/backup-schedule.yaml) — crash-consistent at best, never restore-tested, requiring manual Keeper reconciliation (SYSTEM RESTORE REPLICA) to recover Replicated tables, and churning 200Gi of disposable telemetry to R2 daily. The state that actually matters (HyperDX dashboards/alerts/sources) lives in MongoDB.
A dashboard OOM class rides along: 7-day histogram-rate charts peak ~1.7 GiB against a 3.6 GiB server-wide memory cap (0.9 x 4Gi pod limit) shared with ingest — the per-query spill valve never triggers because the server total exhausts first.
Telemetry data is declared disposable for this program: the design optimizes for the end state, not for preserving history.
Grounding
Section titled “Grounding”Four-agent research sweep 2026-07-05 (traces on bd hl-uj69; durable records in
engram, tags research-2026-07-05):
- Auto-conversion disproven at source level: ClickHouse OSS
Replicateddatabase engine replicates metadata only; it never rewritesMergeTree -> ReplicatedMergeTree.database_replicated_allow_only_replicated_enginethrows (guard, not converter);default_table_engineapplies only when the ENGINE clause is absent (HyperDX seeds declare it);convert_to_replicatedis Atomic-database-only. - Split-brain hazard:
replicas > 1with the stock migrator creates tables everywhere (metadata DDL propagation) but replicates no data — round-robin Services split rows into disjoint per-replica subsets (Langfuse#12589 is the same failure class). - 1 shard x N replicas needs no Distributed table and no HyperDX changes. HyperDX 2.28/2.29’s Distributed-table read support targets sharded setups only. Community precedent: ClickStack-helm-charts#92 user and a published Altinity-operator walkthrough, both with pre-created replicated schemas.
- Seed schema churn ~2 commits/month, new tables ~2/year; upstream cannot
retrofit existing tables either (
IF NOT EXISTS), so all long-lived deployments drift. Disposable data permits drop-and-rebootstrap adoption. - No OSS shared storage: zero-copy replication is officially “not ready
for production”;
s3_plain_rewritablecannot replicate; SharedMergeTree is Cloud-only. Hot path = replicated local volumes. - RustFS caution: zero ClickHouse compatibility reports; known S3-semantics gaps (rustfs#2540, #2079, #2607). Suitable as a bounded backup target only, never as a live S3 data disk.
- Operator verified live: official ClickHouse operator
(
clickhouse.com/v1alpha1,ClickHouseCluster/KeeperCluster) propagatesdataVolumeClaimSpecgrowth to existing PVCs; Longhorn expands online (proven during incident recovery, PR #1604). - Per-replica StatefulSets with preserved volumeClaimTemplates (deepwiki
vs
ClickHouse/clickhouse-operator, confirmed independently in design review round 3): the operator creates one single-pod StatefulSet per replica and preserves each replica’s PVC spec on reconcile — which is why changingdataVolumeClaimSpec.storageClassNameaffects only new replicas’ PVCs (Workstream 2 item 4) while replica-0’s existing PVC keeps its class until recreated. - Replicated database engine verified live (2026-07-05,
system.databaseson the running cluster):defaultis alreadyReplicated('/clickhouse/databases/default', '{shard}', '{replica}')— the operator’senableDatabaseSync(set in our CR) did this at deploy time. Workstream 2’s “new replica receives schema via metadata sync” therefore rests on observed state, not assumed operator behavior. (Tables inside it remain plainMergeTree; that is exactly the gap the bootstrap Job closes.) - Citations verified against GitHub (2026-07-05,
gh api): langfuse#12589 “ClickHouse schema migrations are flaky with Replicated database”; ClickStack-helm-charts#92 “Improvements needed for external MongoDB, OpenTelemetry metrics, and ClickHouse schema support”; hyperdx PR #2351 “Allow CUSTOM_OTELCOL_CONFIG_FILE to override default processors”; hyperdx PR #2545 “fix: change metrics PK for better memory usage”.
Decisions
Section titled “Decisions”| Decision | Choice |
|---|---|
| Scope | One rebuild-centered program absorbing interim fixes and hygiene |
| Cutover model | Tables-only greenfield: keep namespace, MongoDB, URLs; drop/recreate CH tables |
| Topology | 1 shard x 2 replicas, KeeperCluster 1 -> 3 |
| CH data storage | New longhorn-single StorageClass (numberOfReplicas: 1); Keeper stays on standard longhorn |
| Retention | logs 14d / metrics 30d / traces 14d / sessions 14d; system.* logs ~7d |
| Backups | Velero excludes CH data PVCs; MongoDB stays Velero-covered; weekly native BACKUP DATABASE ... TO S3(RustFS) + daily incrementals, restore rehearsal as acceptance gate |
| Merge sequencing | Three PRs: PR1 values-only hardening, PR2 chart+topology — each its own ArgoCD sync with validation between — then PR3 (bootstrap Job + collector-user guard + Velero exclusion) merged at the start of the cutover window |
Non-goals
Section titled “Non-goals”- Sharding (two orders of magnitude below the workload threshold; wrong for arm64 boards).
- S3-tiered hot storage (fights replication 2x; RustFS unqualified). Revisit post-qualification if long retention becomes a goal.
- Swapping to the Altinity operator (official operator just proved its key day-2 op; alpha API is a watch-item, not a blocker).
- Preserving telemetry history across the cutover.
Architecture
Section titled “Architecture” otel-node DaemonSet (metrics) Vector DaemonSet (logs) \ | throttle: clickstack ns v v OTLP clients ---> cs-otel-collector (2Gi, memory_limiter effective, disk-backed bounded export queue) | \__________________________| v ClusterIP Service (round-robin) / \ CH replica 0-0-0 CH replica 0-1-0 (200Gi longhorn-single) (200Gi longhorn-single) \ ReplicatedMergeTree sync / \____ KeeperCluster x3 _____/ ^ bootstrap Job (PreSync hook): Replicated schema, per-signal TTLs; migrator seeds no-op via IF NOT EXISTS- Writes: any replica accepts inserts (async replication,
insert_quorum=0— correct trade for telemetry). Exporterasync_insertdisabled (Workstream 1 item 2) to restore idempotent block dedup on retries. Note the two writers use different Service objects: the collector targets the round-robin ClusterIP Service; Vector targets the headless Service (cs-clickstack-clickhouse-clickhouse-headless...) and resolves pod IPs directly. Correctness is unaffected — ReplicatedMergeTree replicates regardless of which pod receives an insert — but validation steps must not assume a single shared write path. - Reads: HyperDX -> same Service. Contingency for live-tail flicker from
replica lag:
sessionAffinity: ClientIPon a HyperDX-facing Service. - Guard:
database_replicated_allow_only_replicated_engine=1on theotelcollectoruser profile. A future upstream seed adding a new table fails loudly (collector migration crashloop) instead of silently creating a plain table with round-robin-split data. Drift alert onsystem.tables WHERE engine = 'MergeTree' AND database = 'default'as belt-and-suspenders. - Documented exception:
metrics_ts(TimeSeries engine, experimental PromQL path). Its internal DATA/TAGS/METRICS target tables can use Replicated-family engines, but TimeSeries DDL does not round-trip cleanly through the Replicated database engine’s DDL mechanism — so the bootstrap pre-creates it as-is under an admin user and the guard never evaluates it. Its data splits across replicas; acceptable while the PromQL feature is unused.
Workstream 1 — PR1: values-only hardening (one sync, no interaction risk)
Section titled “Workstream 1 — PR1: values-only hardening (one sync, no interaction risk)”All in argocd/app-configs/clickstack-chart/values.yaml +
argocd/app-configs/clickstack/ + argocd/app-configs/monitoring-vector/:
-
Collector memory 1Gi -> 2Gi (request 1Gi / limit 2Gi). This makes the shipped
memory_limiter(1500 MiB, designed for 2Gi) correct as-is — less config than re-tuning limits. -
CUSTOM_OTELCOL_CONFIG_FILEplumbing: ConfigMap mounted viaotel-collector.extraVolumes/extraVolumeMounts, env viaextraEnvs. Contents (all leaf-level additions that survive the koanf key merge under the 2.27 OpAMP remote config):extensions.file_storage/queue(emptyDir at/var/lib/otelcol/queue);exporters.clickhouse.sending_queueandexporters.clickhouse/rrweb.sending_queue:{sizer: items, queue_size: 50000, block_on_overflow: true, storage: file_storage/queue};exporters.clickhouse.async_insert: false(likewise forclickhouse/rrweb) — the contrib exporter defaults this on, and async inserts bypass ReplicatedMergeTree’s idempotent block dedup; disabling it is what makes retry-after-timeout non-duplicating (the Architecture section depends on this key);processors.batch.send_batch_max_size: 10000— caps single-request memory. NoHYPERDX_OTEL_BATCH_*env vars exist upstream (the collector consumes exactly threeHYPERDX_OTEL_*vars, all schema-related), so this cap MUST live in the config file, not in env.
Re-verify the effective config after the PR2 image bump (2.28 moved pipeline processor lists local — these leaf additions remain valid).
-
ClickHouse
containerTemplate.resources: 2/4Gi -> 4Gi request / 8Gi limit (fits 16GB beta workers; fixes the dashboard-OOM class together with item 4). -
clickhouse.cluster.spec.settings.extraUsersConfig(full path — this chart silently drops values under wrong paths, the exact failure class documented at values.yaml lines 65-72):appuser profilemax_memory_usage: 2147483648(2 GiB) so dashboard queries spill or fail alone instead of exhausting the server-wide cap. -
clickhouse.cluster.spec.settings.extraConfig(same full-path caution): TTLs onsystemlog tables (text_log,query_log,part_log,trace_log,metric_log) at ~7 days. -
Disable the unused, unauthenticated fluentd receiver port (24225) — no consumer exists in this cluster.
-
Vector:
throttletransform onkubernetes_logs, keyed per-container, ~100 events/s for theclickstacknamespace (breaks the self-amplification loop independently of the collector).
Workstream 2 — PR2: chart bump + topology (one sync)
Section titled “Workstream 2 — PR2: chart bump + topology (one sync)”- Prerequisite — delete the four orphaned 100Gi PVCs
(
...clickhouse-0-1-0/0-2-0/1-0-0/2-0-0, relics of the 2026-05 shard experiment). Critical, not hygiene: the operator derives PVC names from shard/replica indices, so the new replica in step 4 would silently adopt the stale0-1-0volume — wrong StorageClass, old experiment data. - Chart 3.0.0 -> 3.0.1 (app + collector 2.29.0): follow the render-diff
protocol in
argocd/CLAUDE.md; drop the 2.27.0 collector image-tag override (upstream #219 fixed); diffdocker/otel-collector/schema/seed/between 2.27.0 and 2.29.0 tags to refresh the bootstrap DDL source (new metrics PK #2545, rollup + timeseries tables). longhorn-singleStorageClass (numberOfReplicas: "1",allowVolumeExpansion: true) — added toargocd/app-configs/shared-resources/longhorn-storage-classes.yaml, alongside the existinglonghorn-retainclass.KeeperCluster1 -> 3 replicas (raft membership change; the riskiest single op in this PR — validate quorum health post-sync before proceeding).ClickHouseClusterreplicas: 1 -> 2;dataVolumeClaimSpec.storageClassName: longhorn-single(affects the new replica’s PVC only; replica-0’s class is immutable and is handled in the cutover window).
Post-sync state: replica 0-1-0 runs with schema but no data — a bounded,
known split-data window; hold the cutover to the same day. The schema arrives
via Replicated-database metadata sync, which is grounded in observed state
(see Grounding: default is already Replicated(...) engine on the live
cluster), not assumed operator behavior.
Workstream 3 — PR3 + cutover window (minutes, telemetry-history reset)
Section titled “Workstream 3 — PR3 + cutover window (minutes, telemetry-history reset)”The cutover changes are their own merge, PR3, landed at the start of the
maintenance window: the bootstrap Job manifest, the otelcollector-user
guard, and the Velero exclusion from Workstream 4 item 2 (it must be active
before the new empty tables start filling).
Bootstrap Job (new, in argocd/app-configs/clickstack/): ArgoCD
PreSync hook with
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation. The
delete-policy has repo precedent
(argocd/app-configs/clickstack-alerts/bootstrap-job.yaml and the keycloak
dcr-trusted-hosts job use BeforeHookCreation; nats uses HookSucceeded)
and is what lets the hook re-fire on every sync — but the
PreSync phase is a novel pattern in this repo: all three existing hooks
are PostSync. PreSync is nonetheless required, not preference: the collector’s
migrator runs at pod start, so on any future sync that rolls the collector
(e.g. a chart bump shipping a new seed table) the Job must complete before
the new pod starts. A PostSync hook would deadlock — the guard makes the new
pod crashloop on the missing table, the app never reports healthy, and the
PostSync hook that would create the table never fires. Because the
clickstack Application is multi-source (chart-inflated + plain
kustomize) and hook ordering across multi-source apps is unexercised in this
repo, PreSync ordering gets its own pre-cutover validation (see Validation).
clickhouse/clickhouse-server image running clickhouse-client against one
replica with admin credentials from clickstack-secret. DDL = the 3.0.1
image’s seed SQL verbatim with:
ENGINE = MergeTree->ENGINE = ReplicatedMergeTree(andSummingMergeTree->ReplicatedSummingMergeTreefor rollups) — no engine arguments (Replicated DB{uuid}-based defaults; explicit paths are rejected by default anyway).- Per-signal TTLs baked in: logs 14d, metrics tables 30d, traces 14d, sessions 14d.
- Materialized views unchanged (TO-table MVs store no data).
metrics_tscreated as-is (see exception above).- Everything
IF NOT EXISTS— the Job is idempotent and safe on every sync; the collector’s own migrator then no-ops forever.
Runbook:
- Scale
cs-otel-collectordeployment to 0 (Vector keeps buffering on disk; OTLP clients retry). DROP TABLE ... SYNCallotel_*, rollup, MV, andhyperdx_sessionstables on the Replicated database (propagates to both replicas).- Trigger the bootstrap Job; verify
system.replicaslists all tables on both replicas. - Delete replica-0’s PVC + pod: recreated on
longhorn-single; empty tables sync trivially from replica 1. - Confirm the
otelcollector-user guard (database_replicated_allow_only_replicated_engine=1, set viaclickhouse.cluster.spec.settings.extraUsersConfigin PR3) is live before the collector returns. - Scale the collector back up; watch ingest resume on both replicas.
Vector needs no change (same Service, same user, INSERT-only). HyperDX sources reference unchanged table names; dashboards and alerts continue working against empty-then-refilling tables.
Workstream 4 — Post-cutover: backups, safety nets, hygiene
Section titled “Workstream 4 — Post-cutover: backups, safety nets, hygiene”- CH backups: weekly CronJob
BACKUP DATABASE default TO S3('https://s3.nas.fzymgc.house/<bucket>/clickstack-backups', ...)+ daily incrementals viabase_backup+use_same_s3_credentials_for_base_backup. RustFS qualification settings: path-style endpoint,support_batch_delete: falseinitially,s3_check_objects_after_upload: 1. Acceptance gate: one rehearsedRESTOREinto a scratch database. Documented fallback if RustFS fails qualification: drop CH backups entirely (replication is the availability story) and re-evaluate with MinIO/Garage. - Velero (manifest change rides in PR3 so it is active before the new
tables fill; listed here as it is operationally a backup concern): exclude
CH data volumes via a Velero resource-policies ConfigMap with a volume
policy
storageClasses: [longhorn-single] -> action: skip, referenced by both Schedules (resourcePolicy). Keying on the CH-only StorageClass is self-maintaining — it survives PVC recreation, unlike per-PVC labels. MongoDB volumes stay covered; optionalmongodumpCronJob for a logical dashboard backup. - Watchdog (drift detector + dead-man’s switch, one mechanism): a
CronJob (every 5 min) checks (a) ingest freshness (
max(TimeUnix)staleness) and (b) engine drift (system.tables WHERE engine IN ('MergeTree','SummingMergeTree') AND database='default'), and pushes an uptime-kuma heartbeat only when both pass; uptime-kuma (already pushover-wired) alerts on missed heartbeats. Rationale for implementing drift detection here rather than as a ClickStack tile alert (amended during plan-writing): an alert inside the stack cannot fire when the stack is dark — the external watchdog covers both failure modes with one moving part. - Optional: collector
replicaCount: 2+podDisruptionBudget(CPU-only HPA if ever; never memory-based for queue-driven collectors).
Risks and rollback
Section titled “Risks and rollback”| Risk | Mitigation |
|---|---|
| Seed churn on future chart bumps desyncs bootstrap DDL | Diff schema/seed/ per upgrade (release checklist); the guard turns a missed new table into a visible collector crashloop, never silent split data; disposable data permits drop-and-rebootstrap |
| Keeper 1->3 membership change wedges DDL | Own sync (PR2), validated before cutover; rollback = revert to 1 replica |
| Live-tail flicker from replica lag | sessionAffinity: ClientIP contingency on HyperDX’s read path |
| RustFS backup qualification fails | Documented fallback: no CH backups; MinIO/Garage re-evaluation |
| Chart 3.0.1 remote-config changes interact with custom collector config | PR1 validated under 2.27 first; effective-config re-verified after PR2 (HyperDX collector page) |
metrics_ts guard exception confuses future readers | Documented here and inline in the bootstrap DDL |
| PreSync hook is novel in this repo + multi-source Application hook ordering unexercised | Validated on a benign sync pre-cutover (Validation section); fallback: run the DDL manually via clickhouse-client in the window and demote the Job to PostSync-with-documented-caveat |
| Community-thin precedent for this exact HyperDX topology | Validation phase exercises every read/write path before the old PVC is reclaimed; rollback below |
Rollback: PR1/PR2 are git reverts (ArgoCD auto-syncs back). Cutover worst
case: re-drop tables, re-run bootstrap with plain engines at replicas: 1 —
cost is telemetry history, already written off.
Validation
Section titled “Validation”- PreSync hook ordering in the multi-source Application verified on a benign sync before the cutover relies on it: the Job runs to completion before Sync-phase resources (collector Deployment) apply. First PreSync hook in this repo — do not assume.
- Collector effective config (HyperDX collector page / OpAMP view) shows the
custom-file keys applied:
sending_queue(withstorage),async_insert: false,send_batch_max_size— checked after PR1 and re-checked after the PR2 image bump (2.27 -> 2.29 merge-semantics change). system.replicas: all otel tables present on both replicas,queue_size~0, nois_readonly.- Row-count convergence checked per-pod (not via the Service) after 15 minutes of ingest.
- Pod-kill test: delete one CH pod; ingest and dashboards continue; replica rejoins and catches up.
- Dashboard smoke: the 7d histogram chart that OOM’d renders under the 2 GiB app-user cap.
- Alert smoke: one tile alert fires end-to-end (pushover).
- Backup rehearsal:
RESTOREinto scratch DB from RustFS succeeds. - Collector resilience re-test: brief CH write outage (readonly toggle) shows queue-to-disk buffering, no OOM, no restart.
References
Section titled “References”- Beads: hl-uj69 (this design), hl-z93i (incident, closed), hl-p7z (updated with research), ClickStack-helm-charts#92, hyperdx PR #2351/#2545.
- Engram: memories tagged
research-2026-07-05,incident-2026-07-05(mechanism details, collector config merge semantics, operator PVC-resize proof, RustFS evidence). - PRs: #1604 (PVC expansion, merged).