# Self-host Observability ด้วย ClickStack (ClickHouse + HyperDX + OTel)

Table of Contents

ทำไมต้อง ClickStack

โดยปกติถ้าจะทำ observability ครบ 3 อย่าง (logs / metrics / traces) เรามักจบที่ Grafana LGTM stack ซึ่งดีมาก แต่ก็มี component เยอะ — Loki สำหรับ log, Mimir สำหรับ metric, Tempo สำหรับ trace, แล้วก็ Grafana เป็นหน้าจอ ทุกตัวต้องดูแล scale และ tune แยกกัน

ClickStack เลือกอีกแนวทางหนึ่ง — ใช้ ClickHouse เป็น storage engine เดียวสำหรับทั้ง 3 signal เพราะ ClickHouse เป็น columnar database ที่ query ข้อมูลปริมาณมหาศาลได้เร็วมาก เหมาะกับ telemetry ที่เป็น append-heavy และต้อง aggregate บ่อย

Stack ที่ผม deploy ประกอบด้วย 3 ส่วนหลัก:

  • ClickHouse — เก็บ logs, metrics, traces ทั้งหมด (columnar storage)
  • HyperDX — UI สำหรับ search / explore / dashboard / alert บน ClickHouse
  • OpenTelemetry Collector — รับ telemetry จากทุกที่แล้วเขียนลง ClickHouse

ภาพรวมทั้งระบบ — telemetry ไหลจากซ้ายเข้า ClickHouse แล้วมีสอง UI อ่านออกไป ส่วนข้างใน ClickHouse เองก็แบ่ง storage เป็น hot/cold (จุดที่เป็นบทเรียนของโพสต์นี้):

ClickStack architecture: ingest → ClickHouse (hot/cold) → HyperDX + Portal

Deploy บน AWS — เริ่มด้วย S3 อย่างเดียว

ผม deploy ClickStack ไว้บน AWS โดยตอนแรกตั้งใจให้ ClickHouse ใช้ S3 เป็น storage backend อย่างเดียว — ไม่เก็บ data ลง local disk เลย คิดว่าจะได้ไม่ต้องห่วงเรื่อง disk เต็มและ scale พื้นที่ได้ไม่จำกัด (สปอยล์: นี่แหละจุดที่ผมพลาด เดี๋ยวเล่าตอนท้าย)

docker-compose.yml (ตัดให้สั้น)
services:
clickhouse:
image: clickhouse/clickhouse-server:24.8
volumes:
- ./clickhouse/config.d:/etc/clickhouse-server/config.d # storage policy ชี้ไป S3
ulimits:
nofile: { soft: 262144, hard: 262144 }
hyperdx:
image: hyperdx/hyperdx:latest
depends_on: [clickhouse, mongo]
ports:
- '8080:8080'
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ['--config=/etc/otel/config.yaml']
volumes:
- ./otel-config.yaml:/etc/otel/config.yaml
ports:
- '4317:4317' # OTLP gRPC
- '4318:4318' # OTLP HTTP
mongo:
image: mongo:4.4 # HyperDX ใช้เก็บ config/dashboard

storage policy ตอนแรกของผมหน้าตาประมาณนี้ — มี disk เดียวคือ S3 แล้วให้ทุก table เขียนลงตรงนั้นเลย:

ตอนแรก: S3 เป็น disk เดียว (config.d/storage.xml)
<storage_configuration>
<disks>
<s3>
<type>s3</type>
<endpoint>https://s3.&lt;region&gt;.amazonaws.com/&lt;bucket&gt;/clickhouse/</endpoint>
</s3>
</disks>
<policies>
<s3_only>
<volumes><main><disk>s3</disk></main></volumes>
</s3_only>
</policies>
</storage_configuration>

ฝั่ง OTel collector ก็เป็น pipeline มาตรฐาน — รับ OTLP เข้ามา, batch, แล้ว export เข้า ClickHouse:

otel-config.yaml (ตัดให้สั้น)
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
processors:
batch: {}
exporters:
clickhouse:
endpoint: tcp://clickhouse:9000
database: otel
service:
pipelines:
logs: { receivers: [otlp], processors: [batch], exporters: [clickhouse] }
metrics: { receivers: [otlp], processors: [batch], exporters: [clickhouse] }
traces: { receivers: [otlp], processors: [batch], exporters: [clickhouse] }

รับ telemetry จากหลาย cluster

พอมี collector กลางแล้ว ก็เหลือแค่ทำให้แต่ละ Kubernetes cluster ส่ง logs / metrics / traces มาที่นี่ ผมใช้ OTel collector แบบ DaemonSet ลงทุก node ในแต่ละ cluster เพื่อเก็บ:

  • logs จาก container ทุกตัวบน node
  • metrics จาก kubelet / node
  • traces ที่แอป export ออกมาเป็น OTLP

แล้ว tag ทุกอย่างด้วย k8s.cluster.name (แต่ละ cluster ตั้งค่าของตัวเอง) เพื่อให้แยกได้ว่า data มาจาก cluster ไหนตอน query รวมที่เดียว:

DaemonSet collector — ใส่ resource attribute
processors:
resource:
attributes:
- key: k8s.cluster.name
value: <cluster-name> # แต่ละ cluster ใส่ชื่อตัวเอง
action: upsert

บทเรียนราคาแพง — บิล S3 บานปลาย

มาถึงเรื่องที่สัญญาไว้ตอนต้นครับ 😅

อย่างที่เล่าไปในตอน deploy — ผมให้ ClickHouse เก็บ data ทั้งหมดบน S3 อย่างเดียว (policy s3_only ด้านบน) เพราะคิดว่าถูกและไม่ต้องห่วงเรื่อง disk เต็ม ฟังดูดีใช่มั้ยครับ — แต่พอบิลมา ผมถึงรู้ว่าพลาด

ปัญหาไม่ได้อยู่ที่ “ปริมาณข้อมูล” แต่อยู่ที่ “จำนวน request”:

  • ClickHouse เขียน data เป็น part เล็กๆ จำนวนมหาศาล
  • พอเก็บทุกอย่างบน S3 ทุก part = object บน S3
  • สุดท้ายมี object เล็กๆ ราว 103,000 ชิ้น และทุกครั้งที่ query/merge ก็ยิง request หา S3 รัวๆ
  • บิล S3 ไม่ได้แพงเพราะ storage แต่แพงเพราะ request count

ทางแก้ — Hot/Cold Tiered Storage

วิธีแก้ที่ผมใช้คือ tiered storage — เพิ่ม local disk บน instance (EBS) กลับเข้ามาเป็น tier hot ให้ข้อมูลใหม่อยู่บนนั้นที่เร็วและไม่มีค่า request ส่วนข้อมูลเก่า (cold) ค่อย move ไป S3 ทีหลัง

หลักการ 3 ส่วน:

  1. Storage policy — แบ่ง disk เป็น 2 tier: hot (local EBS) กับ cold (S3)
  2. Compact parts — tune ให้ ClickHouse เขียน part ใหญ่ขึ้น/merge บ่อยขึ้น ลดจำนวน object เล็กๆ
  3. TTL move — ตั้ง TTL ให้ data ที่เก่ากว่า 3 วัน ย้ายจาก hot ไป cold อัตโนมัติ
storage policy (config.d/storage.xml)
<storage_configuration>
<disks>
<hot><type>local</type><path>/var/lib/clickhouse/hot/</path></hot>
<cold>
<type>s3</type>
<endpoint>https://s3.&lt;region&gt;.amazonaws.com/&lt;bucket&gt;/clickhouse/</endpoint>
</cold>
</disks>
<policies>
<hot_cold>
<volumes>
<hot><disk>hot</disk></hot>
<cold><disk>cold</disk></cold>
</volumes>
</hot_cold>
</policies>
</storage_configuration>
ตั้ง TTL move บน table — 3 วันแล้วย้ายไป cold
ALTER TABLE otel_logs
MODIFY TTL toDateTime(timestamp) + INTERVAL 3 DAY TO VOLUME 'cold';

ผม apply policy นี้กับ otel table ทั้ง 8 ตัวบน production — ข้อมูลใหม่จะ insert ลง local hot tier เป็น compact part ส่วน query ที่ดู data ล่าสุด (ซึ่งคือส่วนใหญ่) ก็วิ่งบน local disk ไม่แตะ S3 เลย ทำให้ทั้ง request ที่ยิงหา S3 และบิลลดลงอย่างเห็นได้ชัด

สรุป

ClickStack เป็น observability stack ที่ “เบากว่า” ในแง่จำนวน component — ClickHouse ตัวเดียวจบทั้ง logs/metrics/traces, HyperDX เป็น UI, OTel collector รวม telemetry ทั้ง cluster เข้ามา และเพราะทุกอย่างเป็น OTLP มาตรฐาน การย้าย backend ในอนาคตก็ไม่เจ็บ

แต่บทเรียนที่อยากฝากไว้คือ — อย่าเอา hot data ไปวางบน object storage ตรงๆ ถ้า workload สร้าง object เล็กๆ เยอะ ทำ hot/cold tiered storage ตั้งแต่แรก แล้วค่อย move data เก่าไป S3 จะประหยัดกว่ามาก ผมจ่ายค่าเรียนรู้ตรงนี้ไปแล้ว เลยอยากให้คุณไม่ต้องจ่าย 😁

Resolve production issues, fast. An open source observability platform unifying session replays, logs, metrics, traces and errors powered by ClickHouse and OpenTelemetry.
9.6K410MITTypeScript
My avatar

Thanks for reading! Feel free to check out my other posts or reach out via the links in the footer.


More Posts

# จาก NetBird สู่ Headscale — ทำไมผมย้าย mesh VPN อีกครั้ง

4 min read

บันทึกการย้ายจาก NetBird มาเป็น Headscale — control plane ของ Tailscale แบบ self-host, ACL เป็น code ที่อยู่ใน git, และใช้ subnet router ทำ site-to-site VPN เชื่อมหลาย site เข้า mesh เดียวกัน

Read

# สร้าง Internal Portal ดู Observability หลาย K8s Cluster ด้วย Next.js + OTel

4 min read

high-level architecture ของ internal portal ที่รวม observability ของ Kubernetes หลาย cluster ไว้ที่เดียว — ทุก cluster push telemetry ผ่าน OpenTelemetry เข้า ClickHouse ส่วนกลาง แล้ว Next.js portal…

Read

Comments