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.propagation
의 consume
, 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의 BraveService
와 BraveClient
에서 모두 사용합니다. 먼저 서버 설정부터 보겠습니다. 다음과 같이 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 서버에 찍힌 로그입니다. traceId
는 64b6d21e56b5789992d3c3e597189d3e
, spanId
는 92d3c3e597189d3e
입니다.
그 다음 Armeria 서버에 찍힌 로그입니다. traceId
는 동일하게 64b6d21e56b5789992d3c3e597189d3e
, spanId
는 63ca1621d43b13d5
입니다.
마지막으로 Zipkin 서버에 접속해 확인해보면 해당 요청이 Spring → Armeria를 거쳐 처리되었다는 사실을 확인할 수 있습니다. Armeria span에서 parentId
가 Spring span의 아이디와 일치하는 것도 확인할 수 있습니다.
다음은 같은 방식으로 Armeria 서버에 localhost:8080/hi
요청을 보냅니다. Armeria 서버에 도착한 다음 Spring 서버로 localhost:8081/hi
요청을 보냅니다. Spring 서버로부터 받은 응답에 “armeria-hi “ 문자열을 붙여 최종적으로 응답합니다.
먼저 아래는 Armeria 서버에 찍힌 로그입니다. traceId
는 970f80618600fd0c
, spanId
는 970f80618600fd0c
입니다.
그 다음 Spring 서버에 찍힌 로그입니다. traceId
는 동일하게 970f80618600fd0c
, spanId
는 82bd574c232efd95
입니다.
마지막으로 Zipkin 서버에 접속해 확인해보겠습니다. Spring과 차이점이 있는데 Armeria의 WebClient
에서 요청을 보내는 과정이 별도의 span으로 잡힙니다. 즉, Armeria → Armeria WebClient → Spring을 거쳐 처리되었음을 확인할 수 있습니다. Spring span에서 parentId
가 Armeria WebClient span의 아이디와 일치하고 Armeria WebClient의 parentId
는 최초 spanId
와 일치합니다.
참고로 Armeria에서 WebClient
를 사용하여 요청을 전송할 때 지연 시간이 1초가 넘을 정도로 Spring에 비해 확연하게 오래 걸리는 것을 확인할 수 있습니다. 정확한 원인은 찾지 못했으나 저 구간의 로그가 많이 뜨기 때문에 나중에 이유를 알아낼 수 있을 것 같습니다. (아마 몇 가지 설정을 통해 불필요한 프로토콜 협상 과정을 줄일 수 있지 않을까 생각합니다.)
소소한 소스 코드 분석
간단하게 내부에서 어떤 일이 일어나는지 분석해보겠습니다. 저는 이 실습을 진행하면서 3가지 질문에 대한 답을 얻고 싶었습니다.
- Armeria로 들어오는 요청에 대해 추적 문맥을 어떻게 설정하는가.
- Armeria에서 나가는 요청에 추적 문맥을 어떻게 첨부하는가.
- Armeria 내부에서 어떻게 추적 문맥을 관리하는가.
먼저 1번의 답은 BraveService
소스 코드에서 확인할 수 있습니다. BraveService
는 SimpleDecoratingHttpService
를 상속해 만든 데코레이터입니다. 실제 데코레이터 로직을 구현한 serve
메소드를 확인해보면 다음과 같이 Armeria의 ServiceRequestContext
를 brave.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에 관심이 생기셨다면 사용해보시고 그 경험을 공유해주시면 많은 도움이 될 것 같습니다. 😆