Custom Prometheus Exporters: เขียน Exporter สำหรับ Custom Application

การเขียน Custom Prometheus Exporter เป็นเรื่องจำเป็นเมื่อระบบที่ต้อง monitor ไม่มี exporter สำเร็จรูป หรือเมื่อต้องการเก็บ business metric เฉพาะทาง เช่น จำนวน order ต่อวินาที, latency ของ internal API, หรือสถานะของ job queue — บทความนี้จะลงลึกถึงวิธีเขียน custom exporter ระดับ production ที่เสถียร ปลอดภัย และ scale ได้

เราจะดูทั้งแบบ Python (prometheus_client) และ Go (prometheus/client_golang) ที่เป็นภาษาหลักของ exporter ส่วนใหญ่ พร้อมแนวทางการทำ unit test, การ package เป็น binary/container, และการ instrument ข้อมูลภายในของ exporter เองเพื่อดูว่า exporter แข็งแรงแค่ไหน

เมื่อไหร่ต้องเขียน Custom Exporter

  • ระบบ internal ที่ไม่ได้ expose metric ในรูปแบบ Prometheus เช่น legacy system, mainframe
  • Business metric ที่ไม่สามารถดึงจาก infrastructure ทั่วไป เช่น จำนวนสมาชิกใหม่ต่อชั่วโมง
  • External API ที่ต้องการ monitor SLA เช่น latency ของ partner API
  • Job queue, message broker ที่ไม่มี standard exporter ที่ตรงกับ setup
  • Custom hardware, IoT device ที่ต้องดึงข้อมูลผ่าน serial/SNMP/API เฉพาะ

หลักการออกแบบ Exporter

Exporter ที่ดีต้องทำตามหลัก stateless — ไม่เก็บข้อมูลระยะยาว ทุกครั้งที่ถูก scrape จะไปดึงข้อมูลสด ๆ จากต้นทาง ให้ Prometheus เป็นตัวเก็บ time series แทน

  • Scrape-driven: เก็บข้อมูลเมื่อถูก scrape เท่านั้น ไม่ poll เป็นระยะเอง (ยกเว้น metric ที่ต้นทางช้า)
  • Cardinality-aware: จำกัดจำนวน label combination ให้ไม่เกินหลักพัน ต่อ exporter
  • Fail-open: ถ้า source ล่ม ให้ return metric ที่ว่างเปล่าพร้อม up = 0 แทน error 500
  • Observable: เปิด metric ของ exporter เองด้วย เช่น scrape_duration, scrape_errors_total

Python Exporter (Production Ready)

ตัวอย่างนี้เป็น custom exporter ที่ดึง metric จาก internal REST API — รวม scrape timeout, error handling, และ instrument ตัวเอง:

from prometheus_client import start_http_server, Gauge, Counter, Histogram, REGISTRY
from prometheus_client.core import GaugeMetricFamily
import requests
import time
import logging

logging.basicConfig(level=logging.INFO)
log = logging.getLogger('api_exporter')

SCRAPE_DURATION = Histogram('exporter_scrape_duration_seconds',
                            'Duration of scrape to source API')
SCRAPE_ERRORS = Counter('exporter_scrape_errors_total',
                        'Total scrape errors', ['error_type'])

class ApiCollector:
    def __init__(self, api_url, timeout=5):
        self.api_url = api_url
        self.timeout = timeout

    def collect(self):
        orders = GaugeMetricFamily('business_orders_total',
                                   'Total orders by status',
                                   labels=['status'])
        up = GaugeMetricFamily('source_up', 'Source API reachable', value=0)

        with SCRAPE_DURATION.time():
            try:
                r = requests.get(f"{self.api_url}/stats",
                                 timeout=self.timeout)
                r.raise_for_status()
                data = r.json()
                for status, count in data.get('orders', {}).items():
                    orders.add_metric([status], count)
                up = GaugeMetricFamily('source_up', 'Source API reachable', value=1)
            except requests.Timeout:
                SCRAPE_ERRORS.labels(error_type='timeout').inc()
                log.warning('source API timeout')
            except requests.RequestException as e:
                SCRAPE_ERRORS.labels(error_type='request').inc()
                log.error(f'source API error: {e}')

        yield orders
        yield up

if __name__ == '__main__':
    REGISTRY.register(ApiCollector('http://api.internal:8080'))
    start_http_server(9200)
    log.info('exporter listening on :9200')
    while True:
        time.sleep(60)

จุดสำคัญคือใช้ GaugeMetricFamily แทน Gauge global ทั่วไป เพราะจะไม่คงค่าจาก scrape ก่อน — ถ้า source ล่ม metric จะหายไปแทนที่จะค้างค่าเก่า ทำให้ alert ทำงานได้ถูกต้อง

Go Exporter

Go เป็นภาษาทางการของ Prometheus และเป็นตัวเลือกที่ดีสำหรับ exporter ที่ต้องการ performance สูงและ binary เดียว — ตัวอย่างพื้นฐาน:

package main

import (
    "log"
    "net/http"
    "time"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

type collector struct {
    ordersTotal *prometheus.Desc
    sourceUp    *prometheus.Desc
}

func newCollector() *collector {
    return &collector{
        ordersTotal: prometheus.NewDesc(
            "business_orders_total",
            "Total orders by status",
            []string{"status"}, nil,
        ),
        sourceUp: prometheus.NewDesc(
            "source_up",
            "Source API reachable",
            nil, nil,
        ),
    }
}

func (c *collector) Describe(ch chan<- *prometheus.Desc) {
    ch <- c.ordersTotal
    ch <- c.sourceUp
}

func (c *collector) Collect(ch chan<- prometheus.Metric) {
    orders, err := fetchOrders()
    if err != nil {
        log.Printf("fetch error: %v", err)
        ch <- prometheus.MustNewConstMetric(c.sourceUp, prometheus.GaugeValue, 0)
        return
    }
    ch <- prometheus.MustNewConstMetric(c.sourceUp, prometheus.GaugeValue, 1)
    for status, count := range orders {
        ch <- prometheus.MustNewConstMetric(c.ordersTotal,
            prometheus.CounterValue, count, status)
    }
}

func fetchOrders() (map[string]float64, error) {
    // HTTP call, database query, etc.
    return map[string]float64{
        "pending": 42, "completed": 1024,
    }, nil
}

func main() {
    prometheus.MustRegister(newCollector())
    http.Handle("/metrics", promhttp.Handler())
    srv := &http.Server{
        Addr: ":9200", ReadTimeout: 10 * time.Second,
    }
    log.Fatal(srv.ListenAndServe())
}

Go client ใช้ Collect/Describe interface ซึ่งเป็น pattern ที่แนะนำสำหรับ scrape-driven exporter — metric ถูกสร้างใหม่ทุกครั้งที่ Prometheus scrape ทำให้ไม่มีการ leak state

Unit Testing Exporter

Exporter ที่ใช้ใน production ต้องมี test coverage เพื่อให้แน่ใจว่า metric ที่ expose ถูกต้อง — ใช้ testutil ของ prometheus client library:

# test_exporter.py
from prometheus_client import REGISTRY
from prometheus_client.core import GaugeMetricFamily

def test_collector_returns_metric(requests_mock):
    requests_mock.get('http://api.internal:8080/stats',
                      json={'orders': {'pending': 5, 'completed': 100}})
    from exporter import ApiCollector
    collector = ApiCollector('http://api.internal:8080')
    metrics = {m.name: m for m in collector.collect()}
    assert 'business_orders' in metrics
    pending = [s for s in metrics['business_orders'].samples if s.labels['status']=='pending'][0]
    assert pending.value == 5

สำหรับ Go ใช้ testutil.CollectAndCompare ที่เปรียบเทียบ metric output กับ expected text format ได้โดยตรง — ช่วยจับ regression เมื่อชื่อ metric หรือ label เปลี่ยน

Docker Image

FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY exporter.py .
USER nobody
EXPOSE 9200
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --spider http://localhost:9200/metrics || exit 1
CMD ["python", "-u", "exporter.py"]

รันด้วย user nobody ไม่ใช่ root เพื่อความปลอดภัย — และเพิ่ม HEALTHCHECK เพื่อให้ orchestrator เช่น Kubernetes ตรวจสอบความพร้อมของ exporter ได้

Systemd Service

# /etc/systemd/system/api-exporter.service
[Unit]
Description=API Metrics Exporter
After=network.target

[Service]
Type=simple
User=exporter
Group=exporter
ExecStart=/usr/local/bin/api-exporter
Restart=always
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Security hardening ของ systemd เช่น NoNewPrivileges, ProtectSystem, PrivateTmp ลดผลกระทบถ้ามีช่องโหว่ใน exporter — เป็น baseline ของ service modern

Instrument ตัว Exporter เอง

Metric ที่ควร expose จาก exporter เองเสมอ:

MetricTypeประโยชน์
exporter_scrape_duration_secondsHistogramตรวจสอบว่า exporter ช้าหรือไม่
exporter_scrape_errors_totalCounterAlert ถ้า error spike ขึ้น
source_upGaugeบอกว่า source reachable หรือไม่
exporter_build_infoGaugeVersion tracking แบบ Grafana-friendly
exporter_last_scrape_timestampGaugeตรวจว่า scrape ยังทำงานปกติ

Best Practices

  • ตั้ง timeout ของ source call ให้น้อยกว่า scrape_timeout ของ Prometheus (default 10s) มิฉะนั้น scrape จะค้าง
  • ใช้ prometheus.NewGaugeVec แทน prometheus.NewGauge ที่ต้องประกาศทุก label combination ล่วงหน้า
  • จัดการ concurrency ด้วย sync.Mutex หรือ asyncio.Lock ถ้ามี state ร่วมกันระหว่าง scrape
  • อย่าเก็บ metric เป็น histogram กับ bucket ที่ไม่เหมาะสม — default bucket มักไม่ตรงกับ workload จริง
  • Version exporter ด้วย build_info metric — ช่วย debug เมื่อ Grafana dashboard พัง
  • Logging ให้ ideal level (WARN ขึ้นไปสำหรับ production) — ไม่ต้อง log ทุก scrape
  • ใช้ graceful shutdown — รับ SIGTERM แล้ว close HTTP server อย่าง clean ก่อนจบ process
  • Release ด้วย semver และใช้ CI pipeline ทำ test + build image อัตโนมัติ

เผยแพร่ให้คนอื่นใช้

ถ้า exporter ที่เขียนมีประโยชน์ต่อชุมชน พิจารณา open-source ผ่าน GitHub — ตามกฎ Prometheus community แนะนำให้:

  • ใส่ README อธิบาย metric ทุกตัวที่ expose
  • ใช้ license เช่น Apache 2.0, MIT ที่เข้ากับ Prometheus ecosystem
  • มี Grafana dashboard ตัวอย่างใน dashboard/ directory
  • Publish Docker image ไปที่ Docker Hub หรือ GitHub Container Registry
  • ใส่ตัวอย่าง scrape config ใน README

สรุป

Custom exporter ทำให้ Prometheus ครอบคลุมทุกระบบที่ทีมใช้งาน ตั้งแต่ business metric ไปจนถึง legacy system ที่ไม่เคยคิดว่า monitor ได้ การเขียน exporter ระดับ production ต้องการความใส่ใจทั้งด้าน performance, security, และ observability ของตัว exporter เอง — แต่พอทำให้ครบถ้วน จะได้เครื่องมือที่เสถียรและ scale ได้จริง

เมื่อมี exporter ครบแล้ว ขั้นตอนถัดไปคือการเขียน PromQL query และ alert rule ที่ใช้ metric เหล่านี้ให้เกิดประโยชน์สูงสุด