🔥

Prometheus を Python, Go, Rust で始める

2023/03/22に公開

はじめに

去年から Observability に興味がありいくつか記事を書いてきた(Go で書いたアプリから Jaeger や X-Ray にトレースを送る記事とかコンテナでデプロイした AWS Lambda から X-Ray に OpenTelemetry SDK でトレースを送る記事とか)のですが、今まではトレースのことばかりでメトリクスはノータッチでした。Observability 観点だとメトリクスも外せないと考えてメトリクスデビューすることにしました。今回は Prometheus を使ってみます。アプリケーションは Python(Flask), Go (Echo), Rust(Axum) でそれぞれ書きました。リポジトリはこちらです。

Prometheus とは

Prometheus は、CNCF の graduated プロジェクトで OSS のシステム監視ツールです。複数の言語で計装されたメトリクスを収集することができ、PromQL と呼ばれる独自のクエリ言語を使用して、時系列データに対して高度なクエリを実行することができます。

Pull 型のアーキテクチャを採用しており監視対象のサーバーを静的・動的(こちらは service discovery を使う)に設定して、Prometheus サーバー側から監視対象のサーバーにメトリクスをとりに行く(これに対して監視対象のサーバーから監視サーバーにメトリクスを書き込むのが Push 型)というアプローチです。

また Prometheus には UI も備わっていて以下の図のように PromQL でクエリしたメトリクスを可視化することができます。もしかしたら可視化部分は Grafana を使っているケースが多いかもしれません。

OpenTelemetry Collector からメトリクスを収集することも可能

まだ Experimental ですが OpenTelemetry の exporter には Prometheus 向けのものがあります。自分が公開したコードだとこんな感じの config を書けば Prometheus のエンドポイントに remote write できます。Trace もモニタリングしていて OpenTelemetry を使っている場合に Metrics の取り扱いも OpenTelemetry でやることにして OpenTelemetry に一元化するのは選択肢の一つだと思います。

この辺りに Docker Compose で OpenTelemetry Collector を動かしてメトリクスをすくレイプする方法を書いたのでよければ見てください。

アプリケーションの計装

Middleware があると便利

Web サーバーで全てのリクエストに共通してあるメトリクスを計装する場合を考えます。例えば HTTP リクエスト数はラベルでパスを付与した上でどのパスに対しても計装することが多いでしょう。

素朴に考えると以下のように実装できます。 / にリクエストが来た時のハンドラー関数を rootHandler としたときにこのハンドラー関数の中でメトリクスを操作します。以下のコードはそれっぽい疑似コードなのでそのままだと動かないかもしれないので、あくまで雰囲気を掴むものとして読んでください。

func NewRouter() *echo.Echo {
	e := echo.New()
	e.GET("/", rootHandler)

	return e
}

func rootHandler(c echo.Context) error {
    // set metric

    // some business logic
}

ですがこのやり方だとハンドラー関数一つ一つでメトリクスの操作を行う必要があります。メトリクス名を変更した場合すべてのハンドラー関数を修正するのは手間ですし抜けもれがあるかもしれません。

次に思いつくのはメトリクス操作を関数に切り出す方法です。

func NewRouter(c Controllers) *echo.Echo {
	e := echo.New()
	e.GET("/", rootHandler)

	return e
}

func setMetric() {
    // set metric
}

func rootHandler(c echo.Context) error {
    setMetric()

    // some business logic
}

こうすると setMetric を修正するだけで済みます。

ただこの方法にもまだ難点があります。本来ビジネスロジックだけ記述したいハンドラー関数の中で必ずメトリクス操作関数を呼び出す必要があり、関心を分離しきれていません。

そこで以下のようにハンドラー関数をラップしてメトリクス操作を行うようにして、ルーターにはこのラップした関数を登録するようにします。

func NewRouter(c Controllers) *echo.Echo {
	e := echo.New()
	wrappedHandler := wrapHandler(rootHandler)
	e.GET("/", wrappedHandler)

	return e
}

func wrapHandler(f echo.HandlerFunc) echo.HandlerFunc {
	setMetric()
	return f
}

func setMetric() {
    // set metric
}

func rootHandler(c echo.Context) error {
    // some business logic
}

こうするとハンドラー関数はビジネスロジックの実装に集中できます(メトリクス操作関数を呼ぶのはルーターへの登録前に行うので)。そしてラップ関数を毎回噛ませるのは冗長なのでルーターに登録するハンドラー関数を全部ラップする操作を行うようにすると便利だよねってことで Middleware が出てきます。

Go のフレームワーク Echo の場合 Middleware のドキュメントはこちら、Rust のフレームワーク Axum の場合 Middleware のドキュメントはこちらです。

Python (Flask) の場合

コードはこちらです。

今回 Flask の場合は prometheus-flask-exporter を使っています。これは Counter や Histogram などのメトリクスを定義して関数の前にデコレーター記法で計装します。また HTTP リクエスト数や処理時間などがデフォルトメトリクスとして提供されています。

# Histogram を定義
# メトリクス名、その説明、Histogram の場合はバケットの順に定義する
LATENCY_HISTOGRAM = Histogram(
    "hello_world_latency_seconds", "Time for a hello world request", buckets=[0.0001, 0.0002, 0.0005, 0.001, 0.01, 0.1]
)

@app.route("/")
# デコレータ記法(最初の例で出てきた関数をラップして関数を返すもの)で計装する
@LATENCY_HISTOGRAM.time()
def hello_world() -> dict[str, str]:
    time.sleep(random.random())
    return {"message": "Hello World"}

Go (Echo) の場合

コードはこちらです。一つのファイルに全て書くのではなくディレクトリを分けることにしました。

今回 Go のフレームワークは Echo を使っています。Prometheus Middleware が提供されているのでこれを使っていきます。こちらも HTTP リクエスト数や処理時間などがデフォルトメトリクスとして提供されています。

独自にメトリクスを定義する方法はドキュメントのこの辺りに書かれています。

import (
	"github.com/labstack/echo-contrib/prometheus"
	"github.com/labstack/echo/v4"
)
// *prometheus.Metrics をフィールドにもつ Metrics 構造体を宣言する
// 各フィールドが独自のメトリクス
type Metrics struct {
	healthcheckLatency *prometheus.Metric
	moviesLength       *prometheus.Metric
}

// prometheus.NewPrometheus() に渡す時用に []*prometheus.Metric を返すメソッドを定義
func (m *Metrics) MetricList() []*prometheus.Metric {
	return []*prometheus.Metric{
		m.healthcheckLatency,
		m.moviesLength,
	}
}

// 独自のメトリクスはハンドラー関数を書いているファイルで定義することにして構造体を初期化するときに呼んでくる
func NewMetrics() *Metrics {
	return &Metrics{
		healthcheckLatency: handler.HealthcheckLatencySeconds,
		moviesLength:       handler.MoviesLength,
	}
}

// context.Context のキー名を作っておく。このキーに Metrics 構造体が対応する
const ContextKeyMetrics = "custom_metrics"

func (m *Metrics) AddCustomMetricsMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		c.Set(ContextKeyMetrics, m)
		return next(c)
	}
}

func main() {
	e := echo.New()
	m := NewMetrics()

	// Prometheus Middleware を有効にする
	p := prometheus.NewPrometheus("echo", nil, m.MetricList())
	p.Use(e)

	e.Use(m.AddCustomMetricsMiddleware)
}

Rust (Axum) の場合

コードはこちらです。Axum 公式の Prometheus の例があるのでこれを踏襲します。具体的には Prometheus Rust Client SDK ではなく metrics クレートの metrics-exporter-prometheus を使っています。

Axum のミドルウェアについては去年調べていたのが役に立ちました。HTTP リクエスト数のような全ハンドラーで共通のメトリクスを計装し(コードはここらへん)、ハンドラーの中で使う独自のメトリクスについては metrics クレートの gauge! マクロなどを使っています(コードはここらへん)。

pub fn add_service_builder(router: Router<(), Body>) -> Router<(), Body> {
    let recorder_handle = setup_metrics_recorder();

    router
        .route("/metrics", get(|| async move { recorder_handle.render() }))
        .layer(
            ServiceBuilder::new()
                .layer(axum::middleware::from_fn(track_metrics)),
        )
}

fn setup_metrics_recorder() -> PrometheusHandle {
    const EXPONENTIAL_SECONDS: &[f64] = &[
        0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
    ];

    PrometheusBuilder::new()
        .set_buckets_for_metric(
            Matcher::Full("axum_http_requests_duration_seconds".to_string()),
            EXPONENTIAL_SECONDS,
        )
        .unwrap()
        .install_recorder()
        .unwrap()
}
async fn track_metrics<B>(req: Request<B>, next: Next<B>) -> impl IntoResponse {
    let start = Instant::now();
    let path = req.uri().path().to_owned();
    let method = req.method().clone();

    let response = next.run(req).await;

    let latency = start.elapsed().as_secs_f64();
    let status = response.status().as_u16().to_string();

    let labels = [
        ("method", method.to_string()),
        ("path", path),
        ("status", status),
    ];

	// metrics クレートのマクロ(https://docs.rs/metrics/latest/metrics/index.html#macros)を使用
    increment_counter!("axum_http_requests_total", &labels);
    histogram!("axum_http_requests_duration_seconds", latency, &labels);

    response
}

おわりに

Python, Go, Rust アプリに Prometheus でメトリクスを計装する方法を見てきました。サンプルアプリもできたことなので今後は Kubernetes にアプリをデプロイしていいいい感じにメトリクスをスクレイプするのを試してみようと思います。

Discussion