[Grafana Tempo] Tempo를 통한 Trace 관리
Tempo는 Trace 관리를 위한 오픈소스 시스템 중 하나다.
예를 들어 웹서버를 굴린다고 하면, API 요청 하나하나의 기록을 Trace라고 부른다.
API 요청들의 히스토리와 성공/실패 정보 등을 기록하고 보는 용도로 사용하는 것이다.
Tempo는 자체로도 독립적으로 쓸 수는 있는 일종의 데이터베이스 역할도 하고, trace를 받아서 저장하는 서버 역할도 겸한다. Tempo의 저장용 블록 시스템은 TempoDB라고 한다. 저장소 백엔드는 커스텀 가능하다.
근데 tempo만을 독립적으로 사용하는 경우는 잘 없고, metric을 보관하는 용도로 prometheus를 함께 사용하는게 국룰이다. 하지만 여기서는 다 제외하고 Tempo만을 중점적으로 다루겠다.
Tempo는 Opentelemetry 규격도 만족하기 때문에 otel 에이전트 환경만 있으면 꽤 편하게 연동하고 사용할 수 있다.
Tempo 구성하기 (with Docker Compose)
Docker compose를 사용해서 단일 로컬 환경에서의 tempo 환경을 간단하게 구축해보겠다.
예제 코드 전체는 여기있다.
https://github.com/myyrakle/infrastructures/tree/master/docker-compose/grafana-and-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
metadata_slo:
duration_slo: 5s
throughput_bytes_slo: 1.073741824e+09
trace_by_id:
duration_slo: 5s
metrics:
max_duration: 120h
query_backend_after: 5m
duration_slo: 5s
throughput_bytes_slo: 1.073741824e+09
distributor:
receivers:
otlp:
protocols:
http:
endpoint: "0.0.0.0:4318"
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
metrics_generator:
registry:
external_labels:
source: tempo
cluster: docker-compose
traces_storage:
path: /var/tempo/generator/traces
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
제대로 하면 여기서 metric 저장소로 prometheus도 엮어서 쓰고 그러는데, 최대한 단순하게 구성해놨다.
Dockerfile도 파서 yaml을 넣게 하고
FROM grafana/tempo:latest
USER root
RUN chown 10001:10001 /var/tempo
COPY ./tempo.yaml /etc/tempo.yaml
USER 10001
docker compose yaml을 작성해서 이제 띄워보겠다.
services:
tempo:
build:
context: .
dockerfile: Dockerfile.tempo
user: root
command: [ "-config.file=/etc/tempo.yaml" ]
volumes:
- ./tempo.yaml:/etc/tempo.yaml
- tempo_data:/var/tempo
ports:
- "4317:4317"
- "4318:4318"
grafana:
environment:
- GF_PATHS_PROVISIONING=/etc/grafana/provisioning
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
entrypoint:
- sh
- -euc
- |
mkdir -p /etc/grafana/provisioning/datasources
cat <<EOF > /etc/grafana/provisioning/datasources/ds.yaml
apiVersion: 1
datasources:
- name: Tempo
type: tempo
access: proxy
orgId: 1
url: http://tempo:3200
basicAuth: false
isDefault: true
version: 1
editable: false
apiVersion: 1
uid: tempo
jsonData:
httpMethod: GET
serviceMap:
datasourceUid: prometheus
streamingEnabled:
search: true
EOF
/run.sh
image: grafana/grafana:latest
ports:
- "13000:3000"
volumes:
- grafana_data:/var/lib/grafana
- grafana_etc:/etc/grafana
volumes:
grafana_data:
driver: local
grafana_etc:
driver: local
tempo_data:
driver: local
실행하고, 별다른 오류 없이 그라파나 대시보드도 잘 뜨면 된 것이다.

Agent 구성 (Go, Echo)
이제 저 Tempo에 Trace를 쌓아보자.
예제 코드는 Go, Echo 환경이다.
특별한 이유는 없고, 어떤 언어든 otel sdk 라이브러리가 있으면 사용법 자체는 대동소이할 것이다.
package main
import (
"context"
"log"
"net/http"
"time"
"github.com/labstack/echo/v4"
"go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho"
"go.opentelemetry.io/contrib/instrumentation/runtime"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
otelTrace "go.opentelemetry.io/otel/trace"
)
func initTracer() *trace.TracerProvider {
ctx := context.Background()
// Create the OTLP traceExporter
traceExporter, err := otlptracehttp.New(ctx, otlptracehttp.WithEndpointURL(
"http://localhost:4318",
), otlptracehttp.WithInsecure())
if err != nil {
panic(err)
}
// Create the tracer provider
traceProvider := trace.NewTracerProvider(
trace.WithBatcher(traceExporter),
trace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String("go-server"),
semconv.ServiceNamespaceKey.String("dev"),
)),
)
// Set the global tracer provider
otel.SetTracerProvider(traceProvider)
err = runtime.Start(runtime.WithMinimumReadMemStatsInterval(time.Second))
if err != nil {
panic(err)
}
return traceProvider
}
func main() {
traceProvider := initTracer()
tracer := traceProvider.Tracer("foo")
defer func() {
if err := traceProvider.Shutdown(context.Background()); err != nil {
log.Fatalf("failed to shutdown tracer: %v", err)
}
}()
e := echo.New()
e.Use(otelecho.Middleware("go-server"))
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := next(c)
span := otelTrace.SpanFromContext(c.Request().Context())
if span != nil {
responseStatusCode := c.Response().Status
spanStatus := codes.Ok
if responseStatusCode >= 400 {
spanStatus = codes.Error
}
span.SetStatus(spanStatus, "")
}
return err
}
})
e.GET("/trace", func(c echo.Context) error {
_, span := tracer.Start(c.Request().Context(), "test-span")
time.Sleep(200 * time.Millisecond)
span.End()
_, span = tracer.Start(c.Request().Context(), "test-span2")
time.Sleep(500 * time.Millisecond)
span.End()
return c.String(http.StatusOK, "trace completed")
})
e.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "Hello, World!")
})
e.GET("/foo", func(c echo.Context) error {
time.Sleep(1 * time.Second)
return c.String(http.StatusOK, "foo")
})
e.GET("/bar", func(c echo.Context) error {
time.Sleep(2 * time.Second)
return c.String(http.StatusOK, "bar")
})
e.GET("/not-found", func(c echo.Context) error {
time.Sleep(2 * time.Second)
return c.String(http.StatusNotFound, "NOT FOUND")
})
e.GET("/internal", func(c echo.Context) error {
time.Sleep(2 * time.Second)
return c.String(http.StatusInternalServerError, "INTERNAL SERVER ERROR")
})
e.Logger.Fatal(e.Start(":1323"))
}
코드를 하나씩 보면

이게 기본적인 설정 부분이다.
OTEL 포맷으로 HTTP 연결을 하고, Service명과 Namespace를 전달해줬다.
Namespace는 통상적으로 k8s의 네임스페이스를 위한거긴 한데, stage 등에 활용해도 좋을 것이다.
그리고 미들웨어를 적당히 등록한다.
API 호출 단위 Span에 대해서 추가적인 설정을 하고싶다면 위 코드의 두번째 미들웨어처럼 이래저래 조작하면 된다. 내 경우에는 Span Status에 대한 성공/실패 처리를 기본으로 해주고싶어서 미들웨어 처리를 좀 했다.
그리고 실행해서 API를 이래저래 찔러보면 Tempo에 Trace 기록이 쌓일 것이다.
이제 쌓인걸 대시보드를 통해 조회해보자.
Grafana 대시보드 구성
이제 Tempo로 쌓인 Trace를 대시보드로 조회해보자.
그냥 남들이 만들어놓은 대시보드 템플릿 갖다써도 되는데, 일단 여기서는 직접 깎아보자.
일반적으로 사용되는건 Table Panel이다.
고르고

{resource.service.name="go-server"}
데이터소스는 Tempo로. 쿼리도 적당히 짜준다.
저 표현식은 TraceQL이라고 부르는건데, 여기서는 저 서비스명만 필터링하도록 했다. 필터값은 Grafana Variable 기반으로 구멍을 뚫어 쓸 수도 있을 것이나, 여기서는 다루지 않는다.
https://blog.naver.com/sssang97/223755563107
그리고 조회해보면
이런 식으로 Trace가 잘 쌓인 것을 볼 수 있을 것이다.
기본값이 Trace긴 한데, 원한다면 테이블 모드를 Span으로 바꿔서 볼 수도 있다.
저 아래쪽에 Table Format이다.
그리고 바꿔서 조회해보면 이렇게 바뀐다.
대체로는 Trace보단 Span이 정보가 더 많아서 유용한 편이다.
테이블 컬럼을 숨기거나 노출시키는건 오른쪽 Overrides에서 조정할 수 있다.


Span 단위 활용하기: Attribute
이번에는 Span 단위를 적극 활용해서 Trace에 좀더 상세한 정보를 심고 조회하는 방법들을 몇가지 다뤄보겠다.
사실 Trace는 별 정보도 없는 껍데기, 그룹에 불과할 뿐이다. 실질적인 정보는 전부 Span에 담기며, 하나의 Trace는 무조건 하나 이상의 Span으로 구성된다.
추가적인 Span 단위가 필요하다면 최상위 Span에 child Span들이 Tree 형태로 뻗어나가는 형태로 사용된다.
Span들에는 이런저런 값들을 마구잡이로 구겨넣을 수 있다. 별다른 제한도 없고, 미리 지정해둘 필요도 없다.
이렇게 값을 넣고 찔러보면
실제로도 조회 대상에 잘 들어가며, 필터값으로도 사용할 수 있다.
Child Span
Span은 여러개의 Span을 자식으로 가질 수 있다.
이를 통해 기능의 하위 단위들이 얼마나 레이턴시를 잡아먹고, 어떤 컨텍스트를 가지고 있는지를 추적할 수 있다.
사용법은 그다지 어려울건 없다.

이런 식으로, 컨텍스트 기반으로 span을 생성해서 이름 짓고, Attribute 넣을거 있으면 넣고, 다 끝나면 End로 종료를 하면 되는 것이다.
그리고 찔러보면

이런 식으로 하위 Span들이 생성된 것을 볼 수 있다.
기타 응용
Span 기반으로 테이블을 만들어서 보면, 자질구레한 Child Span까지 노출되는게 좀 불편할 수 있다.
그럴 때는 미들웨어에서 최상위 Span을 나타내는 레이블 값을 구겨넣고

그걸로 필터링해서 볼 수 있다.

그리고 이외의 Span 속성들도 열에 자유롭게 노출할 수 있다.
예를 들어, Status Code는 대부분이 궁금해할만한 속성이다.

이런 느낌으로 쓰면 된다.
참조
https://grafana.com/docs/tempo/latest/getting-started/docker-example/
https://grafana.com/docs/tempo/latest/api_docs/
https://grafana.com/docs/tempo/latest/configuration/