2020. 8. 23. 18:21 ㆍ개발 이야기/Golang
안녕하세요. 해커의 개발일기 입니다.
오늘은 마이크로 서비스 아키텍처를 사용하여 구축된 응용 프로그램을 프로파일링하고 모니터링하는데 사용하는
Distributed Tracing 분산 추적 기술에 중!
오늘은 지난번에 소개해드렸던
https://bourbonkk.tistory.com/84?category=794341
instrumentation 작업을 통해 latency를 측정하고자 하는 곳에 코드를 심어주게 되면 서비스는 그 코드를 심은 부분의 함수가 끝날 때마다 span 데이터를 jaeger-agent에 UDP 형태로 보내게 되는데요. 이때TraceID를 함께 보내 백엔드에서는 동일한 TraceID로 select를 하게 되면 완전한 trace 정보가 완성되게 되는 원리입니다!
위의 예시 그림을 보게 되면Servica A의 span 데이터는 총 3개(빨간색 라인)를 받았을 것이고, Service B의 span 데이터는 1개를 받았을 것입니다. jaeger-agent에 span 데이터를 보내고, 이 jaeger-agent는 jaeger-collector에 전달을 해서kafka나 DB에 바로 넣어주게 되는데요. 이때 백엔드에서 같은 TraceID를 이용해 조회를 하게 되면 위와 같은 완전한 trace 정보가 완성되게 됩니다!~ Service A의 끝나는 시간과Service B의 시작시간의 Gap이 바로 Network Latency가 되겠죠?
자 그러면, Jaeger와 관련해서 어떻게 하면 실제 어플리케이션에 instrumentation을 적용해서 서비스 구간구간 소요시간을 알 수 있는지 알아보도록 하겠습니다. 제가 적용해본 언어는 두 가지가 있는데요. 바로 Golang으로 작성된 app과 JAVA로 작성된 app입니다. 두 가지 예시를 모두 소개해드릴 생각인데요.
JAVA app의 경우에는 special-agent라는 auto-instrumentation 해주는 프로젝트를 이용해 적용하는 방법을 소개했기 때문에 Golang App의 실제 적용 방법을 소개해드리겠습니다.
# Opentracing Jaeger-client golang/JAVA applicaion에 적용하기[JAVA 편]
https://bourbonkk.tistory.com/100
먼저 제가 사용할 jaeger-client는 uber에서 만든 jaeger-client-go를 사용할 건데요. 이것만 사용하니 몇 가지 의존성들이 문제가 되는 것들이 있었어서 제가 사용한 모듈들이 나열된 go.mod 파일을 소개해드리겠습니다.
github.com/go-kit/kit v0.10.0
github.com/gorilla/mux v1.7.4
github.com/jaegertracing/jaeger v1.18.1
github.com/opentracing-contrib/go-stdlib v1.0.0
github.com/opentracing/opentracing-go v1.1.0
github.com/prometheus/client_golang v1.4.1
github.com/uber/jaeger-client-go v2.25.0+incompatible
github.com/uber/jaeger-lib v2.2.0+incompatible
go.uber.org/zap v1.15.0
golang.org/x/net v0.0.0-20200202094626-16171245cfb2
의존성 문제가 없으면 위에 go.mod 파일은 참고 안 하셔도 상관없습니다. 자 그럼 시작하겠습니다.
먼저 go get으로 모듈을 설치하겠습니다.
go get -u github.com/uber/jaeger-client-go/
간략하게 jaeger 프로젝트에서 발췌한 trace init 함수를 통해 설명하도록 하겠습니다.
// Init creates a new instance of Jaeger tracer.
func Init(serviceName string, logger log2.Logger) opentracing.Tracer {
cfg, err := config.FromEnv()
if err != nil {
logger.Log("cannot parse Jaeger env vars", zap.Error(err))
}
cfg.ServiceName = serviceName
// TODO(ys) a quick hack to ensure random generators get different seeds, which are based on current time.
time.Sleep(100 * time.Millisecond)
metricsFactory := prometheus.New().Namespace(metrics.NSOptions{Name: serviceName, Tags: nil}) // metrics.NSOptions{Name: serviceName, Tags: nil}
tracer, _, err := cfg.NewTracer(
config.Metrics(metricsFactory),
config.Observer(rpcmetrics.NewObserver(metricsFactory, rpcmetrics.DefaultNameNormalizer)),
)
if err != nil {
logger.Log("cannot initialize Jaeger Tracer", zap.Error(err))
}
return tracer
}
logger는 사용하고 계신 log로 변경해주시면 될 것 같습니다.
config.FromEnv() 메서드를 이용해 JAEGER 설정에 필요한 값들을 가져오겠습니다. 이 메서드는 jaeger-client-go/config/config.go에 있는 메서드인데 리턴값인 Configuration 구조체를 보면 Jaeger 설정값을 파싱 하는 것을 알 수 있는데요. 대략적으로 아래와 같은 구조체인 것을 볼 수 있습니다.
MetricFactory를 만들어 config.Metrics()에 넣어 tracer를 생성합니다. 이 tracer를 리턴해 tracing이 필요한 부분에 사용합니다.
다음은 main.go인데요 main에서 tracer를 생성해 http 통신을 하는 곳에 span 데이터를 뽑아낼 수 있도록 코드를 넣어줄 예정인데요
tracer = tracing.Init(ServiceName, logger)
opentracing.InitGlobalTracer(tracer)
main에서 tracer를 생성하고 GlobalTracer에 넣어줍니다. 생성된 tracer를 이용해 trace 하고자 하는 곳이 코드를 심어주면 됩니다. HTTPHandler를 만드는 예시를 보겠습니다.
##main.go
// Endpoint domain.
endpoints := api.MakeEndpoints(service, tracer)
// HTTP router
router := api.MakeHTTPHandler(endpoints, logger, tracer)
httpMiddleware := []commonMiddleware.Interface{
commonMiddleware.Instrument{
Duration: HTTPLatency,
RouteMatcher: router,
},
}
tracer를 MakeEndpoints 메서드에 전달해줍니다.
# endpoints.go
// MakeEndpoints returns an Endpoints structure, where each endpoint is
// backed by the given service.
func MakeEndpoints(s Service, tracer stdopentracing.Tracer) Endpoints {
return Endpoints{
LoginEndpoint: opentracing.TraceServer(tracer, "GET /login")(MakeLoginEndpoint(s)),
RegisterEndpoint: opentracing.TraceServer(tracer, "POST /register")(MakeRegisterEndpoint(s)),
HealthEndpoint: opentracing.TraceServer(tracer, "GET /health")(MakeHealthEndpoint(s)),
UserGetEndpoint: opentracing.TraceServer(tracer, "GET /customers")(MakeUserGetEndpoint(s)),
UserPostEndpoint: opentracing.TraceServer(tracer, "POST /customers")(MakeUserPostEndpoint(s)),
AddressGetEndpoint: opentracing.TraceServer(tracer, "GET /addresses")(MakeAddressGetEndpoint(s)),
AddressPostEndpoint: opentracing.TraceServer(tracer, "POST /addresses")(MakeAddressPostEndpoint(s)),
CardGetEndpoint: opentracing.TraceServer(tracer, "GET /cards")(MakeCardGetEndpoint(s)),
DeleteEndpoint: opentracing.TraceServer(tracer, "DELETE /")(MakeDeleteEndpoint(s)),
CardPostEndpoint: opentracing.TraceServer(tracer, "POST /cards")(MakeCardPostEndpoint(s)),
}
}
부분은 micro-socks라는 예제 프로그램의 코드를 발췌한 것입니다. opentracing.TraceServer는 OperationName이라는 span에서 next 엔드포인트를 래핑 하는 미들웨어를 반환합니다. OperationName은 여기서 "GET /health" 이런 것들이 되겠습니다. 즉 http 메서드 요청이 와서 해당 부분이 실행될 때 실행될때 "GET /health" operationName을 가진 root span으로 rapping이 되고 MakeHealthEndPoint에서 정의한 하위 span이 child span이 되도록 정의해줍니다. 잘 이해가 안 갈 수 있기 때문에 그림을 참고하겠습니다. 실제로 온 요청은 "GET /health"로 요청이 오는데요 최 상위 rootspan이 아래 그림처럼 GET /health이 되도록 최상위 rapping을 해주는 것입니다. 만약 traceServer를 안 만들어주었다면 그 밑에 health check operation이 가장 최상위 span이 되겠죠? 이해가 되셨길 바라겠습니다..
코드를 한번 확인해보겠습니다. 요청이 왔을 때 context에 span이 없다면 operationName을 설정해줍니다.
// TraceServer returns a Middleware that wraps the `next` Endpoint in an
// OpenTracing Span called `operationName`.
//
// If `ctx` already has a Span, it is re-used and the operation name is
// overwritten. If `ctx` does not yet have a Span, one is created here.
func TraceServer(tracer opentracing.Tracer, operationName string) endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (interface{}, error) {
serverSpan := opentracing.SpanFromContext(ctx)
if serverSpan == nil {
// All we can do is create a new root span.
serverSpan = tracer.StartSpan(operationName)
} else {
serverSpan.SetOperationName(operationName)
}
defer serverSpan.Finish()
otext.SpanKindRPCServer.Set(serverSpan)
ctx = opentracing.ContextWithSpan(ctx, serverSpan)
return next(ctx, request)
}
}
}
자 이제 실제로 Span을 생성하는 부분을 보겠습니다. 여러 가지의 하나의 요청에서 여러 span을 보내는 것을 예제로 보겠습니다. MakeUserGetEndPoint 부분인데요 보시면 var span을 만들고 이것을 핸들링하는 것을 볼 수 있는데요. 차례대로 설명해보겠습니다. span에 태그를 붙여주고 이 함수가 끝날 때 span이 종료되도록 defer span.Finish를 실행해줍니다.
// MakeUserGetEndpoint returns an endpoint via the given service.
func MakeUserGetEndpoint(s Service) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (response interface{}, err error) {
var span stdopentracing.Span
span, ctx = stdopentracing.StartSpanFromContext(ctx, "get users")
span.SetTag("service", "user")
defer span.Finish()
그리곤 userspan 변수를 만들고 StartSpan("OperationName",부모 span정보)를 넣어주고 s.GetUsers(req.ID)를 실행하고 바로 userspan.Finish()를 실행해 span을 닫아줍니다. 그러면 s.GetUsers(req.ID)의 Latency를 측정해 Jaeger-agent에 전달하겠죠? 그러고 나서 다음 로직인 DB 관련된 곳의 Latency를 측정하는 것을 보겠습니다.
req := request.(GetRequest)
userspan := stdopentracing.StartSpan("users from db", stdopentracing.ChildOf(span.Context()))
usrs, err := s.GetUsers(req.ID)
userspan.Finish()
if req.ID == "" {
return EmbedStruct{usersResponse{Users: usrs}}, err
}
if len(usrs) == 0 {
if req.Attr == "addresses" {
return EmbedStruct{addressesResponse{Addresses: make([]users.Address, 0)}}, err
}
if req.Attr == "cards" {
return EmbedStruct{cardsResponse{Cards: make([]users.Card, 0)}}, err
}
return users.User{}, err
}
여기서는 attrspan을 만들어 db.GetUserAttributes(&user)이 메서드의 span데이터를 측정하는 것인데요 아래와 같이 설정을 해주면
user := usrs[0]
attrspan := stdopentracing.StartSpan("attributes from db", stdopentracing.ChildOf(span.Context()))
db.GetUserAttributes(&user)
attrspan.Finish()
if req.Attr == "addresses" {
return EmbedStruct{addressesResponse{Addresses: user.Addresses}}, err
}
if req.Attr == "cards" {
return EmbedStruct{cardsResponse{Cards: user.Cards}}, err
}
return user, err
}
}
"GET /customers" HTTP 요청이 실행되면 parent Span과 더불어 두 개의 child Span이 생성되게 되는 것입니다! 그러면 Parent Span은 어떻게 되는 걸까요? Parent Span은 가장 최초에 생성된 span 변수이고 이 모든 함수가 끝날 때 span.Finish 되도록 defer span.Finish()를 해두었습니다.
자, 그런데 이 서비스로 요청이 오긴 했는데 다른 jaeger instrumentation이 적용된 서비스에서 왔다면 그 서비스가 root span이 될 것인데요 어떻게 알 수 있을까요? 방법은 HTTPHandler를 만들 때 option으로 이 request에 root span이 있는지 알 수 있는 처리를 해주어야 합니다. 예제는 아래와 같습니다.
func MakeHTTPHandler(e Endpoints, logger log.Logger, tracer stdopentracing.Tracer) *mux.Router {
r := mux.NewRouter().StrictSlash(false)
options := []httptransport.ServerOption{
httptransport.ServerErrorLogger(logger),
httptransport.ServerErrorEncoder(encodeError),
}
r.Methods("GET").Path("/login").Handler(httptransport.NewServer(
e.LoginEndpoint,
decodeLoginRequest,
encodeResponse,
append(options, httptransport.ServerBefore(opentracing.HTTPToContext(tracer, "GET /login", logger)))...,
))
여기서 보시면 opentracing.HTTPToContext(tracer, OPERATIONNAME, logger) 이 부분인데요 위의 코드를 보시면 GET/login 에 요청이 왔을 때 HTTP header에 root span정보가 없다면 이 HTTP 요청이 root span이 되도록 해주는 메서드입니다.
코드를 한번 보겠습니다.
// HTTPToContext returns an http RequestFunc that tries to join with an
// OpenTracing trace found in `req` and starts a new Span called
// `operationName` accordingly. If no trace could be found in `req`, the Span
// will be a trace root. The Span is incorporated in the returned Context and
// can be retrieved with opentracing.SpanFromContext(ctx).
func HTTPToContext(tracer opentracing.Tracer, operationName string, logger log.Logger) kithttp.RequestFunc {
return func(ctx context.Context, req *http.Request) context.Context {
// Try to join to a trace propagated in `req`.
var span opentracing.Span
wireContext, err := tracer.Extract(
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(req.Header),
)
if err != nil && err != opentracing.ErrSpanContextNotFound {
logger.Log("err", err)
}
span = tracer.StartSpan(operationName, ext.RPCServerOption(wireContext))
ext.HTTPMethod.Set(span, req.Method)
ext.HTTPUrl.Set(span, req.URL.String())
return opentracing.ContextWithSpan(ctx, span)
}
}
이렇게 하면 모든 trace 정보를 얻기 위한 준비가 완료됩니다. 설명을 하다 보니 상당히 복잡해 보이는데요.. 간략하게 정리를 해보겠습니다.
1. tracer 생성
2. HTTPToContext를 통해 request에 상위의 span이 있는지.. 즉 상위 trace 정보가 있는지 확인
3. TraceServer를 통해 요청되는 http 요청의 최상위 정의
4. Latency를 얻고자 하는 부분에 span을 만들고 특정 부분의 함수가 끝나는 부분에 span.finish() 실행
그리고 jaeger-client로 instrumentation 작업이 완료한 뒤 실제로 jaeger-agent로 span 데이터를 보내기 위해서는 jaeger-agent의 정보를 넣어줘야겠죠? 몇 가지 환경 변수 설정이 필요합니다.
환경변수명 | 값 |
JAEGER_SERVICE_NAME | The service name. |
JAEGER_AGENT_HOST | The hostname for communicating with agent via UDP (default localhost). |
JAEGER_AGENT_PORT | The port for communicating with agent via UDP (default 6831). |
JAEGER_SAMPLER_TYPE |
The sampler type: remote, const, probabilistic, ratelimiting (default remote). See also https://www.jaegertracing.io/docs/latest/sampling/. |
JAEGER_SAMPLER_PARAM | The sampler parameter (number). |
JAEGER_SAMPLER_MANAGER_HOST_PORT | (deprecated) The HTTP endpoint when using the remote sampler. |
JAEGER_SAMPLING_ENDPOINT |
The URL for the sampling configuration server when using sampler type remote (default http://127.0.0.1:5778/sampling). |
JAEGER_TAGS | A comma separated list of name=value tracer-level tags, which get added to all reported spans. The value can also refer to an environment variable using the format ${envVarName:defaultValue}. |
이렇게 설정하면 끝!
이상입니다. 혹시 궁금하신 부분이나 이해가 안 가시는 부분이 있으시면 댓글을 한번 달아주세요. 여유가 있다면 답글을 달도록 하겠습니다. 저도 포스팅을 작성하면서 다시 한번 정리할 수 있는 기회였습니다.
MSA구조를 tracing 하고 서비스 운용에 많은 도움이 되길 바라겠습니다.
'개발 이야기 > Golang' 카테고리의 다른 글
Go 언어의 특징과 사용 방법 (0) | 2024.11.11 |
---|---|
# Opentracing Jaeger-client golang/JAVA applicaion에 적용하기[JAVA 편] (0) | 2020.08.23 |
# Kubernetes(쿠버네티스) 환경에서 GO 어플리케이션 디버깅 환경 구성 (0) | 2020.08.05 |
#6 Golang gomail을 이용해 SMTP 연동하기 (0) | 2020.01.27 |
#5 Golang bcrypt로 비밀번호 해싱 및 검증하기 (0) | 2019.12.30 |