じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

Golang のEcho で Prometheus Exporter を実装する

はじめに

Prometheus でアプリケーションの構築をしているとどうしてもこの値が取りたいのに... と思うことが多々ある。Pushgateway も選択肢として上げられるが今回は選択肢を増やしてほしいという意味でもExporterの実装方法について検討していきます。ExporterはPrometheusのpull モデルに適合し、監視対象のライフサイクルと一貫性があり、スケーラビリティと自動検出の利点を享受できるため、Pushgatewayよりも推奨される方法です。ただし、特定のユースケース(サービスレベルのバッチジョブなど)では、Pushgatewayの使用が適切な場合もあります。Pushgatewayを使う際には以下の問題点があるので注意が必要です。

  • 複数のインスタンスを1つのPushgatewayで監視すると、単一障害点とボトルネックが発生する可能性がある。
  • Prometheusの自動インスタンスヘルスチェックが利用できなくなる。
  • Pushgatewayは一度プッシュされたデータを忘れず、手動でAPIを通じて削除しない限り永久にPrometheusで公開されてしまう。

Exporter の実装と運用はそこそこ手間になるので最適な方法を選んでほしいです。この辺はCloudを利用しても同じような問題があるので注意しながらやっていきましょう。

サンプルコードはこちらです。サンプルコード自体は雑多な作業リポジトリにおいてあるのでご注意ください。また、アプリケーション自体のリソースを確認するのにEcho のミドルウェアを使用していません。自身の利用しているライブラリーにPrometheus のエンドポイントを提供する機能がないか調べておきましょう。gRPCのGo Server にも同様の機能があります。あと、外部のリソースが確認したいだけならBlackbox exporterという選択肢もあります。

github.com

Exporterとは

Exporterは、Prometheusがメトリクスを収集するために使用するプログラムです。Exporterは、アプリケーションやインフラストラクチャからメトリクスを収集し、Prometheusが理解できる形式に変換して提供します。公式ドキュメントで提供されているExporter一覧は、こちらを参照してください。

Prometheusの公式クライアントライブラリ

Prometheusは、いくつかの言語用の公式クライアントライブラリを提供しており、これを使用してExporterを実装することができます。今回はGoで実装していくのこちらが参考になると思います。

やっていくぞ!おら!

やっていきます

環境構築

# Go のモジュールを作成する。必要なライブラリーはのちほど`go mod tidy` で持ってくる。
go mod init prometheus-go-exporter

実装について

以下をmain.go に配置して実行(go run main.go)してください。以下のコードはEchoを利用したWebサーバーにPrometheusのExporterを実装し、3-shake.comへのアクセスを計測しています。 http://localhost:2121/3-shake-statushttp://localhost:2121/metrics で値を取得できていると思います。

package main

import (
    "fmt"
    "net/http"
    "time"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

// Prometheusのメトリクスを定義しています。
// これらのメトリクスは、HTTPリクエストの情報や3-shake.comへのアクセス情報を収集するために使用されます。
var (
    httpRequestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Number of HTTP requests processed",
        },
        []string{"method", "path"},
    )

    httpRequestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "Duration of HTTP requests",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "path"},
    )

    httpRequestSize = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_size_bytes",
            Help:    "Size of HTTP requests",
            Buckets: prometheus.ExponentialBuckets(128, 2, 10),
        },
        []string{"method", "path"},
    )

    httpResponseSize = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_response_size_bytes",
            Help:    "Size of HTTP responses",
            Buckets: prometheus.ExponentialBuckets(128, 2, 10),
        },
        []string{"method", "path"},
    )

    httpResponseTime = prometheus.NewGaugeVec(
        prometheus.GaugeOpts{
            Name: "http_response_time_seconds",
            Help: "Time of the last HTTP response",
        },
        []string{"method", "path"},
    )

    externalAccessDuration = prometheus.NewHistogram(
        prometheus.HistogramOpts{
            Name:    "external_access_duration_seconds",
            Help:    "Duration of external access to 3-shake.com",
            Buckets: prometheus.DefBuckets,
        },
    )

    lastExternalAccessStatusCode = prometheus.NewGauge(
        prometheus.GaugeOpts{
            Name: "last_external_access_status_code",
            Help: "Last status code of external access to 3-shake.com",
        },
    )
)

// init関数内で、メトリクスをPrometheusに登録しています。
func init() {
    registerMetrics()
}

// registerMetrics関数では、Prometheusにメトリクスを登録しています。
// これにより、Prometheusがメトリクスを収集できるようになります。
func registerMetrics() {
    prometheus.MustRegister(httpRequestsTotal)
    prometheus.MustRegister(httpRequestDuration)
    prometheus.MustRegister(httpRequestSize)
    prometheus.MustRegister(httpResponseSize)
    prometheus.MustRegister(httpResponseTime)
    prometheus.MustRegister(externalAccessDuration)
    prometheus.MustRegister(lastExternalAccessStatusCode)
}

// updateMetrics関数では、受信したHTTPリクエストのメトリクスを更新しています。
// これにより、各リクエストに関する情報が収集されます。
func updateMetrics(method, path string, requestSize, responseSize int, duration time.Duration) {
    httpRequestsTotal.WithLabelValues(method, path).Inc()
    httpRequestDuration.WithLabelValues(method, path).Observe(duration.Seconds())
    httpRequestSize.WithLabelValues(method, path).Observe(float64(requestSize))
    httpResponseSize.WithLabelValues(method, path).Observe(float64(responseSize))
    httpResponseTime.WithLabelValues(method, path).Set(float64(time.Now().Unix()))
}

// prometheusMiddleware関数では、Echoのミドルウェアとして、受信したHTTPリクエストに関するメトリクスを更新する機能を追加しています。
func prometheusMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        startTime := time.Now()
        err := next(c)
        duration := time.Since(startTime)

        requestSize := c.Request().ContentLength
        responseSize := c.Response().Size

        updateMetrics(c.Request().Method, c.Path(), int(requestSize), int(responseSize), duration)

        return err
    }
}

// measureExternalAccess関数では、3-shake.comへの外部アクセスを定期的に計測し、そのアクセス時間とステータスコードをメトリクスに格納しています。
// この関数はメイン関数内で呼び出され、別のゴルーチンで実行されます。
func measureExternalAccess() {
    client := &http.Client{Timeout: 10 * time.Second}

    go func() {
        for {
            startTime := time.Now()
            resp, err := client.Get("https://3-shake.com")
            duration := time.Since(startTime)

            if err == nil {
                externalAccessDuration.Observe(duration.Seconds())
                lastExternalAccessStatusCode.Set(float64(resp.StatusCode))
                resp.Body.Close()
            }

            time.Sleep(1 * time.Minute)
        }
    }()
}

func main() {
    // Echo instance
    e := echo.New()

    // Middleware for Prometheus Exporter
    e.Use(prometheusMiddleware)

    // Enable request logger
    e.Use(middleware.Logger())

    e.GET("/3-shake-status", func(c echo.Context) error {
        status := lastExternalAccessStatusCode.Desc().String()
        return c.String(http.StatusOK, fmt.Sprintf("Last 3-shake.com access status: %s", status))
    })

    // Prometheus Exporter endpoint
    e.GET("/metrics", echo.WrapHandler(promhttp.Handler()))

    // Measure external access to 3-shake.com
    measureExternalAccess()

    // Start the server
    e.Start(":2121")
}

コードを解説するんじゃい

解説をします。

Prometheusのメトリクスを定義

Prometheusのメトリクスを定義しています。これらのメトリクスは、HTTPリクエストの情報や3-shake.comへのアクセス情報を収集するために使用されます。

var (
    // ... (省略)

    externalAccessDuration = prometheus.NewHistogram(
        prometheus.HistogramOpts{
            Name:    "external_access_duration_seconds",
            Help:    "Duration of external access to 3-shake.com",
            Buckets: prometheus.DefBuckets,
        },
    )

    lastExternalAccessStatusCode = prometheus.NewGauge(
        prometheus.GaugeOpts{
            Name: "last_external_access_status_code",
            Help: "Last status code of external access to 3-shake.com",
        },
    )
)

init関数

init関数内で、メトリクスをPrometheusに登録しています。

func init() {
    registerMetrics()
}

registerMetrics関数

registerMetrics関数では、Prometheusにメトリクスを登録しています。これにより、Prometheusがメトリクスを収集できるようになります。

func registerMetrics() {
    // ... (省略)

    prometheus.MustRegister(externalAccessDuration)
    prometheus.MustRegister(lastExternalAccessStatusCode)
}

updateMetrics関数

updateMetrics関数では、受信したHTTPリクエストのメトリクスを更新しています。これにより、各リクエストに関する情報が収集されます。

func updateMetrics(method, path string, requestSize, responseSize int, duration time.Duration) {
    // ... (省略)
}

prometheusMiddleware関数

prometheusMiddleware関数では、Echoのミドルウェアとして、受信したHTTPリクエストに関するメトリクスを更新する機能を追加しています。

func prometheusMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
    // ... (省略: )
}

measureExternalAccess関数

measureExternalAccess関数 では、3-shake.comへの外部アクセスを定期的に計測し、そのアクセス時間ステータスコードをメトリクスに格納しています。この関数はメイン関数内で呼び出され、別のゴルーチンで実行されます。

func measureExternalAccess() {
    client := &http.Client{Timeout: 10 * time.Second}

    go func() {
        for {
            startTime := time.Now()
            resp, err := client.Get("https://3-shake.com")
            duration := time.Since(startTime)

            if err == nil {
                externalAccessDuration.Observe(duration.Seconds())
                lastExternalAccessStatusCode.Set(float64(resp.StatusCode))
                resp.Body.Close()
            }

            time.Sleep(1 * time.Minute)
        }
    }()
}

var 配下ってことぉおおお

Prometheusのメトリクスを定義しています。この辺の実装はよく悩むと思うので公式の実装とかたくさん読むと何をどれに使えばよいかの勘所が掴めると思います。実際に使わないと差が分からないのでとっとと手を動かすのがオススメです。

httpRequestsTotal

処理されたHTTPリクエストの総数をカウントするメトリクスです。prometheus.NewCounterVec関数を使用して定義され、リクエストのメソッド(GET、POSTなど)とパス(リソースへのURLパス)によってラベル付けされます。

httpRequestDuration

HTTPリクエストの処理時間を記録するメトリクスです。prometheus.NewHistogramVec関数を使用して定義され、リクエストのメソッドとパスによってラベル付けされます。デフォルトのバケットは、prometheus.DefBucketsを使用して設定されます。

httpRequestSize

HTTPリクエストのサイズ(バイト単位)を記録するメトリクスです。prometheus.NewHistogramVec関数を使用して定義され、リクエストのメソッドとパスによってラベル付けされます。バケットは、prometheus.ExponentialBuckets関数を使用して設定されます。

httpResponseSize

HTTPレスポンスのサイズ(バイト単位)を記録するメトリクスです。prometheus.NewHistogramVec関数を使用して定義され、リクエストのメソッドとパスによってラベル付けされます。バケットは、同様にprometheus.ExponentialBuckets関数を使用して設定されます。

httpResponseTime

HTTPレスポンスの時間を記録するメトリクスです。このメトリクスは、prometheus.NewGaugeVec関数を使用して定義され、リクエストのメソッドとパスによってラベル付けされます。

externalAccessDuration

これは、3-shake.comへの外部アクセスの持続時間を記録するメトリクスです。このメトリクスは、prometheus.NewHistogram関数を使用して定義されます。デフォルトのバケットは、prometheus.DefBuckets関数を使用して設定されます。

Docker Compose での実行するんじゃろがい

prometheus.yml

まず、prometheus.ymlを作成します。このファイルには、Prometheusがどのようにメトリクスを収集するかについての設定が含まれています。

global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'echo_exporter'
    static_configs:
      - targets: ['echo_exporter:2121']

docker-compose.yml

次に、docker-compose.ymlを作成します。このファイルには、PrometheusとGolangのEchoアプリケーションを実行するために必要なコンテナの設定が含まれています。

version: '3.8'

services:
  echo_exporter:
    build: 
      context: .
      dockerfile: Dockerfile_exporter
    ports:
      - "2121:2121"

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    command:
      - "--config.file=/etc/prometheus/prometheus.yml"
    ports:
      - "9090:9090"

Dockerfile

Dockerfileを作成して、Echoアプリケーションをコンテナで実行できるようにします。別に動けばよいのでなんか工夫とかはしてないです。本番でやるときはうまくマルチステージビルドとか使って下さい。

# Use the official Golang image as the base image
FROM golang:1.20

# Set the working directory
WORKDIR /app

# Copy go.mod and go.sum to download dependencies
COPY go.mod go.sum ./

# Download dependencies
RUN go mod download

# Copy the source code
COPY . .

# Build the application
RUN go build -o main .

# Expose the port the application will run on
EXPOSE 2121

# Run the application
CMD ["./main"]

docker compose の実行

2023 年 6 月末から、Compose V1 はサポートされなくなり、すべての Docker Desktop バージョンから削除されるので注意してほしいです。ちなみにcompose がdockerコマンドに入るようになったのでdocker-compose 特別にインストールせずとも実行可能になりました。

# デーモン化しちゃう
docker compose up -d 

Dockerfileを使用してecho_exporterサービスがビルドされ、PrometheusとGolangのEchoアプリケーションをそれぞれのコンテナで起動します。Prometheusは、echo_exporterサービスからメトリクスを収集し、ポート9090でアクセスできるようになります。

last_external_access_status_code を確認するには起動した状態でこちらを参考にしてください。

一回、シャットダウンしたので以下のようなグラフが出力されていますね。。長くなったのでこれで終わります。

さいごに

実は Echo において Prometheus は、HTTP リクエストのメトリックを生成することができるミドルウェアを提供しているので基本的な部分でコードを書く必要がありません。もし、アプリケーションに実装できるならそれが良いです。独自に実装などせずにエンドポイントにて500 Internal Server Errorが多発していればアラートをすれば良いだけなので...。もし、インフラのコードがアプリに組み込めないもしくはプロダクションコードは開発側しか触れない時には協力を仰いで下さい。開発側との人間関係に問題があったりセキュリティ上の課題がある場合には別の手段を考えましょう。

package main
import (
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo-contrib/prometheus"
)
func main() {
    e := echo.New()
    // Enable metrics middleware
    p := prometheus.NewPrometheus("echo", nil)
    p.Use(e)

    e.Logger.Fatal(e.Start(":1323"))
}

と書くだけで外部リソースへのアクセス以外のメトリクスは提供できます。また、外部リソースに対してもいくつかの構造体を持っているのでこれらも効率的に提供できます。

echo.labstack.com

本当に関係ないんですけど2023年4月19日にECサイト構築・運用セキュリティガイドラインを読み解く会 というのをやるので興味あれば!

owasp-kyushu.connpass.com

続編のブログも書いておきました。

syu-m-5151.hatenablog.com