はじめに
概要
この記事では OpenTelemetry Collector を用いてアプリケーションの Metric/Trace/Log を Grafana に連携する方法を紹介します。
尚 OpenTelemetry の説明については以下のドキュメントを参照してください。
今回は以下の構成を docker compose を利用して構築したいと思います。
- App … Golang で実装したアプリケーションになります(参考)。送信は SDK を使って OTLPHTTP で行います
- OpenTelemetry Collector … Metric/Trace/Log を App から受け取り各コンポーネントに送信します
- Loki/Tempo/Mimir/Grafana … Grafana とそれに付随するシステムです、詳細は公式のドキュメントを確認してください
動機
OpenTelemetry SDK を用いると OTLP 形式で Metric/Trace/Log を送信することが可能で、OpenTelemetry Collector と Grafana の各コンポーネント(loki/tempo)は OTLP に対応しています。このことから OpenTelemetry SDK と Collector のみで Metric/Trace/Log の送信も可能ということになります。
しかし、現状 OpenTelemetry SDK を用いてアプリの Metric/Trace/Log を収集する記事は多くありますが、収集の際に OpenTelemetry Collector のみで送信しているものがあまりありませんでした。そこで、収集を複数ツールで行う際に発生する構成管理の複雑化を OpenTelemetry Collector に統一することで回避できるのではないかと考え、検証を行い記事にしようと考えました。
合わせて再現難易度を下げるため docker compose を用いて構築するようにしています。
注意事項
今回はあくまで検証目的での構築になります。
そのため以下の内容は考慮していません。
- データの永続化
- 耐障害性
- 認証
実際に運用する場合は、上記も考慮して構成してください。
手順
それでは、構成を作成していきましょう。
流れとしては「事前準備 → App作成 → compose.yaml と設定ファイル作成 → 動かす」で完了します。
事前準備
docker 以外に以下のものは既にインストールされている想定で初めていきます。
- ko … golang のアプリケーションの docker image が作成しやすくなります
App作成
golang のアプリケーションを作成していきます。
基本的には OpenTelemetry の公式の Getting Started で紹介されているもので、エンドポイントを叩かれたら出目を返すアプリです。変更点としては以下になります。
- otel.go … stdout に流しているものを otlphttp で流しています
- rolldice.go … metric と trace にdiceを振った人の名前も記録するようにしています
# module作成
mkdir dice
cd dice
go mod init dice
# 必要なライブラリ取得
go get "go.opentelemetry.io/otel" \
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" \
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" \
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" \
"go.opentelemetry.io/otel/sdk/log" \
"go.opentelemetry.io/otel/log/global" \
"go.opentelemetry.io/otel/propagation" \
"go.opentelemetry.io/otel/sdk/metric" \
"go.opentelemetry.io/otel/sdk/resource" \
"go.opentelemetry.io/otel/sdk/trace" \
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"\
"go.opentelemetry.io/contrib/bridges/otelslog" \
"go.opentelemetry.io/otel/semconv/v1.26.0"
otel.go
Metric/Trace/Log の Provider の設定をしていきます。
今回は oltphttp で送信します。
otel.go
package main
import (
"context"
"errors"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func setupOTelSDK(ctx context.Context) (shutdown func(context.Context) error, err error) {
var shutdownFuncs []func(context.Context) error
shutdown = func(ctx context.Context) error {
var err error
for _, fn := range shutdownFuncs {
err = errors.Join(err, fn(ctx))
}
shutdownFuncs = nil
return err
}
handleErr := func(inErr error) {
err = errors.Join(inErr, shutdown(ctx))
}
prop := newPropagator()
otel.SetTextMapPropagator(prop)
res, err := newResource()
if err != nil {
handleErr(err)
return
}
tracerProvider, err := newTraceProvider(ctx, res)
if err != nil {
handleErr(err)
return
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
meterProvider, err := newMeterProvider(ctx, res)
if err != nil {
handleErr(err)
return
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider)
loggerProvider, err := newLoggerProvider(ctx, res)
if err != nil {
handleErr(err)
return
}
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
global.SetLoggerProvider(loggerProvider)µ
return
}
func newPropagator() propagation.TextMapPropagator {
return propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
}
func newTraceProvider(ctx context.Context, res *resource.Resource) (*trace.TracerProvider, error) {
traceExporter, err := otlptracehttp.New(ctx)
if err != nil {
return nil, err
}
traceProvider := trace.NewTracerProvider(
trace.WithResource(res),
trace.WithBatcher(traceExporter,
trace.WithBatchTimeout(time.Second)),
)
return traceProvider, nil
}
func newMeterProvider(ctx context.Context, res *resource.Resource) (*metric.MeterProvider, error) {
metricExporter, err := otlpmetrichttp.New(ctx)
if err != nil {
return nil, err
}
meterProvider := metric.NewMeterProvider(
metric.WithResource(res),
metric.WithReader(metric.NewPeriodicReader(metricExporter,
metric.WithInterval(3*time.Second))),
)
return meterProvider, nil
}
func newResource() (*resource.Resource, error) {
return resource.Merge(resource.Default(),
resource.NewWithAttributes(semconv.SchemaURL,
semconv.ServiceName("dice"),
semconv.ServiceVersion("0.1.0"),
))
}
func newLoggerProvider(ctx context.Context, res *resource.Resource) (*log.LoggerProvider, error) {
logExporter, err := otlploghttp.New(ctx)
if err != nil {
return nil, err
}
loggerProvider := log.NewLoggerProvider(
log.WithResource(res),
log.WithProcessor(log.NewBatchProcessor(logExporter)),
)
return loggerProvider, nil
}
rolldice.go
実際に dice の目を返す部分です。
実行したユーザ名とともに Metric/Trace/Log を適宜作成しています。
rolldice.go
package main
import (
"fmt"
"io"
"log"
"math/rand"
"net/http"
"strconv"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
const name = "dice-app"
var (
tracer = otel.Tracer(name)
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
rollCnt metric.Int64Counter
)
func init() {
var err error
rollCnt, err = meter.Int64Counter("dice.rolls",
metric.WithDescription("The number of rolls by roll value"),
metric.WithUnit("{roll}"))
if err != nil {
panic(err)
}
}
func rolldice(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "roll")
defer span.End()
roll := 1 + rand.Intn(6)
var msg string
var playerName = "anonymous"
if player := r.PathValue("player"); player != "" {
msg = fmt.Sprintf("%s is rolling the dice", player)
playerName = player
} else {
msg = "Anonymous player is rolling the dice"
}
logger.InfoContext(ctx, msg, "result", roll, "player", playerName)
if roll > 3 {
logger.WarnContext(ctx, "Roll is greater than 3", "roll", roll, "player", playerName)
} else {
logger.ErrorContext(ctx, "Roll is less than 3", "roll", roll, "player", playerName)
}
rollValueAttr := attribute.Int("roll.value", roll)
playerAttr := attribute.String("player", playerName)
span.SetAttributes(rollValueAttr)
span.SetAttributes(playerAttr)
rollCnt.Add(ctx, 1, metric.WithAttributes(rollValueAttr, playerAttr))
resp := strconv.Itoa(roll) + "\n"
if _, err := io.WriteString(w, resp); err != nil {
log.Printf("Write failed: %v\n", err)
}
}
main.go
http の handler の設定などを実施しています。
main.go
package main
import (
"context"
"errors"
"log"
"net"
"net/http"
"os"
"os/signal"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func main() {
if err := run(); err != nil {
log.Fatalln(err)
}
}
func run() (err error) {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
otelShutdown, err := setupOTelSDK(ctx)
if err != nil {
return
}
defer func() {
err = errors.Join(err, otelShutdown(context.Background()))
}()
srv := &http.Server{
Addr: ":8080",
BaseContext: func(_ net.Listener) context.Context { return ctx },
ReadTimeout: time.Second,
WriteTimeout: 10 * time.Second,
Handler: newHTTPHandler(),
}
srvErr := make(chan error, 1)
go func() {
srvErr <- srv.ListenAndServe()
}()
select {
case err = <-srvErr:
return
case <-ctx.Done():
stop()
}
err = srv.Shutdown(context.Background())
return
}
func newHTTPHandler() http.Handler {
mux := http.NewServeMux()
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
handler := otelhttp.WithRouteTag(pattern, http.HandlerFunc(handlerFunc))
mux.Handle(pattern, handler)
}
handleFunc("/rolldice/", rolldice)
handleFunc("/rolldice/{player}", rolldice)
handler := otelhttp.NewHandler(mux, "/")
return handler
}
compose.yaml
このタイミングで App を compose.yaml に記載します。
compose.yaml
services:
# 情報収集用のアプリ
dice-app:
image: ko.local/dice:latest
platform: linux/amd64
ports:
- 8080:8080
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
compose.yaml 修正と設定ファイル作成(Grafana関連)
Loki/Grafana/Tempo/Mimir を構築するための compose.yaml と各設定ファイルを記載します(設定ファイルは config
ディレクトリ以下に配置)。
基本的に各コンポーネントのリポジトリの example 以下のままですが、注意点として以下を記載します。
- mimir … LoadBalancer を nginx で作成しており、 mimir は LB 経由で呼び出されます
- loki … OTLP を受け入れるために latest ではなく明示的に 3.1.0 にしています
compose.yaml
App について記載したものに追記している前提になります。
compose.yaml
# grafana
grafana:
environment:
- GF_PATHS_PROVISIONING=/etc/grafana/provisioning
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_FEATURE_TOGGLES_ENABLE=traceqlEditor traceQLStreaming metricsSummary
image: grafana/grafana:latest
volumes:
- ./config/datasource.yaml:/etc/grafana/provisioning/datasources/datasource.yaml
ports:
- 3000:3000
# mimir関連
minio:
image: minio/minio
entrypoint: [ "" ]
command: [ "sh", "-c", "mkdir -p /data/mimir && minio server --quiet /data" ]
environment:
- MINIO_ROOT_USER=mimir
- MINIO_ROOT_PASSWORD=supersecret
mimir-lb:
image: nginx:latest
volumes:
- ./config/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- "mimir-1"
- "mimir-2"
- "mimir-3"
ports:
- 9009:9009
mimir-1:
image: grafana/mimir:latest
command: [ "-config.file=/etc/mimir.yaml", "-auth.multitenancy-enabled=false" ]
hostname: mimir-1
depends_on:
- minio
volumes:
- ./config/mimir.yaml:/etc/mimir.yaml
mimir-2:
image: grafana/mimir:latest
command: [ "-config.file=/etc/mimir.yaml", "-auth.multitenancy-enabled=false" ]
hostname: mimir-2
depends_on:
- minio
volumes:
- ./config/mimir.yaml:/etc/mimir.yaml
mimir-3:
image: grafana/mimir:latest
command: [ "-config.file=/etc/mimir.yaml", "-auth.multitenancy-enabled=false" ]
hostname: mimir-3
depends_on:
- minio
volumes:
- ./config/mimir.yaml:/etc/mimir.yaml
# tempo関連(metric_generatorのためにprometheusもいる)
tempo:
image: grafana/tempo:latest
command: [ "-config.file=/etc/tempo.yaml" ]
volumes:
- ./config/tempo.yaml:/etc/tempo.yaml
ports:
- "3200:3200" # tempo
- "9095:9095" # tempo grpc
prometheus:
image: prom/prometheus:latest
command:
- --config.file=/etc/prometheus.yaml
- --web.enable-remote-write-receiver
- --enable-feature=exemplar-storage
- --enable-feature=native-histograms
volumes:
- ./config/prometheus.yaml:/etc/prometheus.yaml
ports:
- "9090:9090"
# loki
loki:
image: grafana/loki:3.1.0
ports:
- 3100:3100
volumes:
- ./config/loki.yaml:/etc/loki/local-config.yaml
command: -config.file=/etc/loki/local-config.yaml
datasource.yaml
grafana の datasource の設定をあらかじめ入れておきます。
datasource.yaml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
uid: prometheus
access: proxy
orgId: 1
url: http://prometheus:9090
basicAuth: false
isDefault: false
version: 1
editable: false
jsonData:
httpMethod: GET
- name: Loki
type: loki
access: proxy
orgId: 1
url: http://loki:3100
basicAuth: false
isDefault: true
version: 1
editable: false
- name: Tempo
type: tempo
access: proxy
orgId: 1
url: http://tempo:3200
basicAuth: false
isDefault: false
version: 1
editable: false
apiVersion: 1
uid: tempo
jsonData:
httpMethod: GET
serviceMap:
datasourceUid: prometheus
lokiSearch:
datasourceUid: 'loki'
tracesToLogs:
datasourceUid: 'loki'
tags: [ 'container' ]
mappedTags: [ { key: 'service.name', value: 'dice' } ]
mapTagNamesEnabled: true
spanStartTimeShift: '-1h'
spanEndTimeShift: '1h'
filterByTraceID: true
filterBySpanID: false
- name: Mimir
type: prometheus
access: proxy
orgId: 1
url: http://mimir-lb:9009/prometheus
version: 1
editable: true
isDefault: false
loki.yaml
loki の設定ファイルです。
loki.yaml
auth_enabled: false
server:
http_listen_port: 3100
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
ruler:
alertmanager_url: http://localhost:9093
limits_config:
allow_structured_metadata: true
mimir.yaml
mimir の設定ファイルです。
mimir.yaml
# Do not use this configuration in production.
# It is for demonstration purposes only.
# Run Mimir in single process mode, with all components running in 1 process.
target: all,overrides-exporter
# Configure Mimir to use Minio as object storage backend.
common:
storage:
backend: s3
s3:
endpoint: minio:9000
access_key_id: mimir
secret_access_key: supersecret
insecure: true
bucket_name: mimir
# Blocks storage requires a prefix when using a common object storage bucket.
blocks_storage:
storage_prefix: blocks
tsdb:
dir: /data/ingester
# Use memberlist, a gossip-based protocol, to enable the 3 Mimir replicas to communicate
memberlist:
join_members: [mimir-1, mimir-2, mimir-3]
server:
log_level: warn
nginx.conf
mimir の LoadBalancer 用の nginx の設定ファイルです。
nginx.conf
events {
worker_connections 1024;
}
http {
upstream backend {
server mimir-1:8080 max_fails=1 fail_timeout=1s;
server mimir-2:8080 max_fails=1 fail_timeout=1s;
server mimir-3:8080 max_fails=1 fail_timeout=1s backup;
}
server {
listen 9009;
access_log /dev/null;
location / {
proxy_pass http://backend;
}
}
}
tempo.yaml
tempo の設定ファイルです。
tempo.yaml
stream_over_http_enabled: true
server:
http_listen_port: 3200
log_level: info
query_frontend:
search:
duration_slo: 5s
throughput_bytes_slo: 1.073741824e+09
trace_by_id:
duration_slo: 5s
distributor:
receivers:
otlp:
protocols:
http:
metrics_generator:
storage:
path: /var/tempo/generator/wal
remote_write:
- url: http://prometheus:9090/api/v1/write
send_exemplars: true
traces_storage:
path: /var/tempo/generator/traces
ingester:
max_block_duration: 5m # cut the headblock when this much time passes. this is being set for demo purposes and should probably be left alone normally
compactor:
compaction:
block_retention: 1h # overall Tempo trace retention. set for demo purposes
storage:
trace:
backend: local # backend configuration to use
wal:
path: /var/tempo/wal # where to store the wal locally
local:
path: /var/tempo/blocks
overrides:
defaults:
metrics_generator:
processors: [service-graphs, span-metrics, local-blocks] # enables metrics generator
generate_native_histograms: both
prometheus.yaml
tempo で利用する prometheus の設定ファイルです。
prometheus.yaml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: [ 'localhost:9090' ]
- job_name: 'tempo'
static_configs:
- targets: [ 'tempo:3200' ]
OpenTelemetry Collector 関係の設定追加
OpenTelemetry Collector を docker compose 上で稼働させるための設定を作成します( otelcol.yaml は config
ディレクトリ以下に配置)。
compose.yaml は上記のものに追記している前提になります。
compose.yaml
compose.yaml
# opentelemetry collector
otel-collector:
image: otel/opentelemetry-collector-contrib
volumes:
- ./config/otelcol.yaml:/etc/otelcol-contrib/config.yaml
ports:
- 1888:1888 # pprof extension
- 13133:13133 # health_check extension
- 55679:55679 # zpages extension
- 12345:12345 # prometheus
otelcol.yaml
otelcol.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
batch:
exporters:
debug:
otlphttp/loki:
endpoint: http://loki:3100/otlp
otlphttp/tempo:
endpoint: http://tempo:4318
otlphttp/mimir:
endpoint: http://mimir-lb:9009/otlp
service:
extensions: [health_check, pprof, zpages]
pipelines:
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/loki, debug]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/mimir, debug]
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/tempo, debug]
extensions:
health_check:
pprof:
zpages:
実行
準備はできたので実行します。
# Appビルド&docker image作成
## ko.local/dice:latest というimageが作成される
ko publish -L --base-import-paths .
# image確認
docker images ko.local/dice:latest
# 起動
docker compose up -d
docker compose ps
# Appにリクエスト投げる
curl localhost:8080/rolldice/
curl localhost:8080/rolldice/alice/
grafana で確認
問題なく起動できたら localhost:3000 にアクセスして grafana に接続してみましょう。
datasource の設定はできていると思うので、 dashboard の作成が可能になっているかと思います。
まとめ
今回の検証で以下のことが確認できました。
- OpenTelemetry SDK で App に作り込むと OpenTelemetry Collector のみで metric/trace/log が収集できる事
- 検証環境程度だったら docker compose で気軽に作成することが可能な事
また OpenTelemetry の SDK および Collector を用いることで以下のメリットと懸念点があると考えています。
- メリット
- metric/log/trace をアプリから Grafana 等の観測できるアプリケーションに送る際、利用するものが OpenTelemetry に統一される(sdk と collector)ため構築が煩雑にならない
- OpenTelemetry SDK を使い metric/log/trace を出力するとそれぞれの関連付けが容易になる
- 懸念点
- OpenTelemetry SDK や Collector には一部 experimantal や beta の機能があるので、今後変更が入る可能性の考慮が必要
- アプリケーションが既に出来上がっている場合、今回の構成だと OpenTelemetry の SDK を噛ませる必要があるので、導入コストが高いかもしれない
これらを検討しつつ、アプリケーションを開発していく場合は導入を検討してみてはいかがでしょうか?
参考
Grafana関連
- 実際にリポジトリの example を参考にしました