openTelemetry는 기존 파편화되어 존재하던 애플리케이션 계측, 원격 데이터 생성, 수집, 전송을 통합하고 표준화하는 프로젝트입니다. openTelemetry에는 다양한 개념이 있습니다. 개념이 너무 방대하기 때문에 포스팅에서는 개념 위주의 설명보다 실습 기반으로 golang의 gRPC의 경우에서의 openTelemetry의 분산 트레이싱을 관찰해 봅니다. gRPC는 스트리밍 api를 재외하고 unary로만 진행했습니다. golang으로 opentelemetry sdk와 opentelemetry collector를 이용해서 zipkin으로 tracing을 해보는 실습 hands-on을 서술합니다.
위와 같은 구성으로 구현합니다. grpc.client와 grpc.server에서 수집한 트레이싱 데이터를 openTelemetry collector container로 수집하고 zipkin으로 전달하여 시각화합니다. 디렉터리 구조는 다음과 같습니다.
.
├── api
│ ├── api.pb.go
│ ├── api.proto
│ └── api_grpc.pb.go
├── cmd
│ ├── client
│ │ └── client.go
│ └── server
│ └── server.go
├── go.mod
├── go.sum
├── internal
│ ├── client.go
│ └── telemetry.go
├── makefile
└── otel-config.yaml
다루는 기술은 다음과 같습니다.
- golang
- docker
- openTelemetry
- gRPC
- zipkin
- makefile
해당 프로젝트의 주소입니다.
https://github.com/atgane/golang-otel-grpc-test
GitHub - atgane/golang-otel-grpc-test
Contribute to atgane/golang-otel-grpc-test development by creating an account on GitHub.
github.com
1. protobuffer, makefile, openTelemetry collector 설정
go mod init main으로 초기화했습니다.
이후 /api/api.proto 파일을 다음과 같이 작성합니다.
syntax = "proto3";
option go_package = "github.com/atgane/zrpc-server/api";
service Data {
rpc Get(GetRequest) returns (GetResponse) {}
}
message GetRequest {
string key = 1;
}
message GetResponse {
string key = 1;
}
/makefile을 다음과 같이 작성합니다. make 명령어로 openTelemetry와 zipkin 컨테이너를 구동시키는 명령과 protobuf파일을 golang으로 출력하는 build 명령을 추가했습니다.
build:
protoc api/*.proto \
--go_out=. \
--go-grpc_out=. \
--go_opt=paths=source_relative \
--go-grpc_opt=paths=source_relative \
--proto_path=.
deploy-otelcol:
docker run --name otelcol \
-d -p 4317:4317 \
-v ./otel-config.yaml:/etc/otelcol-contrib/config.yaml \
otel/opentelemetry-collector-contrib
deploy-zipkin:
docker run --name zipkin -d -p 9411:9411 openzipkin/zipkin
remove-otelcol:
docker rm -f otelcol
remove-zipkin:
docker rm -f zipkin
logs-otelcol:
docker logs -f otelcol
다음 명령어를 입력합니다.
make build
그러면 /api/api_grpc.pb.go, /api/api.pb.go 파일이 생성됩니다.
2. openTelemetry collector 설정
/otel-config.yaml파일을 다음과 같이 작성합니다.
receivers:
otlp:
protocols:
grpc:
processors:
batch:
exporters:
zipkin:
endpoint: http://192.168.219.101:9411/api/v2/spans
logging:
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [zipkin, logging]
설정을 간단하게 살펴보겠습니다.
- receivers: collector에서 어떻게 수신할지 정의합니다. 여기서는 openTelemetry collector의 grpc를 이용하여 수신받습니다.
- processors: 어떻게 원격 측정 데이터를 처리할지 정의합니다. batch processor를 이용하여 처리를 그룹화하였습니다.
- exporters: 처리된 원격 측정 데이터를 어디로 내보낼지 정의합니다. zipkin과 logging을 정의했습니다. zipkin endpoint는 자신의 ip에 맞게 수정하도록 합니다.
- service: 트레이스 데이터를 어떻게 수집하고 내보낼지 정의합니다.
gRPC를 통해 입력받은 추적 데이터를 batch Processor를 통해 처리하고 zipkin과 logging으로 내보내는 설정입니다.
3. InitTrace() 구현
openTelemetry로 추적 데이터를 수집하기 위해 tracerProvider라는 객체를 이용해야 합니다. 해당 객체를 이용하기 위해 InitTrace()라는 함수를 구현해봅니다.
/internal/telemetry.go에 다음과 같이 작성합니다.
package internal
import (
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/sdk/trace"
)
func InitTrace() (*trace.TracerProvider, error) {
// 1.1. trace를 어떻게 노출시키는 방법을 정의하는 exporter를 선언합니다.
exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
if err != nil {
return nil, err
}
// 1.2. 동기적으로 span에 대한 정보를 exporter로 전달하는 processor를 생성합니다.
spanProcessor := trace.NewSimpleSpanProcessor(exporter)
// 1.3. tracer provider를 선언합니다.
provider := trace.NewTracerProvider()
provider.RegisterSpanProcessor(spanProcessor)
return provider, nil
}
trace정보를 노출시킬 exporter를 정의합니다. 여기서 stdout으로 내보내기 위해 stdouttrace로 exporter를 선언했습니다. 동기적으로 span(수집할 함수들...정도로 생각합시다)을 수집하기 위해 1.2. 에서 SimpleSpanProcessor를 선언했습니다. 이후 trace provider를 선언해 줍니다.
4. 단일 대상에서 stdout으로 확인하기
/cmd/client/client.go에 다음과 같이 작성합니다.
package main
import (
"context"
"fmt"
"log"
"main/internal"
"go.opentelemetry.io/otel/trace"
)
var tracer trace.Tracer
func main() {
// 2.1. trace provider를 가져옵니다.
tp, err := internal.InitTrace()
if err != nil {
log.Fatal(err)
}
// 2.2. tracer를 생성합니다.
tracer = tp.Tracer("client-go")
ctx := context.Background()
someFunc1(ctx)
}
// 2.3. 컨텍스트를 전파합니다.
func someFunc1(ctx context.Context) {
ctx, span := tracer.Start(ctx, "some func1")
defer span.End()
fmt.Println("call func1")
someFunc2(ctx)
}
func someFunc2(ctx context.Context) {
_, span := tracer.Start(ctx, "some func2")
defer span.End()
fmt.Println("call func2")
}
trace provider를 이용하여 전역 tracer를 초기화합니다. 이후 컨텍스트 전파를 이용하여 someFunc1, someFunc2를 거쳐 정보를 기록합니다. go run cmd/client/client.go로 실행하면 다음의 출력을 확인할 수 있습니다.
call func1
call func2
{
"Name": "some func2",
"SpanContext": {
"TraceID": "598b81024e792daf19b67b8fa0baec34",
"SpanID": "6d481a042d7eea2a",
"TraceFlags": "01",
"TraceState": "",
"Remote": false
},
"Parent": {
"TraceID": "598b81024e792daf19b67b8fa0baec34",
"SpanID": "9d0726b08e636b65",
"TraceFlags": "01",
"TraceState": "",
"Remote": false
},
"SpanKind": 1,
~~~
함수 두 개에 대한 트레이스 정보를 수집하여 두 개의 json형식으로 출력됨을 확인할 수 있습니다.
5. gRPC client 구현 및 에러 확인
이번에는 gRPC client를 구현하고 실행시켜 에러를 마주해봅시다.
/internal/client.go파일을 다음과 같이 작성합니다.
package internal
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// 3.1. grpc 클라이언트를 생성하기 위한 코드를 작성합니다.
// 테스트용으로 구성하여 secure옵션은 따로 설정하지 않았습니다.
func CreateClient(ctx context.Context, addr string) (conn *grpc.ClientConn, err error) {
return grpc.DialContext(ctx, addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
}
그리고 /cmd/client/client.go의 func someFunc(~) 이후 부분을 다음과 같이 작성합니다.
func someFunc2(ctx context.Context) {
ctx, span := tracer.Start(ctx, "some func2")
defer span.End()
fmt.Println("call func2")
callServer(ctx)
}
func callServer(ctx context.Context) {
ctx, span := tracer.Start(ctx, "call server")
defer span.End()
// 3.2. gRPC client를 생성합니다.
conn, err := internal.CreateClient(ctx, ":7777")
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return
}
defer conn.Close()
// 3.3. 요청을 전달합니다.
res, err := api.NewDataClient(conn).Get(ctx, &api.GetRequest{Key: "hello"})
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return
}
fmt.Println(res.Key)
}
callServer()가 호출될 때 err가 발생하는 경우 if절로 이동하여 span에 Error와 status를 기록합니다. 아직 서버를 구현하지 않은 상태로 실행시킨다면 conn, err 밑의 에러 처리 부분을 마주하게 됩니다. 실행시키면 결과의 일부분이 다음과 같음을 확인할 수 있습니다.
~~~
"Events": [
{
"Name": "exception",
"Attributes": [
{
"Key": "exception.type",
"Value": {
"Type": "STRING",
"Value": "*status.Error"
}
},
{
"Key": "exception.message",
"Value": {
"Type": "STRING",
"Value": "rpc error: code = Unavailable desc = connection error: desc = \"transport: Error while dialing: dial tcp :7777: connect: connection refused\""
}
}
],
"DroppedAttributeCount": 0,
"Time": "2023-10-12T00:04:50.018155435+09:00"
}
],
"Links": null,
"Status": {
"Code": "Error",
"Description": "rpc error: code = Unavailable desc = connection error: desc = \"transport: Error while dialing: dial tcp :7777: connect: connection refused\""
},
~~~
error가 event 부분에 기록되어 있는 것을 확인할 수 있습니다. 위처럼 에러를 처리하게 되면 span의 event부분에 error가 기록됩니다.
6. gRPC server 구현
/cmd/server/server.go파일을 다음과 같이 작성합니다.
package main
import (
"context"
"log"
"main/api"
"main/internal"
"net"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc"
)
var tracer trace.Tracer
func main() {
// 4.1. trace provider를 가져옵니다.
tp, err := internal.InitTrace()
if err != nil {
log.Fatal(err)
}
// 4.2. tracer를 생성합니다.
tracer = tp.Tracer("server-go")
l, err := net.Listen("tcp", ":7777")
if err != nil {
log.Fatal(err)
}
// 4.3. data server를 호스팅합니다.
s := new(DataServer)
gs := grpc.NewServer()
api.RegisterDataServer(gs, s)
if err := gs.Serve(l); err != nil {
log.Fatal(err)
}
}
type DataServer struct {
api.DataServer
}
// 4.4. data server 메서드 get을 구현합니다.
func (d *DataServer) Get(ctx context.Context, req *api.GetRequest) (*api.GetResponse, error) {
_, span := tracer.Start(ctx, "receive message")
defer span.End()
res := &api.GetResponse{Key: "hi"}
return res, nil
}
client와 마찬가지로 tracer를 전역으로 생성하고 gRPC 서버를 구현합니다. DataServer에 Get메서드를 추가했습니다. 이후 4.2. 에서 tracer를 생성하고 4.3. 에서 서버를 호스팅 합니다.
서버를 먼저 실행하고 클라이언트를 실행해봅니다.
go run cmd/server/server.go
go run cmd/client/client.go
클라이언트에서 event에 기록되던 에러가 사라짐과 서버에서 stdout으로 트레이스 정보가 출력되는 것을 확인할 수 있습니다. 서버 정보를 살펴보면 다음과 같이 parent에 대한 정보가 없는 것을 확인할 수 있습니다.
{
"Name": "receive message",
"SpanContext": {
"TraceID": "4e16c7fcba890137679c4df61a4d8801",
"SpanID": "18ab0ea79dfd6adf",
"TraceFlags": "01",
"TraceState": "",
"Remote": false
},
"Parent": {
"TraceID": "00000000000000000000000000000000",
"SpanID": "0000000000000000",
"TraceFlags": "00",
"TraceState": "",
"Remote": false
~~~
분명 클라이언트로부터 호출받았는데 parent가 없으니 트레이스 정보에 대한 전파가 이루어지지 않음을 확인할 수 있습니다.
7. gRPC상에서 추적 전파
추적을 연결하기 위해 /cmd/server/server.go파일을 다음과 같이 수정합니다.
~~~
s := new(DataServer)
gs := grpc.NewServer(
grpc.UnaryInterceptor(otelgrpc.UnaryServerInterceptor()), // 5.3. grpc에 unary interceptor를 추가합니다.
)
api.RegisterDataServer(gs, s)
if err := gs.Serve(l); err != nil {
log.Fatal(err)
}
}
client에도 마찬가지의 작업을 해주어야 하므로 /internal/client.go를 다음과 같이 수정합니다.
package internal
import (
"context"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// 3.1. grpc 클라이언트를 생성하기 위한 코드를 작성합니다.
// 테스트용으로 구성하여 secure옵션은 따로 설정하지 않았습니다.
func CreateClient(ctx context.Context, addr string) (conn *grpc.ClientConn, err error) {
return grpc.DialContext(ctx, addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor()), // 5.2. grpc에 unary interceptor를 추가합니다.
)
}
마지막으로 /internal/telemetry.go에 global propagator를 다음처럼 설정해 줍니다.
package internal
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/trace"
)
func InitTrace() (*trace.TracerProvider, error) {
// 1.1. trace를 어떻게 노출시키는 방법을 정의하는 exporter를 선언합니다.
exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
if err != nil {
return nil, err
}
// 1.2. 동기적으로 span에 대한 정보를 exporter로 전달하는 processor를 생성합니다.
spanProcessor := trace.NewSimpleSpanProcessor(exporter)
// 1.3. tracer provider를 선언합니다.
provider := trace.NewTracerProvider()
provider.RegisterSpanProcessor(spanProcessor)
otel.SetTracerProvider(provider)
// 5.1. trace 전파를 위해 global propagator를 설정합니다.
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
return provider, nil
}
해당 구성으로 서버와 클라이언트를 재실행하면 다음처럼 parent에 대한 정보가 포함됨을 확인할 수 있습니다.
{
"Name": "receive message",
"SpanContext": {
"TraceID": "cf34d6c492e6e078790a45602f028c1f",
"SpanID": "940c0fc7f5228f28",
"TraceFlags": "01",
"TraceState": "",
"Remote": false
},
"Parent": {
"TraceID": "cf34d6c492e6e078790a45602f028c1f",
"SpanID": "ba89e50538544c89",
"TraceFlags": "01",
"TraceState": "",
"Remote": false
},
"SpanKind": 1,
"StartTime": "2023-10-12T00:15:16.055883618+09:00",
~~~
8. openTelemetry Collector로 수집 확인하기
먼저 /makefile에 정의한 명령어로 zipkin과 collector를 실행시킵니다.
deploy-otelcol
deploy-zipkin
openTelemetry Collector의 로그는 make logs-otelcol로 확인할 수 있습니다.
telemetry의 exporter를 stdout이 아닌 외부에 노출시킬 수 있도록 다음처럼 수정해줍니다.
/internal/telemetry.go파일을 다음과 같이 수정합니다.
package internal
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/trace"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func InitTrace() (*trace.TracerProvider, error) {
ctx := context.Background()
// 6.1. opentelemetry collector로 전달하는 exporter를 선언합니다.
conn, err := grpc.DialContext(ctx, "localhost:4317", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock())
if err != nil {
return nil, err
}
exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
if err != nil {
return nil, err
}
// 1.2. 동기적으로 span에 대한 정보를 exporter로 전달하는 processor를 생성합니다.
spanProcessor := trace.NewSimpleSpanProcessor(exporter)
// 1.3. tracer provider를 선언합니다.
provider := trace.NewTracerProvider()
provider.RegisterSpanProcessor(spanProcessor)
otel.SetTracerProvider(provider)
// 5.1. trace 전파를 위해 global propagator를 설정합니다.
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
return provider, nil
}
이후 서버와 클라이언트 코드를 실행하면 기존 stdout으로 출력되는 로그가 사라짐을 확인할 수 있습니다. 그러나 collector의 로그에서 span이 수집되는 것을 확인할 수 있습니다.
2023-10-11T14:08:29.519Z info otlpreceiver@v0.87.0/otlp.go:83 Starting GRPC server {"kind": "receiver", "name": "otlp", "data_type": "traces", "endpoint": "0.0.0.0:4317"}
2023-10-11T14:08:29.519Z info service@v0.87.0/service.go:169 Everything is ready. Begin running and processing data.
2023-10-11T14:08:40.538Z info TracesExporter {"kind": "exporter", "data_type": "traces", "name": "logging", "resource spans": 6, "spans": 6}
2023-10-11T14:10:40.347Z info TracesExporter {"kind": "exporter", "data_type": "traces", "name": "logging", "resource spans": 3, "spans": 3}
2023-10-11T14:10:40.547Z info TracesExporter {"kind": "exporter", "data_type": "traces", "name": "logging", "resource spans": 3, "spans": 3}
2023-10-11T14:10:41.350Z info TracesExporter {"kind": "exporter", "data_type": "traces", "name": "logging", "resource spans": 6, "spans": 6}
2023-10-11T14:10:41.751Z info TracesExporter {"kind": "exporter", "data_type": "traces", "name": "logging", "resource spans": 6, "spans": 6}
2023-10-11T14:10:42.555Z info TracesExporter {"kind": "exporter", "data_type": "traces", "name": "logging", "resource spans": 6, "spans": 6}
2023-10-11T14:10:42.956Z info TracesExporter {"kind": "exporter", "data_type": "traces", "name": "logging", "resource spans": 6, "spans": 6}
2023-10-11T14:10:43.558Z info TracesExporter {"kind": "exporter", "data_type": "traces", "name": "logging", "resource spans": 6, "spans": 6}
2023-10-11T14:10:43.959Z info TracesExporter {"kind": "exporter", "data_type": "traces", "name": "logging", "resource spans": 6, "spans": 6}
2023-10-11T14:10:44.762Z info TracesExporter {"kind": "exporter", "data_type": "traces", "name": "logging", "resource spans": 6, "spans": 6}
2023-10-11T14:10:45.164Z info TracesExporter {"kind": "exporter", "data_type": "traces", "name": "logging", "resource spans": 6, "spans": 6}
2023-10-11T15:04:08.100Z info TracesExporter {"kind": "exporter", "data_type": "traces", "name": "logging", "resource spans": 2, "spans": 2}
이번에는 zipkin을 확인해봅니다. zipkin의 endpoint인 localhost:9411에 접속하면 다음의 화면을 확인할 수 있습니다. run query 버튼을 눌러봅시다.
trace을 조회하면 다음의 예시를 확인할 수 있습니다.
함수를 3개 거치고 gRPC data/get요청을 클라이언트가 전달 후 서버가 받고 내부에서 처리하는 것이 span으로 기록됩니다.
dependencies를 조회하면 다음과 같습니다.
참고자료
https://opentelemetry.io/docs/collector/configuration/
Configuration
Familiarity with the following pages is assumed: Data collection concepts in order to understand the repositories applicable to the OpenTelemetry Collector. Security guidance Basics The Collector consists of four components that access telemetry data: Rece
opentelemetry.io
https://github.com/open-telemetry/opentelemetry-demo
GitHub - open-telemetry/opentelemetry-demo: This repository contains the OpenTelemetry Astronomy Shop, a microservice-based dist
This repository contains the OpenTelemetry Astronomy Shop, a microservice-based distributed system intended to illustrate the implementation of OpenTelemetry in a near real-world environment. - Git...
github.com
https://github.com/wavefrontHQ/opentelemetry-examples
GitHub - wavefrontHQ/opentelemetry-examples
Contribute to wavefrontHQ/opentelemetry-examples development by creating an account on GitHub.
github.com
알렉스 보텐 저/노승헌 "관찰가능성 엔지니어링"-한빛미디어(2023)
매튜 A. 티트무스 저/노승헌 "클라우드 네이티브 Go"-동양북스(2023)
'개발 > docker, k8s, CNCF' 카테고리의 다른 글
오픈소스 기여 - agones (5) | 2023.12.02 |
---|---|
cert-manager, jaeger operator를 이용한 jaeger 설치 (2) | 2023.10.29 |
docker, k8s 네트워크 뜯기(6) - docker network none 상태에서 외부랑 통신해보자 (0) | 2023.03.09 |
docker, k8s 네트워크 뜯기(5) - pod 내부의 container와 다른 pod 내부의 container는 어떻게 서로 통신할까? (0) | 2023.03.05 |
docker, k8s 네트워크 뜯기(4) - local registry로 kind k8s cluster에 배포하기 (0) | 2023.03.05 |
댓글