Kubernetes به‌صورت پیش‌فرض از CPU و حافظه آگاه است، اما در عمل خیلی از تصمیم‌های مقیاس‌دهی بستگی به سیگنال‌هایی دارد که بیرون از این حدهای ساده‌اند: طول صف‌ها، مدت اجرای آخرین batch، تعداد اتصال‌های WebSocket فعال در یک pod و غیره. وقتی معیارهای داخلی کافی نیستند، یک custom exporter شکاف را پر می‌کند و آن سیگنال‌ها را به Prometheus می‌دهد تا برای alerts و HorizontalPodAutoscaler قابل استفاده شوند. 🚀

یک exporter ساده وظیفهٔ مشخصی دارد: متریک‌ها را روی endpoint /metrics در فرمت سادهٔ Prometheus منتشر کند تا Prometheus بتواند آن را scrape کند، سری زمانی‌ها را ذخیره کند و برای alerting و scaling استفاده کند. گاهی برنامه را می‌توان مستقیم instrument کرد (کتابخانهٔ Prometheus را embed کنیم)، اما وقتی منبع داده بیرونی است یا کنترل کد برنامه را ندارید، یک exporter مجزا منطقی‌تر است.

فرمت متریک‌ها ساده است: یک خط برای هر متریک با نام، لیبل‌های اختیاری و مقدار عددی. کتابخانه‌های client سریال‌سازی را انجام می‌دهند؛ کار شما انتخاب اینکه چه چیزی را اندازه‌گیری کنید و چه زمانی مقدار را به‌روز کنید است.

قبل از نوشتن کد باید تصمیم بگیرید که با چه نوع سیگنال سروکار دارید. مدل دادهٔ Prometheus سه نوع اصلی دارد:

• Counter: فقط افزایش می‌یابد؛ مناسب برای مجموع‌ها مثل درخواست‌ها، jobهای پردازش‌شده یا خطاها. هرگز برای مقادیری که ممکن است کاهش یابند از counter استفاده نکنید. 🔺

• Gauge: مقدار لحظه‌ای که می‌تواند بالا و پایین برود؛ مناسب برای عمق صف، تعداد اتصالات فعال، اندازهٔ کش و غیره. ⚖️

• Histogram: توزیع مشاهدات را ثبت می‌کند (مثلاً latency) و اجازه می‌دهد صدک‌ها (p99، p50) را محاسبه کنید، نه فقط میانگین. 📊

نام‌گذاری متریک‌ها را با قرارداد snake_case و با پسوندهای متعارف مثل _total برای counter و _seconds برای زمان‌ها رعایت کنید. مثال: worker_jobs_processed_total (counter)، worker_queue_depth (gauge)، worker_job_duration_seconds (histogram). نام‌های تمیز در آینده کار دیباگ و alerting را خیلی ساده‌تر می‌کنند.

برای اکوسیستم Kubernetes، رایج‌ترین انتخاب برای نوشتن exporter زبان Go و کتابخانهٔ github.com/prometheus/client_golang است. مثال گام‌به‌گام زیر نشان می‌دهد چطور پروژه راه‌اندازی، متریک‌ها تعریف، جمع‌آوری و در نهایت سرو شود.

# راه‌اندازی پروژه و گرفتن وابستگی‌ها
mkdir my-exporter && cd my-exporter
go mod init example.com/my-exporter
go get github.com/prometheus/client_golang/prometheus
go get github.com/prometheus/client_golang/prometheus/promhttp

در فایل main.go متریک‌ها را تعریف و ثبت کنید، سپس یک گوروتین برای poll کردن منابع و به‌روزرسانی متریک‌ها اجرا کنید و /metrics و /healthz را سرو کنید.

package main

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

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

var (
    jobsProcessed = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "worker_jobs_processed_total",
            Help: "Total number of jobs processed, partitioned by status.",
        },
        []string{"status"},
    )
    queueDepth = prometheus.NewGauge(
        prometheus.GaugeOpts{
            Name: "worker_queue_depth",
            Help: "Current number of jobs waiting in the queue.",
        },
    )
    jobDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
        Name:    "worker_job_duration_seconds",
        Help:    "Time spent processing a single job.",
        Buckets: prometheus.DefBuckets,
    })
)

func init() {
    // ثبت متریک‌ها در رجیستری پیش‌فرض
    prometheus.MustRegister(jobsProcessed, queueDepth, jobDuration)
}

func collectMetrics() {
    for {
        // مثال: خواندن از یک منبع واقعی مثل DB یا message broker
        depth := float64(rand.Intn(50))
        queueDepth.Set(depth)

        start := time.Now()
        // شبیه‌سازی پردازش یک job
        time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
        jobDuration.Observe(time.Since(start).Seconds())

        jobsProcessed.WithLabelValues("success").Inc()

        time.Sleep(5 * time.Second)
    }
}

func main() {
    go collectMetrics()

    http.Handle("/metrics", promhttp.Handler())
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    log.Println("Listening on :8080")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatalf("server error: %v", err)
    }
}

فاصلهٔ polling در collectMetrics باید کوتاه‌تر از scrape interval پرومتئوس باشد تا هر scrape مقدار تازه ببیند. در بسیاری از استقرارها scrape interval پیش‌فرض 15s است؛ در مثال بالا از 5s استفاده شده تا به‌خوبی پوشش بدهد.

قبل از کانتینری کردن، محلی تست کنید:

# اجرا محلی
go run .
# سپس در یک ترمینال دیگر:
curl http://localhost:8080/metrics | grep worker_

اگر خط‌های HELP و TYPE و متریک‌های شما ظاهر شدند، exporter آمادهٔ کانتینری شدن است. برای تصویر multi-stage Docker تا image نهایی کوچک بماند از الگوی زیر استفاده کنید:

# Dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /exporter .

FROM gcr.io/distroless/static:nonroot
COPY --from=builder /exporter /exporter
EXPOSE 8080
ENTRYPOINT ["/exporter"]

# Build & push (نمونه)
docker build -t my-registry/my-exporter:v1.0.0 .
docker push my-registry/my-exporter:v1.0.0

برای اجرا در کلاستر دو مانیفست اصلی لازم دارید: یک Deployment و یک Service. مثال خلاصه‌شده (به‌صورت قابل ویرایش):

# deployment.yaml (نمونه)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-exporter
  namespace: monitoring
  labels:
    app.kubernetes.io/name: my-exporter
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: my-exporter
  template:
    metadata:
      labels:
        app.kubernetes.io/name: my-exporter
      annotations:
        prometheus.io/scrape: "true"          # حذف کنید اگر از ServiceMonitor استفاده می‌کنید
        prometheus.io/port: "8080"
        prometheus.io/path: "/metrics"
    spec:
      containers:
      - name: exporter
        image: my-registry/my-exporter:v1.0.0
        ports:
        - name: metrics
          containerPort: 8080
        readinessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 10
        resources:
          requests:
            cpu: "50m"
            memory: "32Mi"
          limits:
            cpu: "100m"
            memory: "64Mi"
---
# service.yaml
apiVersion: v1
kind: Service
metadata:
  name: my-exporter
  namespace: monitoring
  labels:
    app.kubernetes.io/name: my-exporter
spec:
  selector:
    app.kubernetes.io/name: my-exporter
  ports:
  - name: metrics
    port: 8080
    targetPort: 8080

اگر از Prometheus Operator یا kube-prometheus-stack استفاده می‌کنید، بهتر است یک ServiceMonitor بسازید تا اپراتور آن را برای scrape پیدا کند:

# servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: my-exporter
  namespace: monitoring
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: my-exporter
  endpoints:
  - port: metrics
    path: /metrics
    interval: 15s

اگر Prometheus شما بر پایهٔ annotations کار می‌کند (scrape via annotations)، آن annotations را در Pod template قرار دهید (همان‌طور که در نمونه بالا هست). اگر مطمئن نیستید از کدام مدل استفاده می‌شود، ServiceMonitor روش شفاف‌تر و قابل اشکال‌زدایی‌تری است. 🛠️

برای بررسی اینکه exporter در Prometheus ظاهر شده است، از port-forward به سرویس Prometheus استفاده کنید و صفحهٔ Targets را بررسی کنید. اگر وضعیت UP باشد، همه چیز درست است و می‌توانید از metricها در alertها و HPAها استفاده کنید.

نکات عملی کوتاه و مفید:

• metric names و label keys را ثابت نگه دارید؛ تغییر آن‌ها پس از انتشار باعث دردسر در alerting و داشبوردها می‌شود. 🧭

• برای متریک‌های پر فرکانس از cardinality زیاد پرهیز کنید (labelهای با تعداد مقادیر بالا). این مورد حافظهٔ Prometheus و سرعت query را تحت تأثیر قرار می‌دهد. ⚠️

• اگر exporter را داخل یک کتابخانه قرار می‌دهید که بسته‌های دیگر هم ممکن است آن را instrument کنند، از prometheus.Register به جای MustRegister استفاده کنید و خطاها را مدیریت کنید تا ثبت تکراری باعث panic نشود.

اگر خواستید، می‌توانم فایل‌های نمونهٔ کامل و آمادهٔ deploy (main.go، Dockerfile، deployment/service/ServiceMonitor) را برای شما آماده کنم تا مستقیماً در CI/CD قرار بگیرند. آیا می‌خواهید آن‌ها را دریافت کنید؟ 🙂