# 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 (จุดที่เป็นบทเรียนของโพสต์นี้):
Deploy บน AWS — เริ่มด้วย S3 อย่างเดียว
ผม deploy ClickStack ไว้บน AWS โดยตอนแรกตั้งใจให้ ClickHouse ใช้ S3 เป็น storage backend อย่างเดียว — ไม่เก็บ data ลง local disk เลย คิดว่าจะได้ไม่ต้องห่วงเรื่อง disk เต็มและ scale พื้นที่ได้ไม่จำกัด (สปอยล์: นี่แหละจุดที่ผมพลาด เดี๋ยวเล่าตอนท้าย)
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/dashboardstorage policy ตอนแรกของผมหน้าตาประมาณนี้ — มี disk เดียวคือ S3 แล้วให้ทุก table เขียนลงตรงนั้นเลย:
<storage_configuration> <disks> <s3> <type>s3</type> <endpoint>https://s3.<region>.amazonaws.com/<bucket>/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:
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 รวมที่เดียว:
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 ส่วน:
- Storage policy — แบ่ง disk เป็น 2 tier:
hot(local EBS) กับcold(S3) - Compact parts — tune ให้ ClickHouse เขียน part ใหญ่ขึ้น/merge บ่อยขึ้น ลดจำนวน object เล็กๆ
- TTL move — ตั้ง TTL ให้ data ที่เก่ากว่า 3 วัน ย้ายจาก hot ไป cold อัตโนมัติ
<storage_configuration> <disks> <hot><type>local</type><path>/var/lib/clickhouse/hot/</path></hot> <cold> <type>s3</type> <endpoint>https://s3.<region>.amazonaws.com/<bucket>/clickhouse/</endpoint> </cold> </disks> <policies> <hot_cold> <volumes> <hot><disk>hot</disk></hot> <cold><disk>cold</disk></cold> </volumes> </hot_cold> </policies></storage_configuration>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 จะประหยัดกว่ามาก ผมจ่ายค่าเรียนรู้ตรงนี้ไปแล้ว เลยอยากให้คุณไม่ต้องจ่าย 😁