Zipkin으로 Armeria와 Spring 함께 추적하기

분산 추적과 Zipkin 그리고 Armeria

MSA(Micro Service Arhictecture) 환경에서 클라이언트가 보낸 요청은 하나 이상의 서비스를 거쳐 처리됩니다. 이 때 각 서비스가 따로 로그를 남기면 문제가 발생했을 때 원인을 찾는 시간이 오래 걸리고 직관에 의존하여 추론하기 때문에 원인을 잘못 짚는 상황도 발생합니다. 이 문제를 해결하기 위해 “분산 추적” 개념이 등장하였고 대표적인 구현체로 Zipkin, Jaeger 등이 있습니다.

Armeria는 공식 페이지에 들어가면 바로 “Build a reactive microservice at your pace, not theirs.” 라는 문구를 볼 수 있습니다. 이 말처럼 Armeria는 마이크로서비스를 만드는데 특화되어있습니다. 그럼 Armeria가 포함된 MSA 환경에서 어떻게 분산 추적을 할 수 있을까요? 이 글은 Armeria, Spring 서비스를 하나씩 만들고 두 서버를 모두 거치는 요청을 Zipkin으로 추적하는 과정을 정리했습니다.

이전 글인 “Armeria에서 MDC를 사용해도 될까?”도 보고오시면 Armeria에서 로깅을 고민하고 계신 분들께 도움이 되리라 생각합니다. 글에서 실습한 코드는 깃허브 zipkin-practice에서 확인하실 수 있습니다.

분산 추적?

이미 분산 추적이 어떻게 작동하시는지 잘 알고계신다면 바로 다음 절로 넘어가셔도 좋습니다. 여기서는 간략하게 설명하겠습니다.

하나의 요청에 두 개의 서버를 거칠 때 각 서버가 남기는 로그를 연결할 수단이 필요합니다. 먼저 최초로 요청을 받은 서비스에서 traceId를 생성하고 로깅할 때 이 아이디를 함께 기록합니다. 그리고 다른 서비스로 요청이 넘어갈 때 traceId를 함께 전송합니다. traceId를 전파시키는 방법은 다양하지만 대표적으로 HTTP 요청의 경우 헤더에 traceId를 포함한 추적 문맥 정보를 첨부하여 전송합니다.

더 자세한 설명이 필요하시다면 “토스 /23 분산 추적 체계 & 로그 중심으로 Observability 확보하기”, “LINE 광고 플랫폼의 MSA 환경에서 Zipkin을 활용해 로그 트레이싱하기”을 참고하시면 좋을 것 같습니다.

Zipkin 구축

먼저 각 서버의 모든 로그들을 수집할 중앙 서버인 Zipkin 서버를 만들겠습니다. 실습 수준에서는 너무 쉬워서 별로 설명할 내용이 없는데요. 여러 방식으로 실행할 수 있지만 공식 문서에서 권장하는 도커로 실행했습니다.

간단한 실습이므로 인메모리 저장소를 사용하는 Zipkin 서버를 도커로 실행합니다. 더 자세한 내용은 github 링크를 참고하시면 되겠습니다.

만약 인메모리 저장소가 아닌 다른 저장소(MySQL, Elasticsearch 등)를 사용하고 싶으시다면 github 링크를 참고해주세요.

컨테이너 실행 후 브라우저에서 http://localhost:9411에 접속하면 Zipkin 서버의 UI가 정상적으로 작동하는 것을 확인할 수 있습니다.

Spring 구축

다음은 Spring 서비스 구축입니다. Spring을 먼저 구축하는 이유는 아직 Zipkin이나 Armeria 사용해보시지 않은 분들에게 그래도 좀 더 익숙한 코드가 되지 않을까 하여 이렇게 순서를 결정했습니다.

Spring에서 Zipkin을 사용하기 위해 구글링하면 Spring Cloud Sleuth가 많이 언급되는데요. 애석하게도 공식 문서를 보면 Spring Boot 3 버전부터 사용할 수 없다는 공지가 있습니다. (여기서부터 상당히 난관이었는데요. 😅 다행히 공식 문서에서 설정 방법을 찾았습니다.)

Spring Boot 공식 문서 13.8. Tracing에 다양한 분산 추적 라이브러리와 연동하는 방법이 설명되어있습니다. 다양한 구현체를 선택할 수 있지만 그 중 Armeria와 똑같은 구성으로 Brave + Zipkin을 사용하겠습니다. 아래와 같이 의존성을 추가합니다. 이 때 actuator가 반드시 필요하므로 빠뜨리면 안됩니다.

다음으로 프로퍼티 설정이 필요합니다. application.yml(또는 application.properties) 파일을 다음과 같이 설정합니다.

Zipkin을 통해 서비스를 구분하기 쉽도록 spring.application.name을 설정해줍니다. management.tracing.sampling.probability 값으로 샘플링할 비율을 설정할 수 있는데 모든 로그를 전부 샘플링하기 위해 1.0으로 설정합니다. (probability sampling 참고)

다음으로 management.propagationconsume, produce 모두 b3_multi로 설정합니다. 위에서 언급했듯이 추적 문맥 정보를 전파할 때 HTTP 헤더에 어떤 이름을 사용할지 선택하는 부분입니다. Armeria와 호환성을 위해 B3 Multiple Headers 방식을 선택합니다. 그리고 로그를 전송할 zipkin.tracing.endpoint를 설정합니다. 해당 URL은 Zipkin 공식 v2 API 명세에서 확인할 수 있습니다. (사실 기본값이여서 설정하지 않으셔도 문제 없습니다.)

마지막으로 로컬 머신의 로그에서 traceId, spanId를 확인하기 위해 loggin.pattern.level을 설정합니다. logback.xml 또는 logback-spring.xml을 사용하셔도 좋습니다.

다른 예제가 궁금하시다면 Spring 블로그에서 소개하는 “Observability with Spring Boot 3”를 참고하셔도 좋습니다.

Armeria 구축

Armeria 설정 과정은 공식 문서의 “Zipkin integration”과 코드 예제를 참고했습니다. 큰 차이는 없지만 실습에 맞춰 수정한 부분이 있어서 이 절을 참고하시면 도움이 될 것 같습니다.

먼저 아래와 같이 의존성을 추가합니다.

다음으로 아르메리아로 들어오는 요청의 추적 문맥을 설정하기 위한 코드를 구성합니다. 아래는 brave.Tracing 인스턴스를 생성하는 코드입니다. (전체 코드는 제 github 실습 코드를 참고해주세요.)

중요한 부분만 언급합니다. 먼저 serviceName 필드를 설정하여 해당 서비스를 Zipkin에서 식별할 수 있는 이름을 설정합니다. 다음으로 sender() 메소드에서 로그를 전송할 Zipkin 서버의 엔드포인트를 설정합니다. 그리고 create() 메소드 내부에 CurrentTraceContext 인스턴스 생성 시 MDCScopeDecorator를 스코프 데코레이터로 추가합니다. 이는 로컬 머신 로그에 traceId, spanId를 함께 기록하기 위해 slf4j.MDC에 추적 문맥 정보 유지하는 설정입니다. 마지막으로 Tracing 인스턴스를 스태틱 필드로 설정하여 싱글톤으로 사용하도록 했는데요. 그 이유는 다음에 나올 BraveClient에서 동일한 인스턴스를 사용하기 위함입니다.

brave.Tracing 인스턴스는 Armeria의 BraveServiceBraveClient에서 모두 사용합니다. 먼저 서버 설정부터 보겠습니다. 다음과 같이 BraveService 데코레이터를 달아서 Armeria로 들어오는 요청의 추적 문맥을 관리합니다. 예를 들어 새로운 요청이면 traceId를 부여하고 다른 서비스를 거쳐온 요청이면 추적 문맥을 이어받아 로깅합니다.

Armeria에서 외부 서버로 요청을 보낼 때는 WebClient를 사용하는데요. 이 때 BraveClient를 데코레이터로 달아 추적 문맥을 HTTP 헤더에 담아 함께 전송합니다. 다음과 같이 설정합니다.

마지막으로 logback.xml을 설정하여 로컬 머신 로그에서 traceId, spanId를 함께 확인했습니다.

핑퐁 요청 추적하기

좀 길었지만 모든 준비가 끝났습니다. 이제 Spring → Armeria 순으로 처리되는 요청과 Armeria → Spring 순으로 처리되는 요청을 날려보고 Zipkin 서버에서 어떻게 보이는지 확인합니다.

참고로 Spring에서 HTTP 요청을 보낼 때 RestTemplate를 사용했습니다. 만약 Spring MVC가 아닌 Spring WebFlux를 사용하신다면 WebClient를 사용하시면 됩니다.

먼저 Spring의 컨트롤러 코드입니다.

/hello/hi를 보실 수 있는데요. /hello는 Spring→Armeria 순서 테스트에 사용하고 /hi는 Armeria → Spring 순서 테스트에 사용합니다.

다음은 Armeria의 annotated 서비스입니다. Spring의 컨트롤러와 동격이라 보시면 됩니다. 코드도 비슷하여 읽는데 큰 어려움은 없으실 것 같습니다.

이제 진짜 요청을 날려보겠습니다. 먼저 Spring 서버로 localhost:8081/hello 요청을 전송합니다. 요청은 Spring 서버에 도착한 후 Armeria 서버로 연달아 localhost:8080/hello 요청을 보냅니다. Armeria 서버로부터 받은 응답에 “spring-hello “ 문자열을 붙여 최종적으로 응답합니다.

먼저 아래는 Spring 서버에 찍힌 로그입니다. traceId64b6d21e56b5789992d3c3e597189d3e, spanId92d3c3e597189d3e입니다.

그 다음 Armeria 서버에 찍힌 로그입니다. traceId는 동일하게 64b6d21e56b5789992d3c3e597189d3e, spanId63ca1621d43b13d5입니다.

마지막으로 Zipkin 서버에 접속해 확인해보면 해당 요청이 Spring → Armeria를 거쳐 처리되었다는 사실을 확인할 수 있습니다. Armeria span에서 parentId가 Spring span의 아이디와 일치하는 것도 확인할 수 있습니다.

Spring → Armeria 요청 그래프
Spring → Armeria 요청 그래프
Parent id 확인
Parent id 확인

다음은 같은 방식으로 Armeria 서버에 localhost:8080/hi 요청을 보냅니다. Armeria 서버에 도착한 다음 Spring 서버로 localhost:8081/hi 요청을 보냅니다. Spring 서버로부터 받은 응답에 “armeria-hi “ 문자열을 붙여 최종적으로 응답합니다.

먼저 아래는 Armeria 서버에 찍힌 로그입니다. traceId970f80618600fd0c, spanId970f80618600fd0c입니다.

그 다음 Spring 서버에 찍힌 로그입니다. traceId는 동일하게 970f80618600fd0c, spanId82bd574c232efd95입니다.

마지막으로 Zipkin 서버에 접속해 확인해보겠습니다. Spring과 차이점이 있는데 Armeria의 WebClient에서 요청을 보내는 과정이 별도의 span으로 잡힙니다. 즉, Armeria → Armeria WebClient → Spring을 거쳐 처리되었음을 확인할 수 있습니다. Spring span에서 parentId가 Armeria WebClient span의 아이디와 일치하고 Armeria WebClient의 parentId는 최초 spanId와 일치합니다.

Armeria → Spring 요청 그래프
Armeria → Spring 요청 그래프

참고로 Armeria에서 WebClient를 사용하여 요청을 전송할 때 지연 시간이 1초가 넘을 정도로 Spring에 비해 확연하게 오래 걸리는 것을 확인할 수 있습니다. 정확한 원인은 찾지 못했으나 저 구간의 로그가 많이 뜨기 때문에 나중에 이유를 알아낼 수 있을 것 같습니다. (아마 몇 가지 설정을 통해 불필요한 프로토콜 협상 과정을 줄일 수 있지 않을까 생각합니다.)

소소한 소스 코드 분석

간단하게 내부에서 어떤 일이 일어나는지 분석해보겠습니다. 저는 이 실습을 진행하면서 3가지 질문에 대한 답을 얻고 싶었습니다.

  1. Armeria로 들어오는 요청에 대해 추적 문맥을 어떻게 설정하는가.
  2. Armeria에서 나가는 요청에 추적 문맥을 어떻게 첨부하는가.
  3. Armeria 내부에서 어떻게 추적 문맥을 관리하는가.

먼저 1번의 답은 BraveService 소스 코드에서 확인할 수 있습니다. BraveServiceSimpleDecoratingHttpService를 상속해 만든 데코레이터입니다. 실제 데코레이터 로직을 구현한 serve 메소드를 확인해보면 다음과 같이 Armeria의 ServiceRequestContextbrave.http.HttpServerRequest로 변환하고 brave.http.HttpServerHandler#handleReceive의 인자로 넣습니다.

handleReceive 메소드의 설명을 보면 “Conditionally joins a span, or starts a new trace, depending on if a trace context was extracted from the request.”이라 되어있습다. 코드와 함께 해석하면 요청에 들어있는 추적 문맥에 따라 Armeria가 첫 번째 서비스인 경우 새로운 trace를 시작하거나 다른 서버를 거쳐 온 경우 기존 trace의 새로운 span을 구성한다는 것을 알 수 있습니다.

다음으로 2번 역시 1번과 유사한 방식으로 해답을 찾을 수 있습니다. BraveClient의 소스코드에서 데코레이터 로직을 구현한 execute 메소드를 보면 BraveService와 비슷하게 다음과 같은 코드를 볼 수 있습니다.

먼저 Armeria의 req에서 기존 헤더를 끄집어낸 뒤에 이를 brave.http.HttpClientRequest 생성할 때 사용합니다. 이후 brave.http.HttpClientHandler#handleReceive 메소드를 거치는데 그 설명을 보면 “Starts the client span after assigning it a name and tags. This injects the trace context onto the request before returning.”을 보실 수 있습니다. 따라서 handleReceive 내부에서 RequestHeadersBuilder 인스턴스에 추적 문맥을 추가하고 최종적으로 req의 헤더를 업데이트합니다.

마지막 3번 질문이 이 글을 쓰게된 계기와 맞닿아있는데요. Armeria는 내부에서 요청을 처리할 때 여러 스레드를 거칠 수 있습니다. 때문에 로깅할 때 이 점을 고려하여 slf4j.MDC를 관리해주어야 합니다. (해당 내용을 이전 글에서 다뤘습니다.) 추적 문맥 역시 요청을 처리하다 스레드가 바뀌면 정보를 유지해야 합니다. Armeria는 여러 스레드를 걸친 요청 문맥 정보를 RequestContext에 넣어 저장합니다. 따라서 추적 문맥 역시 이 곳에 저장하는데요. 위쪽 TraceFactory 코드에 등장하는 RequestContextCurrentTraceContext의 Armeria 공식 문서 설명을 보면 다음과 같습니다. “It stores the trace context into a RequestContext and loads the trace context from the RequestContext automatically. Because of that, we don’t need to use a thread local variable which can lead to unpredictable behavior in asynchronous programming.” 정리하면 RequestContextCurrentTraceContext가 추적 문맥을 RequestContext에 저장하고 필요할 때 빼서 사용하고 덕분에 스레드가 바뀔 때 발생할 수 있는 이상한 문제를 피할 수 있습니다.

정리

여기까지 Armeria와 Spring으로 작은 서비스를 만들고 Zipkin으로 요청을 추적해봤습니다. 마이크로서비스를 만드는데 최적화된 Armeria인 만큼 MSA 환경에서 널리 사용되는 Zipkin을 지원합니다. Zipkin이 지원하는 깔끔한 UI를 통해 쉽게 요청을 추적할 수 있었습니다. 실습 후 BraveService, BraveClient 데코레이터가 어떻게 동작하는지 알아봤고 Armeria 내부에서 스레드가 바뀔 때 RequestContextCurrentTraceContext가 어떤 역할을 하는지 간략히 알아봤습니다. 현재 Armeria에서는 Brave-Zipkin 구성만 사용할 수 있지만 조만간 사용하시는 분들이 늘어나 기여해주신다면 Spring 처럼 다양한 구현체를 사용할 수 있을 것입니다.

확실히 직접 실습을 통해 직접 분산 추적 환경을 구성하니 자연스럽게 더 깊이 파게되어서 많이 배울 수 있었습니다. 사실 이 글의 시작은 Armeria의 RequestContext에 대해 공부하다 이를 활용하는 좋은 예시가 로깅이여서 이전 글인 “Armeria에서 MDC를 사용해도 될까?” 부터 이 글까지 이어졌습니다. 아직 완벽하게 내부 소스 코드까지 이해하지 못했지만 좋은 스타트 지점을 찾았다고 생각합니다. 나중에 RequestContext를 주제로도 글을 써볼 예정입니다. 저는 아직 대규모 분산 시스템 개발의 경험이 없음에도 Armeria를 통해 많이 배우고 있습니다. 이 글을 읽으시고 Armeria에 관심이 생기셨다면 사용해보시고 그 경험을 공유해주시면 많은 도움이 될 것 같습니다. 😆

comments powered by Disqus

Related