Zipkin에서 URL 추적 설정: Sampler와 Sleuth를 활용한 스프링 부트 적용
이전 글에서 ALB에서 상태 검사를 요청하는 간격을 5분으로 늘리고, 상태 검사용 컨트롤러를 생성해 상태 검사에 더 적합하게 코드를 수정했었다. 그런데 결국 Zipkin UI에 5분마다 테스트 요청이 누적되는 건 마찬가지라 이 헬스 체크 요청까지 Zipkin에 추적하는 게 필요할까?라는 생각이 들었다.
(이전 내용이 궁금하다면 아래 링크에서 확인할 수 있다.)
[AWS] AWS ALB 헬스 체크(상태 검사) 간격 및 경로 수정을 통한 서비스 최적화
1. 상태 검사용 요청을 Zipkin에서 추적하는게 맞나
Zipkin에서 헬스 체크 요청을 추적하는 것이 유용할 수도 있고, 메모리 낭비가 될 수도 있다. 이는 여러분이 관리하는 서비스의 특성과 여러분이 필요로 하는 모니터링의 종류에 따라 다르게 판단될 수 있다.
헬스 체크 요청의 추적은 서비스의 가용성을 지속적으로 모니터링하는 데 유용할 수 있다. 요청의 성공 여부, 응답 시간 등을 분석하여 서비스 상태에 대한 통찰을 얻을 수 있다. 그리고 헬스 체크 요청을 통해 네트워크 지연, 서버 오류 등 인프라 관련 문제를 빠르게 식별할 수 있다.
그러나 정기적이고 반복적인 헬스 체크 요청은 데이터 양을 빠르게 증가시켜 Zipkin 서버의 메모리와 저장 공간을 과도하게 사용할 수 있다. 빈번한 헬스 체크 로그가 중요한 트래픽 로그를 가릴 수 있어서 이로 인해 실제 문제를 파악하는 데 방해가 될 수 있다.
우리는 테스트 연동 요청을 보내고 이를 Zipkin에 추적하지 않도록 설정하는 게 더 적합하다고 판단했다. Zipkin 추적은 주로 실제 트래픽과 관련된 중요한 요청들을 모니터링하고 분석하기 위한 것이기 때문에, 정기적인 헬스 체크나 테스트 요청 같은 것들은 필터링해서 제외하는 것이 일반적이기도 했다.
스프링 부트에서 커스텀 설정을 추가하면 Zipkin 클라이언트의 요청 필터링 기능을 사용할 수 있다.
2. Sampler 설정 설명
Zipkin의 Sampler는 Zipkin에 추적할 요청을 결정하는 데 사용되는 컴포넌트다. Zipkin에서 모든 요청을 추적하면 데이터 양이 매우 많아지고 성능에 영향을 줄 수 있기 때문에, Sampler를 사용해서 어떤 요청을 추적할지 결정한다.
2-1. Sampler의 기본 원리
Zipkin의 Sampler는 요청 추적을 결정하는 데 도움이 되는 도구다. 모든 요청을 추적하면 정보가 너무 많아져서 데이터 처리와 성능에 부담이 될 수 있기 때문에, 어떤 요청을 추적할지 선택하는 게 중요하다. 이렇게 Sampler를 사용하면 필요한 요청만 추적해서 효율적으로 데이터를 관리할 수 있다.
2-2. Sampler 사용 방식
Sampler를 사용하는 방식은 간단하다. 먼저 추적하고 싶은 요청의 조건을 정의하는 Sampler 클래스를 만들고, 이 클래스를 스프링 빈으로 등록한다. 그러면 스프링 부트가 자동으로 이 Sampler를 찾아서 Zipkin과 연결해준다. 이렇게 설정하면 네가 정의한 조건에 따라 요청이 추적되거나 추적되지 않게 된다.
첫 번째로, 모든 요청을 추적하길 원한다면 아래 코드를 적용한다. 이 설정은 기본 설정이기 때문에 따로 설정해주지 않아도 된다.
@Bean
public Sampler customSampler() {
return Sampler.ALWAYS_SAMPLE; // 모든 요청을 추적
}
두 번째로, 특정 URL만 추적하고 싶다면 아래 코드를 적용한다.
private static final List<String> ALLOWED_PATHS = Arrays.asList(
"/api/special" // 허용할 첫 번째 URL 패턴
);
@Bean
public Sampler customSampler() {
return new Sampler() {
@Override
public boolean isSampled(long traceId) {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes) {
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
return ALLOWED_PATHS.stream().anyMatch(path -> request.getRequestURI().startsWith(path));
}
// HTTP 요청 정보가 없는 경우 기본적으로 추적하지 않음
return false;
}
};
}
세 번째로, 특정 조건에 따른 추적을 허용하고 싶다면 아래 코드를 적용한다.
@Bean
public Sampler customSampler() {
return new Sampler() {
@Override
public boolean isSampled(long traceId) {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes) {
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
return "my-value".equals(request.getHeader("X-Special-Header"));
}
// HTTP 요청 정보가 없는 경우 기본적으로 추적하지 않음
return false;
}
};
}
마지막으로, 모든 요청을 추적하지 않길 바란다면 아래 코드를 적용한다.
@Bean
public Sampler neverSampler() {
return Sampler.NEVER_SAMPLE; // 모든 요청 추적 거부
}
이렇게 Sampler를 다양하게 설정하면, 내가 필요로 하는 정보만 추적해서 효율적으로 데이터를 관리하고 시스템 성능을 유지할 수 있다.
3. 특정 URL만 추적 허용하도록 스프링 부트 코드 수정
기존에 내가 사용하던 의존성은 아래와 같다.
// zipkin
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-tracing-bridge-brave'
implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
application.yml은 아래와 같다.
management:
tracing:
sampling:
probability: 1.0
propagation:
consume: B3
produce: B3
zipkin:
tracing:
endpoint: http://${EC2_PUBLIC_DNS}:9411/api/v2/spans
나는 내가 허용한 URL만 Zipkin에서 추적하도록 설정했다.
import brave.sampler.Sampler;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Arrays;
import java.util.List;
@Configuration
public class ZipkinTracingConfig {
private static final List<String> ALLOWED_PATHS = Arrays.asList(
"/member/nicknameChange" // 허용할 첫 번째 URL 패턴
// 추후 추가될 URL
);
@Bean
public Sampler customSampler() {
return new Sampler() {
@Override
public boolean isSampled(long traceId) {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes) {
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
return ALLOWED_PATHS.stream().anyMatch(path -> request.getRequestURI().startsWith(path));
}
// HTTP 요청 정보가 없는 경우 기본적으로 추적하지 않음
return false;
}
};
}
}
이렇게 설정만 하면 끝난다!
사실 처음에는 아래 코드처럼 일단 RequestContextHolder.getRequestAttributes() 를 받아서 진행하려고 했다. 그러나 이 코드는 getRequestAttributes() 여기서 NullPointerException 에러가 나서 수정을 했다.
import brave.sampler.Sampler;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Arrays;
import java.util.List;
@Configuration
public class ZipkinTracingConfig {
private static final List<String> ALLOWED_PATHS = Arrays.asList(
"/member/nicknameChange" // 허용할 첫 번째 URL 패턴
// "/api/another", // 허용할 두 번째 URL 패턴
// "/api/more" // 추가로 허용할 URL 패턴
);
@Bean
public Sampler customSampler() {
return new Sampler() {
@Override
public boolean isSampled(long traceId) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
return ALLOWED_PATHS.stream().anyMatch(path -> request.getRequestURI().startsWith(path));
}
};
}
}
위 코드처럼 작성하면 아래와 같은 에러가 난다.
2023-11-22T06:12:43.949Z ERROR [member,,] 1 --- [nio-8081-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container java.lang.NullPointerException: Cannot invoke "org.springframework.web.context.request.ServletRequestAttributes.getRequest()" because the return value of "org.springframework.web.context.request.RequestContextHolder.getRequestAttributes()" is null 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at com.recipia.member.config.ZipkinTracingConfig$1.isSampled(ZipkinTracingConfig.java:27) ~[classes!/:0.0.1-SNAPSHOT] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at brave.Tracer.decorateContext(Tracer.java:279) ~[brave-5.15.1.jar!/:na] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at brave.Tracer.nextSpan(Tracer.java:366) ~[brave-5.15.1.jar!/:na] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at io.micrometer.tracing.brave.bridge.BraveSpanBuilder.span(BraveSpanBuilder.java:78) ~[micrometer-tracing-bridge-brave-1.1.3.jar!/:1.1.3] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at io.micrometer.tracing.brave.bridge.BraveSpanBuilder.start(BraveSpanBuilder.java:166) ~[micrometer-tracing-bridge-brave-1.1.3.jar!/:1.1.3] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at io.micrometer.tracing.handler.PropagatingReceiverTracingObservationHandler.onStart(PropagatingReceiverTracingObservationHandler.java:74) ~[micrometer-tracing-1.1.3.jar!/:1.1.3] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at io.micrometer.tracing.handler.PropagatingReceiverTracingObservationHandler.onStart(PropagatingReceiverTracingObservationHandler.java:35) ~[micrometer-tracing-1.1.3.jar!/:1.1.3] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at io.micrometer.observation.ObservationHandler$FirstMatchingCompositeObservationHandler.onStart(ObservationHandler.java:149) ~[micrometer-observation-1.11.2.jar!/:1.11.2] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at io.micrometer.observation.SimpleObservation.notifyOnObservationStarted(SimpleObservation.java:222) ~[micrometer-observation-1.11.2.jar!/:1.11.2] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at io.micrometer.observation.SimpleObservation.start(SimpleObservation.java:169) ~[micrometer-observation-1.11.2.jar!/:1.11.2] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at org.springframework.web.filter.ServerHttpObservationFilter.createOrFetchObservation(ServerHttpObservationFilter.java:133) ~[spring-web-6.0.11.jar!/:6.0.11] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at org.springframework.web.filter.ServerHttpObservationFilter.doFilterInternal(ServerHttpObservationFilter.java:107) ~[spring-web-6.0.11.jar!/:6.0.11] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.0.11.jar!/:6.0.11] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174) ~[tomcat-embed-core-10.1.11.jar!/:na] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149) ~[tomcat-embed-core-10.1.11.jar!/:na] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[spring-web-6.0.11.jar!/:6.0.11] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.0.11.jar!/:6.0.11] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174) ~[tomcat-embed-core-10.1.11.jar!/:na] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149) ~[tomcat-embed-core-10.1.11.jar!/:na] 09c4fde617fe4ddb9d639f36e7c77c16 recipia-member-container
NullPointerException이 발생하는 원인은 RequestContextHolder.getRequestAttributes()가 null을 반환하기 때문이다.
이는 Sampler 구현체 내에서 HTTP 요청 정보에 접근하려 할 때, 해당 정보가 존재하지 않는 상황에서 발생하는 일반적인 문제다.
Sampler 구현에서 HttpServletRequest를 사용하기 전에 RequestContextHolder.getRequestAttributes()가 null인지 확인하는 로직을 추가해야 한다. Sampler는 HTTP 요청 외에도 다른 상황에서 호출될 수 있기 때문에, 모든 상황에서 HttpServletRequest가 존재한다고 가정하면 안 된다.
💡 그런데 왜 application.yml을 수정하지 않아도 그대로 Sampler가 적용되는지 궁금해졌다.
Spring Boot의 추적 시스템에서는 application.yml 파일의 management.tracing.sampling.probability 설정이 기본적인 요청 추적 비율을 결정한다. 이 설정은 전체 요청 중 얼마나 많은 비율이 추적될지 정하는 기본값으로, 예를 들어 probability: 0.5라고 설정하면 이론적으로 전체 요청의 절반만 추적된다.
하지만, 커스텀 Sampler를 사용할 경우, 이 설정보다 더 우선적으로 적용된다. 즉, 커스텀 Sampler로 특정 조건을 설정하면, 그 조건에 부합하는 요청은 항상 추적된다. 예를 들어, 특정 URL에 대한 요청을 항상 추적하도록 설정할 수 있으며, 나머지 요청들 중에서는 application.yml의 설정에 따라 50%만 추적된다.
이러한 동작 방식 때문에, 커스텀 Sampler가 요청 별로 추적 여부를 결정한다면, application.yml의 management.tracing.sampling.probability 설정은 기본값인 1.0을 유지해도 된다. 즉, 커스텀 Sampler의 설정이 application.yml의 설정보다 우선 적용되며, 전체적인 추적 비율에 대한 설정은 커스텀 Sampler에 의해 덮어 쓰인다.
그러나 추적 비율을 낮추고 싶다면, management.tracing.sampling.probability 설정을 조절해 네트워크 트래픽과 리소스 사용량을 줄일 수 있다. 이 설정은 전체적인 추적 비율을 조절하지만, 커스텀 Sampler로 특정 경로의 추적 여부를 강제할 수 있다는 점에서 유연성을 제공한다. 따라서 특정 HTTP 요청만 추적하고자 한다면, 커스텀 Sampler 구현체를 사용하고 application.yml 설정은 기본값으로 두어도 충분하다. 이러한 방식은 추적 시스템의 유연성과 성능 최적화에 크게 기여한다.
4. Zipkin UI 확인
Zipkin UI를 확인해 보면 이제 더 이상 /health 요청이 추적되지 않는 모습을 확인할 수 있다.
(화면에 나와있는 로그들은 37분전, 27분전에 추적되었던 것들이다. 그 이후로는 추적되지 않고 있는 모습이다.)
그리고 내가 허용한 URL을 요청 보내면 아래와 같이 추적된다.
(아래 바로 /health 가 있는 건 다시 재배포하고 롤링 업데이트 되는 과정에 추적된 요청이다...)
5. 그리고 application.yml로 더 쉽게 수정하기
Zipkin 추적 관련 더 찾아보니까 application.yml 설정으로 아주 간단하게 추적을 제어하는 방법도 있었다.
spring:
sleuth:
web:
servlet:
skip-pattern: /health
이렇게 해도 해당 URL이 Zipkin에서 추적되지 않는다.
6. Spring Boot에서 Sampler를 적용하는 것과 application.yml에서 Spring Sleuth를 직접 설정하는 것 사이의 차이점
Sampler를 사용하면, 추적할 요청을 결정하기 위해 사용자 정의 논리를 적용할 수 있다. 이는 특정 조건에 기반하여 어떤 HTTP 요청을 추적할지, 어떤 요청을 무시할지 동적으로 결정하는 데 유용하다. 예를 들어, 특정 API 엔드포인트나 특정 시간대의 요청만 추적하고 싶을 때 커스텀 Sampler를 사용하여 이를 구현할 수 있다. 이 방법은 복잡한 추적 논리가 필요할 때 특히 유용하며, 추적의 유연성을 크게 향상시킨다.
반면, application.yml 파일에서 Spring Sleuth의 설정을 직접 조정하는 것은 전체적이고 정적인 추적 설정을 제공한다. 예를 들어, enabled: false 설정은 HTTP 클라이언트 추적을 전체적으로 비활성화한다. 또한, skip-pattern 설정을 사용하면 특정 URL 패턴을 가진 요청에 대한 추적을 건너뛸 수 있다. 이러한 설정은 애플리케이션 전반에 걸쳐 적용되며, 개별 요청 수준에서의 세밀한 추적 제어는 제공하지 않는다.
요약하자면, Sampler는 개별 요청 수준에서 더 정교한 추적 제어를 가능하게 하며, application.yml 설정은 전체적인 추적 설정에 더 적합하다. Sampler는 설정이 복잡하고 유연성이 필요한 상황에 적합한 반면, application.yml 설정은 단순하고 전역적인 추적 설정을 원할 때 사용하기 좋다.
결국 내가 원했던 건 /health라는 상태 검사용 URL을 Zipkin 추적을 비활성화하는 거였기 때문에 sleuth를 활용한 application.yml 설정을 수정해 줘도 됐을 거란 생각이 들었다.
7. 마무리
이 글을 마무리 지으며, 우리는 AWS ALB 헬스 체크와 Zipkin 추적 설정에 대한 여정을 되돌아본다. 이 과정에서 Zipkin의 커스텀 Sampler와 application.yml 설정을 활용하여 시스템 추적을 더욱 효율적으로 관리하는 방법을 배웠다.
그리고 한 가지 문제에 대해 다양한 해결책이 있음을 깨닫는 것은 언제나 흥미롭다. 이 경험은 탐구와 호기심이 우리의 시야를 얼마나 넓힐 수 있는지를 다시 한번 상기시켜 준다.
이 포스트는 Team chillwave에서 사이드 프로젝트 중 적용했던 부분을 다시 공부하며 기록한 것입니다.
시간이 괜찮다면 팀원 '개발자의 서랍'님의 블로그도 한번 봐주세요 :)
'Spring > Spring Boot' 카테고리의 다른 글
[Spring Boot] Spring Boot 3.X 버전에서 Spring Batch 적용하기 (0) | 2023.11.26 |
---|---|
[Spring Boot] 분산 시스템에서의 SNS/SQS 메시지 처리: 단일 책임 원칙과 Zipkin의 Trace ID 에러 핸들링을 중심으로 (0) | 2023.11.24 |
[Spring Boot] AWS 분산 시스템에서 Zipkin으로 로그 추적하기: SNS/SQS 통합 실전 적용 (1) | 2023.11.20 |
[Spring Boot] 스프링 부트에서 Feign 클라이언트 사용하기: 파라미터 처리 방식 이해하기 (1) | 2023.11.19 |
[Spring Boot] MSA 환경에서 SNS/SQS 활용하기: 서버간 DB 동기화와 제로 페이로드 방식의 효과적 구현 (0) | 2023.11.18 |