こんにちは。ジェダイパンくず🍞 です。
以前の記事 自前開発した Prometheus Exporter で自宅ルータのメトリクス監視運用している話 や Prometheus Exporter 開発で Discord サーバ運営の健全化を図る では、Go で Prometheus Exporter を書いて Prometheus Server に scrape させていました。
最近は Grafana のエージェントとして Grafana Alloy を使う構成が増えています。Alloy は Prometheus の scrape や remote_write、OpenTelemetry、ログ収集などを 1 つのバイナリで扱えるテレメトリコレクタで、prometheus.exporter.* という形で多くの exporter を内蔵しています。そこで「自作 exporter を Alloy の世界に持ち込むにはどうするのか」「Alloy の component そのものを Go で書けるのか」を調べて、実際に手を動かしてみました。
コードは下記に置いてあります。
https://github.com/jedipunkz/alloy-playground
結論
自作のメトリクスを Alloy で扱う方法は大きく 2 つあります。
| パターン | 内容 |
|---|---|
A: 独立 exporter + prometheus.scrape |
従来通り Go で exporter を書き、公式の Alloy から HTTP で scrape する |
| B: Alloy を fork して component を追加 | Alloy のソースツリーに internal/component/prometheus/exporter/<name> を足してビルドする |
Alloy の component は Go で書けます。ただし Alloy にはプラグイン機構が無く、exporter 系 component が使う API は internal/ 配下にあるため、パターン B は Alloy を fork してビルドするしかありません。自分のサービス向けの独自メトリクスであれば、運用コストの低いパターン A を選ぶのが良さそうだと感じました。
Alloy のパイプラインのおさらい
Alloy は component をつないでパイプラインを組みます。メトリクスの場合は典型的に次の流れになります。
flowchart LR E[prometheus.exporter.*<br/>targets を export] --> S[prometheus.scrape<br/>targets を scrape] T[任意の HTTP /metrics] --> S S --> R[prometheus.remote_write] R --> P[(Prometheus など)]
prometheus.exporter.* component は自分でメトリクスを送るのではなく、「自分を scrape するための target」を export します。それを prometheus.scrape が受け取って scrape し、prometheus.remote_write で送信します。prometheus.scrape には任意の HTTP endpoint も渡せるので、従来の exporter もそのまま scrape できます。この 2 つの入口がそれぞれパターン A と B に対応します。
なお Alloy には declare ブロックで独自 component を定義する仕組みもありますが、これは既存の component を Alloy の設定言語で組み合わせるもので、Go のコードを足せるわけではありません。
サンプルの構成
両パターンとも、/proc/loadavg を読んで tiny_load_average{period="1m|5m|15m"} を出すだけの同じ collector を使います。docker compose で Prometheus / Alloy / Grafana を起動し、Alloy から Prometheus へ remote_write します。どちらのパターンから来たデータかは external_labels の pattern ラベルで区別します。
flowchart LR
subgraph A[Pattern A]
E[tiny-exporter :9100] -- scrape --> AA[alloy<br/>公式イメージ]
end
subgraph B[Pattern B: --profile fork]
AB[alloy-fork<br/>prometheus.exporter.tiny]
end
AA -- remote_write<br/>pattern=standalone --> P[prometheus]
AB -- remote_write<br/>pattern=fork --> P
P --> G[grafana]
| フォルダ | 内容 |
|---|---|
standalone-exporter/ |
パターン A の Go 製 exporter |
alloy-fork/ |
パターン B の component と、Alloy をソースからビルドする Dockerfile |
config/ |
Alloy の設定ファイルと Grafana の datasource |
compose.yaml |
Prometheus / Grafana / Alloy / exporter |
共通の collector
prometheus.Collector interface を実装した collector です。scrape されるたびに /proc/loadavg を読み、先頭 3 つの値を gauge として返します。
type loadCollector struct {
path string
desc *prometheus.Desc
}
func newLoadCollector(path string) *loadCollector {
return &loadCollector{
path: path,
desc: prometheus.NewDesc("tiny_load_average", "System load average.", []string{"period"}, nil),
}
}
func (c *loadCollector) Describe(ch chan<- *prometheus.Desc) { ch <- c.desc }
func (c *loadCollector) Collect(ch chan<- prometheus.Metric) {
b, err := os.ReadFile(c.path)
if err != nil {
ch <- prometheus.NewInvalidMetric(c.desc, err)
return
}
loads, err := parseLoadavg(string(b))
if err != nil {
ch <- prometheus.NewInvalidMetric(c.desc, err)
return
}
for i, period := range []string{"1m", "5m", "15m"} {
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue, loads[i], period)
}
}
この collector 自体は Alloy に依存しません。ここがポイントで、パターン A と B の違いは「この collector を誰がホストするか」だけです。
パターン A: 独立 exporter + prometheus.scrape
exporter
collector を registry に登録して /metrics で公開するだけの、いつもの exporter です。デフォルトの listen address は 127.0.0.1:9100 にしていて、compose では -listen-address=:9100 を渡してコンテナネットワーク内にだけ公開しています。
func main() {
addr := flag.String("listen-address", "127.0.0.1:9100", "address to expose /metrics on")
loadavg := flag.String("loadavg-path", "/proc/loadavg", "path to loadavg file")
flag.Parse()
reg := prometheus.NewRegistry()
reg.MustRegister(newLoadCollector(*loadavg))
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
srv := &http.Server{
Addr: *addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Printf("listening on %s", *addr)
log.Fatal(srv.ListenAndServe())
}
手元で起動して curl すると次のように返ります。
# HELP tiny_load_average System load average.
# TYPE tiny_load_average gauge
tiny_load_average{period="15m"} 0.08
tiny_load_average{period="1m"} 0.22
tiny_load_average{period="5m"} 0.15
Alloy の設定
公式の grafana/alloy イメージに、exporter を scrape して remote_write する設定を渡すだけです。
prometheus.scrape "tiny" {
targets = [{"__address__" = "tiny-exporter:9100"}]
scrape_interval = "15s"
forward_to = [prometheus.remote_write.local.receiver]
}
prometheus.remote_write "local" {
external_labels = {pattern = "standalone"}
endpoint {
url = "http://prometheus:9090/api/v1/write"
}
}
Prometheus 側は --web.enable-remote-write-receiver を付けて起動し、remote_write を受け付けるようにしています。
パターン B: Alloy を fork して component を追加
component の登録
Alloy 本体の prometheus.exporter.dnsmasq などを読むと、exporter 系 component は次の 3 つで構成されています。
component.Register:init()で component 名・引数の型・build 関数を登録するexporter.New: integration を component に変換し、自分を scrape するためのtargetsを export するintegrations.NewCollectorIntegration:prometheus.Collectorを integration として包む
これに倣って prometheus.exporter.tiny を書くと次のようになります。
func init() {
component.Register(component.Registration{
Name: "prometheus.exporter.tiny",
Stability: featuregate.StabilityExperimental,
Args: Arguments{},
Exports: exporter.Exports{},
Build: exporter.New(createExporter, "tiny"),
})
}
// Arguments configures the prometheus.exporter.tiny component.
type Arguments struct {
LoadavgPath string `alloy:"loadavg_path,attr,optional"`
}
// DefaultArguments holds the default arguments for prometheus.exporter.tiny.
var DefaultArguments = Arguments{LoadavgPath: "/proc/loadavg"}
// SetToDefault implements syntax.Defaulter.
func (a *Arguments) SetToDefault() { *a = DefaultArguments }
func createExporter(opts component.Options, args component.Arguments) (integrations.Integration, string, error) {
a := args.(Arguments)
i := integrations.NewCollectorIntegration("tiny",
integrations.WithLogger(opts.Logger),
integrations.WithCollectors(newLoadCollector(a.LoadavgPath)),
)
// loadavg is host-level, so use the hostname as the instance label.
return i, common.HostNameInstanceKey(), nil
}
要点は次の通りです。
Argumentsの struct tagalloy:"loadavg_path,attr,optional"が、Alloy 設定ファイルで書ける属性になるSetToDefaultで属性を省略したときのデフォルト値を決めるcreateExporterの 2 つ目の戻り値がinstanceラベルになる。loadavg はホスト単位の値なのでホスト名を使ったjobラベルはexporter.Newによってintegrations/tinyになるStabilityをStabilityExperimentalにしたので、起動時に--stability.level=experimentalが必要になる
Arguments が Alloy の設定言語として正しく読めるかは、Alloy の syntax.Unmarshal を使ってテストできます。
func TestUnmarshalAlloy(t *testing.T) {
var args Arguments
if err := syntax.Unmarshal([]byte(``), &args); err != nil {
t.Fatal(err)
}
if args != DefaultArguments {
t.Fatalf("got %+v, want defaults", args)
}
if err := syntax.Unmarshal([]byte(`loadavg_path = "/host/proc/loadavg"`), &args); err != nil {
t.Fatal(err)
}
if args.LoadavgPath != "/host/proc/loadavg" {
t.Fatalf("got %q", args.LoadavgPath)
}
}
component を有効にするには、Alloy の internal/component/all パッケージから blank import されている必要があります。本体の all.go を編集すると upstream 追従時に conflict しやすいので、別ファイルとして足しています。
package all
// Copied into the Alloy source tree as internal/component/all/tiny.go.
import (
_ "github.com/grafana/alloy/internal/component/prometheus/exporter/tiny" // Import prometheus.exporter.tiny
)
なぜ fork が必要か
上のコードは github.com/grafana/alloy/internal/... を import しています。Go の仕様で internal/ 配下のパッケージは、その親ディレクトリ以下からしか import できません。つまり別モジュールから prometheus.exporter.tiny だけを提供することはできず、Alloy のソースツリーにファイルを置いてビルドする必要があります。
ビルド
Alloy v1.21.1 の make alloy を読むと、実体は collector/ ディレクトリ(OpenTelemetry Collector Builder で生成された main パッケージ)での go build でした。Dockerfile では upstream を clone し、ファイルをコピーして同じ場所でビルドしています。UI のビルドは省いているので、このバイナリの Web UI は使えず API のみになります。
FROM golang:1.27.3 AS build
ARG ALLOY_VERSION=v1.21.1
RUN git clone --depth 1 --branch ${ALLOY_VERSION} https://github.com/grafana/alloy.git /src/alloy
WORKDIR /src/alloy
COPY tiny internal/component/prometheus/exporter/tiny
COPY all_tiny.go internal/component/all/tiny.go
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
cd collector && CGO_ENABLED=0 go build -tags "netgo gore2regex" -o /out/alloy .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/alloy /bin/alloy
ENTRYPOINT ["/bin/alloy"]
Alloy の設定
自作 component を通常の component と同じように書けます。prometheus.exporter.tiny.host.targets を prometheus.scrape に渡すのは、本体の exporter component と同じ使い方です。
prometheus.exporter.tiny "host" {
loadavg_path = "/proc/loadavg"
}
prometheus.scrape "tiny" {
targets = prometheus.exporter.tiny.host.targets
scrape_interval = "15s"
forward_to = [prometheus.remote_write.local.receiver]
}
prometheus.remote_write "local" {
external_labels = {pattern = "fork"}
endpoint {
url = "http://prometheus:9090/api/v1/write"
}
}
ビルドしたバイナリをこの設定で起動し、Alloy の HTTP server から component 単体のメトリクスを取ると次のようになりました(ビルド直後でロードアベレージが高くなっています)。
./alloy run --server.http.listen-addr=127.0.0.1:12345 --stability.level=experimental config/alloy/fork.alloy
curl -s http://127.0.0.1:12345/api/v0/component/prometheus.exporter.tiny.host/metrics
tiny_build_info{branch="",goarch="amd64",goos="linux",goversion="go1.27.3",revision="bd570c68ab36fd1328a7d50e79a4e70932a84c54-modified",tags="netgo,gore2regex",version="v1.21.1-devel"} 1
tiny_load_average{period="15m"} 8.7
tiny_load_average{period="1m"} 25.21
tiny_load_average{period="5m"} 20.46
tiny_build_info は自分では書いていません。NewCollectorIntegration の metrics handler が <integration 名>_build_info を自動で登録するためです。exporter のプロセスやポートを別に用意しなくても、Alloy の HTTP server 経由でメトリクスが出ていることが分かります。
docker compose で動かす
パターン A だけを起動する場合は次の通りです。
docker compose up -d --build
パターン B は Alloy 全体をソースからビルドするので、compose の profile で分けています。
docker compose --profile fork up -d --build
| URL | 内容 |
|---|---|
| http://127.0.0.1:3000 | Grafana。Explore で tiny_load_average を検索する |
| http://127.0.0.1:9090 | Prometheus |
| http://127.0.0.1:12345 | Alloy (パターン A) の UI |
| http://127.0.0.1:12346 | Alloy (パターン B)。UI 無し、API のみ |
ポートはすべて 127.0.0.1 に bind しています。
比較
パターン A: 独立 exporter + prometheus.scrape |
パターン B: Alloy を fork して component 追加 | |
|---|---|---|
| Alloy 本体 | 公式イメージをそのまま使える。upgrade はタグを上げるだけ | 自前ビルド。upgrade ごとに upstream へ追従して再ビルドが必要 |
| 依存する API | client_golang の公開 API のみ |
Alloy の internal/ API。リリース間の互換性の保証が無い |
| ビルド (実測) | 依存 module 33 個、バイナリ 14MB | Alloy 全体のビルドが必要。依存 module 2043 個、バイナリ 536MB |
| プロセス / ネットワーク | exporter のプロセスとポートが増える。exporter 側の公開範囲を管理する必要がある | 単一バイナリ。追加ポート不要 (Alloy の HTTP server 経由) |
| 設定 | exporter の flag / env と Alloy の設定が分かれる | Alloy 設定の属性で渡せ、他 component の出力を式で参照できる |
| 再利用性 | Prometheus 単体や他のエージェントからも scrape できる | Alloy 専用 |
| 可観測性 | exporter の状態は up メトリクスで見る |
Alloy の UI / health に component として出る |
| 脆弱性対応 | Grafana の公式リリースに任せられる | 自分で再ビルドして配布する |
ビルドの差は想像以上でした。パターン B は 1 つの component を足すだけでも Alloy 全体(AWS / Azure / Datadog などの SDK を含む 2000 以上の module)をビルドすることになり、初回は依存のダウンロードだけでかなり時間がかかります。
判断の目安としては次のように考えています。
- 自分のサービス向けの独自メトリクスならパターン A。exporter は Alloy 以外からも使え、Alloy の upgrade にも縛られない
- 既存の OSS exporter を Alloy に統合したい、あるいは upstream に contribute したいならパターン B。Alloy 本体の
prometheus.exporter.*も、既存 exporter の collector パッケージをこの形で包んでいる(例:internal/static/integrations/dnsmasq_exporterはgithub.com/google/dnsmasq_exporter/collectorを包んでいる)
まとめ
Alloy の component は Go で書け、component.Register / exporter.New / NewCollectorIntegration の 3 つを押さえれば、既存の prometheus.Collector を数十行で component にできました。一方で internal/ に依存するため fork とフルビルドが前提になり、個人やチームで運用するにはコストが大きいです。自作 exporter を Alloy で扱うだけであれば、これまで通り exporter を書いて prometheus.scrape で scrape するのが一番シンプルだと感じました。