티스토리 뷰
[o11y] OpenTelemetry, Jaeger을 활용해 spring-boot 어플리케이션 분산 추적해보기
시리어스강 2024. 1. 28. 22:39사내에서 어플리케이션 모니터링 관련하여 기존에 사용하던 상용 APM과 별개로, OpenTelemetry(OTel
) 기반의 Observability 환경을 준비해보자라는 의견이 있어 개인 공부 겸 포스팅을 해보려고 한다.
Baeldung 글을 참고하여, OTel
과 Jaeger
를 활용해 어플리케이션을 어떻게 분산 추적(distributed tracing)하는지 아주아주 간단한 예제로 살펴보자 😎
1. 기본 개념
예제를 살펴보기 전에 주로 언급할 개념들에 대해 간단히 살펴보면 다음과 같다. → 각 잡고 보자면 개념만으로도 살펴봐야할 내용들이 많겠지만, 이번 포스팅의 목적은 어플리케이션을 OTel
과 Jaeger
로 살펴보기 위함이니 간략히 정리했다.
OpenTelemetry(OTel)
OTel
은 API, SDK 및 도구 모음으로써, 이를 활용해 소프트웨어의 성능과 동작을 분석하는데 도움이 되는 텔레메트리 데이터(metric, log, trace)를 측정(instrument), 생성(generate), 수집(collect), 내보낼 수(export) 있다.
OTel Collector
OTel
의 핵심 구성 요소 중 하나로써, OTel Collectors
를 통해 텔레메트리 데이터를 수신, 처리, 내보내는 방법을 벤더사에 구애받지 않고(vendor-agnostic) 구현할 수 있다. 따라서 여러 agent/collector를 실행, 운영, 유지 관리할 필요가 없다.
뒤에서 살펴볼 샘플 어플리케이션에서는 OTel Collector
를 통해 Jaeger
로 텔레메트리 데이터를 보내지만, 위 그림에서 볼 수 있듯이 프로메테우스
나 다른 Observability 백엔드로 데이터를 처리하고 내보낼 수 있다.
분산 추적(Distributed tracing)
분산 추적이란 분산 시스템을 통해 전달되는 데이터 요청을 관찰하는 것이다. 분산 추적을 통해 개발자는 마이크로서비스 전반에서 발생하는 요청 경로를 추적할 수 있다.
Jaeger
분산 추적 가시성 플랫폼(Distributed tracing observability platform) → 개념만 봐서는 쉽게 와닿지 않을 수 있는데, 이후 살펴볼 Jaeger 화면을 보면 어떤 역할을 하는지 쉽게 이해할 수 있다.
트레이스/스팬(trace/span)
트레이스
는 어플리케이션을 구성하는 서비스에서 처리하는 단일 요청의 진행 상황을 추적하는 것을 뜻한다. 스팬
은 트레이스
의 작업 단위인데, 요청이 시스템을 통과할 때 관련된 개별 서비스 또는 구성 요소가 수행하는 작업을 나타낸다. → 개념과 위 그림을 함께 보면 훨씬 이해하기 쉬울 것 같다.
다운스트림/업스트림
어떤 항목이 다른 항목에 가치를 더하거나 의존하는 경우, 이를 다운스트림
에 해당한다.
소프트웨어 의존 관계로 예를 들면, 컴포넌트 C는 컴포넌트 A와 B의 기능을 임포트하고 자신의 가치를 추가해 다운스트림
컴포넌트가 된다.
마이크로서비스로 예를 들면, 서비스 A가 서비스 B를 의존하므로 다운스트림
이 된다. 이후에 살펴볼 어플리케이션도 이와 동일한 구성으로 되어 있다.
2. 어플리케이션
분산 추적을 위해 어플리케이션은 두 개(업스트림/다운스트림)로 나누어 구성했다. 모든 내용을 열거하기엔 분량이 많아 생략한 내용들도 있으므로, 세부 구현 내용은 github을 참고하면 된다.
build.gradle 설정
dependencyManagement {
imports {
mavenBom 'org.springframework.cloud:spring-cloud-dependencies:2021.0.5'
mavenBom 'org.springframework.cloud:spring-cloud-sleuth-otel-dependencies:1.1.2'
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation ('org.springframework.cloud:spring-cloud-starter-sleuth') {
exclude group: 'org.springframework.cloud', module: 'spring-cloud-sleuth-brave'
}
implementation 'org.springframework.cloud:spring-cloud-sleuth-otel-autoconfigure'
implementation 'io.opentelemetry:opentelemetry-exporter-otlp:1.23.1'
}
build.gradle
설정은 두 어플리케이션이 동일하다. 특이 사항이라면, spring-cloud-sleuth
는 brave
라는 분산 추적 라이브러리를 사용하는데 이를 OTel
로 대체하기 위해 종속성에서 제외한다.
그리고 spring-boot-starter-actuator
의존성의 경우 프로젝트를 구성하며 헬스 체크를 위해 넣었는데, 제외시켜도 무방하다.
시퀀스 다이어그램
상호 작용 과정을 요약하자면 다음과 같다.
다운스트림 어플리케이션
상품 정보를 불러올 수 있는 API(/product/{id}
)를 호출하면,
@RestController
@RequestMapping("/product")
public class ProductController {
...
@GetMapping(path = "/{id}")
public Product product(@PathVariable long id) {
log.info("getting product and price details with product id = {}", id);
return productRepository.findProductById(id);
}
}
가격 정보를 불러 올 수 있는 업스트림 어플리케이션의 API를 호출해서, 데이터를 가지고 오는 형태로 되어 있다.
public class PriceClient {
private final RestTemplate restTemplate;
...
public Price price(long id) {
log.info("fetching price details with product id = {}", id);
String url = String.format("%s/price/%d", baseUrl, id);
return restTemplate.getForEntity(url, Price.class)
.getBody();
}
}
업스트림 어플리케이션
가격 정보를 불러올 수 있는 API(/price/{id}
)를 제공한다.
@RestController
@RequestMapping("/price")
public class PriceController {
...
@GetMapping(path = "/{id}")
public Price price(@PathVariable long id) {
log.info("get price details for product id = {}", id);
return priceRepository.findPriceById(id);
}
}
테스트 데이터
호출 데이터의 경우 별도의 DB를 사용하지 않고, repository
빈이 생성된 이후에 특정 데이터를 메모리에 저장해서 사용하는 방식으로 구성되어 있다.
상품과 가격 데이터를 보면 상품 ID와 가격 ID가 동일한 ID를 사용하는데, 업스트림 어플리케이션에서 에러가 발생하는 케이스를 살펴보기 위해, 100005 ID의 상품은 가격 데이터가 빠져있다.
상품 ID | 상품명 | 가격 ID | 가격 | 할인률 |
100001 | apple | 100001 | 12.5 | 2.5 |
100002 | pears | 100002 | 10.5 | 2.1 |
100003 | banana | 100003 | 18.5 | 2.2 |
100004 | mango | 100004 | 18.5 | 2.2 |
100005 | test | - | - | - |
호출 샘플
업스트림 어플리케이션의 포트는 18080으로 설정하고, 다운스트림 어플리케이션의 포트는 18081로 설정해두었다.
정상 요청
에러 요청
3. 어플리케이션 Dockerfile
업스트림 어플리케이션
FROM adoptopenjdk/openjdk11:alpine
ARG APP_JAR=upstream-0.0.1-SNAPSHOT.jar
COPY build/libs/${APP_JAR} /app/${APP_JAR}
WORKDIR /app
ENTRYPOINT ["java", "-jar", "upstream-0.0.1-SNAPSHOT.jar"]
openjdk11
이미지를 기반으로, 빌드된 어플리케이션 jar를 특정 경로로 복사한 뒤 jar를 실행하도록 구성되어 있다. 다운스트림 어플리케이션의 구성도 jar 이름만 다르고, 동일하게 되어있다.
여기까지 준비했다면, 컨테이너 기반으로 서비스를 호출해 볼 수도 있다.
4. Spring Sleuth 구성
Spring Sleuth
설정을 통해 트레이스를 OTel Collector
로 내보낼 수 있다.
application.yml
spring:
sleuth:
otel:
config:
trace-id-ratio-based: 1.0
exporter:
otlp:
endpoint: http://collector:4317
trace-id-ratio-based
은 수집된 스팬
의 샘플링 비율을 정의한 속성인데, 1.0으로 설정할 경우 모든 스팬
을 내보낸다는 의미이다.
그리고 OTel
endpoint로 텔레메트리 데이터를 보내기 위한 설정이 있는데, endpoint의 경로는 이후에 살펴볼 docker-compose
내 설정을 참고하면 된다.
5. OTel Collector 설정
receivers:
otlp:
protocols:
grpc:
http:
processors:
batch:
exporters:
jaeger:
endpoint: jaeger-service:14250
tls:
insecure: true
service:
pipelines:
traces:
receivers: [ otlp ]
processors: [ batch ]
exporters: [ jaeger ]
exporter
의 경우, 예제에서 Jaeger
로 분산 추적을 할 예정이므로 Jaeger
로 설정한다. endpoint 경로는 이후에 살펴볼 docker-compose
내 설정을 참고하면 된다.
서비스 섹션 내 processors
의 batch
옵션은 데이터를 더 잘 압축하고, 데이터 전송에 필요한 발신 연결 수를 줄이는데 활용된다.
receiver
는 OTLP
를 사용하는데, gRPC 또는 HTTP를 통해 데이터를 수신하는 것으로 보면 된다.
6. docker-compose를 활용한 서비스 구성
서비스 기동은 docker-compose를 활용할 것이다.
docker-compose
version: "3.3"
services:
upstream-service:
platform: linux/amd64
build: upstream/
ports:
- "18080:18080"
downstream-service:
platform: linux/amd64
build: downstream/
ports:
- "18081:18081"
collector:
image: otel/opentelemetry-collector:0.72.0
command: [ "--config=/etc/otel-collector-config.yml" ]
volumes:
- ./otel-config.yml:/etc/otel-collector-config.yml
ports:
- "4317:4317"
depends_on:
- jaeger-service
jaeger-service:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686"
- "14250"
upstream-service
/ downsteam-service
는 어플리케이션 부분으로, 특이 사항이라면 샘플 코드를 작성한 단말이 애플 Mac M1을 사용해서 docker-compose
내에 platform
을 linux/amd64
로 설정해두었는데 다른 환경에서 테스트가 필요하다면 이 부분에 대한 수정이 필요할 수 있다!
collector
는 이전에 작성한 OTel Collector
설정 파일을 기반으로 OTel Collector
를 기동하는 부분이다. ports로 노출한 포트는 receiver
가 데이터를 수신할 포트로, 4317의 경우 gRPC이다.
jaeger-service
는 Jaeger
를 기동하는 부분으로 16886 포트는 Jaeger UI
이고, 14250 포트는 데이터를 수집하기 위한 포트이다.
7. 서비스 기동
서비스 기동은 어플리케이션을 빌드한 후 docker-compse up
명령어로 실행하면 된다. github에 올려둔 샘플 코드에서는 startup.sh
스크립트로 한번에 실행할 수 있다.
#!/bin/sh
../gradlew downstream:build upstream:build
docker-compose up --build
서비스 기동 확인
$ docker ps | grep opentelemetry
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
... opentelemetry-setup-with-jaeger-upstream-service "java -jar upstream-…" ... Up About a minute 0.0.0.0:18080->18080/tcp opentelemetry-setup-with-jaeger-upstream-service-1
... opentelemetry-setup-with-jaeger-downstream-service "java -jar downstrea…" ... Up About a minute 0.0.0.0:18081->18081/tcp opentelemetry-setup-with-jaeger-downstream-service-1
... otel/opentelemetry-collector:0.72.0 "/otelcol --config=/…" ... Up About a minute 0.0.0.0:4317->4317/tcp, 55678-55679/tcp opentelemetry-setup-with-jaeger-collector-1
... jaegertracing/all-in-one:latest "/go/bin/all-in-one-…" ... Up About a minute .., 0.0.0.0:16686->16686/tcp, 0.0.0.0:50201->14250/tcp opentelemetry-setup-with-jaeger-jaeger-service-1
8. 분산 추적
이제 모든 준비가 끝났으니 분산 추적을 해보자.
요청 성공
먼저 다운스트림 어플리케이션의 API(ex. http://<server-ip>:18081/product/100003)를 호출하면 아래와 같은 로그를 확인할 수 있다.
opentelemetry-setup-with-jaeger-downstream-service-1 | 2024-01-28 ... INFO [downstream-service,✅232b74791301ad3d39e16851314b57ba,🚩13a2ebe57954e543] 1 --- [io-18081-exec-5] c.example.controller.ProductController : getting product and price details with product id = 100003
opentelemetry-setup-with-jaeger-downstream-service-1 | 2024-01-28 ... INFO [downstream-service,✅232b74791301ad3d39e16851314b57ba,🚩13a2ebe57954e543] 1 --- [io-18081-exec-5] c.example.repository.ProductRepository : get product from product repo with product id = 100003
opentelemetry-setup-with-jaeger-downstream-service-1 | 2024-01-28 ... INFO [downstream-service,✅232b74791301ad3d39e16851314b57ba,🚩13a2ebe57954e543] 1 --- [io-18081-exec-5] com.example.api.client.PriceClient : fetching price details with product id = 100003
opentelemetry-setup-with-jaeger-upstream-service-1 | 2024-01-28 ... INFO [upstream-service,✅232b74791301ad3d39e16851314b57ba,🚩7a923b69bf0d1e7f] 1 --- [io-18080-exec-3] com.example.controller.PriceController : get price details for product id = 100003
opentelemetry-setup-with-jaeger-upstream-service-1 | 2024-01-28 ... INFO [upstream-service,✅232b74791301ad3d39e16851314b57ba,🚩7a923b69bf0d1e7f] 1 --- [io-18080-exec-3] com.example.repository.PriceRepository : find price from price repo with product id = 100003
로그를 자세히 살펴보면 다운스트림과 업스트림 어플리케이션에서 남기는 로그에 동일한 ID(✅)가 남아있는 것을 볼 수 있는데, 이것이 트레이스 ID
이다. 이를 통해, 하나의 호출로 여러 마이크로서비스를 호출하는 환경에서 요청 흐름을 살펴볼 수 있다. 참고로 트레이스 ID
옆에 있는 ID(🚩)는 스팬 ID
이다.
이 같은 정보를 확인할 수 있는 이유는 어플리케이션 의존성으로 추가했던 Spring Cloud Sleuth
가 API 호출 시, 트레이스 ID
및 스팬 ID
를 HTTP 헤더에 추가해주기 때문이다.
Jaeger UI
를 통해 내용을 살펴보면, 트레이스 ID
가 232b747
인 요청에 대한 정보를 확인할 수 있고
특정 트레이스 ID
에 대해서 스팬 ID
를 포함하여 더 많은 정보를 확인해 볼 수 있다.
요청 실패
성공 요청에 대한 트레이스 모니터링도 해보았으니, 실패 요청에 대한 모니터링도 해보자.
상품 ID가 100005
로 상품 정보를 요청할 경우, 가격 정보가 없어 업스트림 어플리케이션에서 404
에러를 내도록 구성 되어있다.
이를 Jaeger UI
에서 살펴보면, 빨간색 표시가 뜨면서 에러가 발생했음을 알 수 있다.
스팬 정보에서 HTTP status code도 확인 가능하다.
9. 정리
어플리케이션 개발부터 OTel
, Jaeger
를 통한 분산 추적까지 해보았는데, 하나의 포스팅에 담기에는 꽤나 많은 양이었던 것 같다. 분량상 설명이 생략된 부분도 있긴 하지만, 그래도 나처럼 어플리케이션을 띄워보면서 이해하는걸 선호하는 분들에게 어떤 식으로든 도움이 되었으면 좋겠다.
많은 내용들이 있었지만, 그 중에서 개인적으로 중요하다고 생각되는 트레이스 데이터의 흐름을 요약하는 걸로 이번 포스팅을 마치겠다.
트레이스 데이터 흐름 요약
1. 유저가 `다운스트림` 어플리케이션의 상품 조회 API를 호출
2. `다운스트림`/`업스트림` 어플리케이션에서 `Collector`로 트레이스 데이터 전송(4317 port)
3. `Collector`는 `Jaeger` 엔드포인트를 통해 트레이스 데이터를 `Jaeger` 백엔드로 전송(14250 port)
4. 로그 및 `Jaeger` UI를 통해 분산 추적
샘플 코드 🤓
참고 자료 🙇♂️
- https://www.baeldung.com/spring-boot-opentelemetry-setup
- https://reflectoring.io/upstream-downstream/
- https://www.jaegertracing.io/
- https://aws.amazon.com/ko/what-is/distributed-tracing/
- https://opentelemetry.io/
- https://hackage.haskell.org/package/hs-opentelemetry-sdk-0.0.3.6/docs/OpenTelemetry-Trace.html