I run a small self-hosted observability tool on the cheapest VPS I could find on purpose: 2 cores, 2 GB RAM, 20 GB SATA SSD. It ingests errors, traces and metrics from two low-traffic sites of mine. The stack is three containers — a Go app, PostgreSQL, and ClickHouse.
One evening docker stats showed ClickHouse sitting on 880 MB of its 1 GB limit and the box swapping, with basically zero events coming in. So I went looking for where the memory and disk had gone. The answer turned out to be a good lesson in how a database can spend almost all of its I/O talking to itself.
543 KB of my data, 579 MB of ClickHouse talking about ClickHouse
First thing I checked: how much data had my app actually stored versus how much ClickHouse had stored about itself.
- My application database: 543 KB, 16k rows
- The
systemdatabase: 579 MB, 46.3M rows
Roughly a thousand to one. Disk was 12 GB used out of 20 — on a tool that had recorded half a megabyte of real telemetry.
The culprit was ClickHouse's own system logs, several of which have no TTL by default and therefore grow forever:
-
trace_log— 404 MB, 26M rows (the query profiler writes here; it's on by default, sampling once per second) -
asynchronous_metric_log— 16.6M rows -
text_log— 132 MB - plus
query_log,latency_log
Only metric_log, processors_profile_log and part_log ship with a TTL. Everything else just accumulates.
Then I looked at the insert rate over 30 seconds:
-
trace_log— 227 rows/s -
asynchronous_metric_log— 157 rows/s -
text_log— 44 rows/s - my application — about 5 rows/s
98.8% of all inserts were ClickHouse narrating its own internals.
The part that's expensive beyond disk
Here's the number that made me stop. Over the same 30 seconds:
- rows inserted: 16,222
- rows merged: 11,007,643
That's a 1 : 678 ratio. For every row written, the engine rewrote 678 already-sitting rows.
The mechanics: MergeTree drops every insert into its own data part, then merges parts into bigger ones so reads stay fast. When the table is small this is cheap. But when a table holds 26M rows and the inserts are tiny and constant, each successive merge drags along more and more already-written data. In the limit, the engine spends most of its I/O shuffling old rows around, not storing new ones. That was my "60% disk busy, idle app" mystery.
The hypothesis that was wrong
I was sure I had it: the bloated trace_log drives the merges, the merges burn CPU, and the profiler samples the merge threads too — a nice closed loop. Test: measure CPU, TRUNCATE TABLE system.trace_log, measure again.
CPU didn't drop. ~69% before, ~81% after.
Honest caveat: my "after" window opened 60 seconds after deleting 400 MB, and part cleanup itself loads the machine, so some of that rise could be the TRUNCATE. But the point stood — one table didn't explain it.
So I disabled the heavy logs entirely and re-measured. Merges collapsed:
- merged rows / 30s: 11,007,643 → 5,727
- inserted rows / 30s: 16,222 → 35
- disk: 2.7 GB freed out of 20
And CPU… barely moved: 69% → 61%.
The conclusion I had to accept: the system logs were a real disk and I/O problem, but they were not the CPU cause. Two separate symptoms I'd carelessly glued into one.
The trap I set for myself
While writing this up I'd claimed ClickHouse was "eating half the machine." It wasn't, and the mistake is a common one: docker stats reports CPU as a percentage of one core, not the whole machine. 60% in docker stats on a 2-core box is ~30% of the box. ps on the host confirmed it: 51.4% across two cores. If you debug container load, always cross-check against the host — it's easy to double your own problem on paper.
What actually moved the needle
Disabling the heavy logs, plus tuning for a small machine:
-
asynchronous_metrics_update_period_s: 1 → 60 -
metric_log: collect 1s → 30s, flush 7.5s → 60s, 3-day TTL - background pools:
background_schedule_pool_size128 → 8 (the default targets many-core servers),background_pool_size4, the rest at 2 -
mark_cache_size: 5 GiB → 256 MiB. Yes — the default mark cache is five times the whole container's memory limit.
Result, averaged over 15 samples:
| Before | After | |
|---|---|---|
| Container memory | 842–920 MB | 256 MB |
| Host memory used | 1043 MB | 779 MB |
| Load average | 1.15 | 0.79 |
| Disk used | 12 GB | 9.2 GB |
The big win is memory: −66%. ClickHouse had been living at 85–90% of its cgroup limit and would OOM on any spike; now it sits at ~25% with real headroom. The mark cache is most of that.
The mistake that took prod down
Worth telling, because it's the one that stings. I applied the first tuning config straight to the live server without testing it locally. ClickHouse runs a sanity check at startup: number_of_free_entries_in_pool_to_execute_mutation (default 20) must not exceed background_pool_size × background_merges_mutations_concurrency_ratio. I'd set background_pool_size=4, the product was 8, the check failed → BAD_ARGUMENTS → the server refused to start. Monitoring was down for a few minutes.
The bug wasn't the number. It was the order of operations. The fix — which is also how I recovered — is to boot a throwaway container of the same version with the config locally, confirm it starts, then ship. It takes thirty seconds, which is less than a prod rollback. The rollback, thankfully, was clean: a .bak next to the file, restored in 8 seconds.
Don't tune your defaults down to the minimum
When I brought these settings into the project repo, someone reasonably asked: won't they hurt a 10-core / 10 GB server? Yes, they would. I checked all eleven settings; only three are universal:
-
random_page_cost=1.1andeffective_io_concurrency=200— those are facts about SSDs, not about machine size - a TTL on the system logs — unbounded growth is bad on any hardware
The other eight actively hurt a big box: background_pool_size=4 caps merge parallelism and invites "too many parts", a shrunken mark_cache_size adds disk reads, shared_buffers=64MB is absurd at 10 GB, and so on. So the config ended up in two layers — a base compose file with only the universal settings, and an opt-in overlay for constrained machines:
docker compose -f docker-compose.yml -f docker-compose.small.yml up -d
Enter fullscreen mode Exit fullscreen mode
The rule I took away: if a setting is proportional to resources, it doesn't belong in your default config. Defaults should only hold what's true on 2 cores and on 20.
Takeaway
None of this is really about my project — it's about stock database defaults. ClickHouse out of the box assumes it has many cores, plenty of RAM, and that nobody's counting disk. When that isn't true, you can hand back a couple of gigabytes of disk and two-thirds of your memory with one config file. Just test it locally first.
The tool I was measuring is github.com/OtezVikentiy/gotcha — Go, self-hosted, speaks the Sentry SDK protocol and OTLP. Everything above is reproducible: the configs are in the repo and the box is described up top.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.