OpenTelemetry를 사용한 End-To-End Tracing

Nicolas Fränkel

Nicolas Fränkel

August 31, 2022

Ecosystem

마이크로서비스를 구현하든 하지 않든(그리고 아마도 하지 않는 것이 좋을 것입니다), 여러분의 시스템은 대부분 여러 컴포넌트로 구성되어 있을 것입니다. 가장 간단한 시스템은 리버스 프록시, 애플리케이션, 데이터베이스로 이루어져 있을 것입니다. 이 경우, 모니터링은 좋은 아이디어일 뿐만 아니라 필수입니다. 요청이 흐를 수 있는 컴포넌트의 수가 많을수록 이 요구사항은 더 강력해집니다.

그러나 모니터링은 여정의 시작일 뿐입니다. 요청이 대량으로 실패하기 시작하면 모든 컴포넌트에 걸친 집계된 뷰가 필요합니다. 이를 트레이싱이라고 하며, _관측 가능성_의 한 축입니다; 다른 두 축은 메트릭과 로그입니다.

이 글에서는 트레이스에만 초점을 맞추고 관측 가능성 여정을 시작하는 방법을 설명하겠습니다.

W3C Trace Context 명세

트레이싱 솔루션은 이기종 기술 스택에서 작동할 수 있는 표준 형식을 제공해야 합니다. 이러한 형식은 공식적이거나 사실상의 명세를 준수해야 합니다.

명세가 갑자기 나타나는 경우는 거의 없다는 것을 이해해야 합니다. 일반적으로 시장에는 이미 몇 가지 다른 구현이 존재합니다. 대부분의 경우, 새로운 명세는 추가적인 구현을 이끌어냅니다. 유명한 XKCD 만화가 설명하는 것처럼:

유명한 XKCD 만화

그러나 때로는 기적이 일어납니다: 시장이 새로운 명세를 받아들입니다. 여기서 Trace Context는 W3C 명세이며, 효과를 발휘한 것으로 보입니다:

이 명세는 분산 트레이싱 시나리오를 가능하게 하는 컨텍스트 정보를 전파하기 위한 표준 HTTP 헤더와 값 형식을 정의합니다. 이 명세는 서비스 간에 컨텍스트 정보가 전송되고 수정되는 방식을 표준화합니다. 컨텍스트 정보는 분산 시스템에서 개별 요청을 고유하게 식별하며, 제공자별 컨텍스트 정보를 추가하고 전파하는 수단도 정의합니다. -- https://www.w3.org/TR/trace-context/

이 문서에서 두 가지 중요한 개념이 나타납니다:

  • 트레이스는 여러 컴포넌트에 걸친 요청의 경로를 따릅니다.
  • 스팬은 단일 컴포넌트에 바인딩되며, 자식-부모 관계로 다른 스팬과 연결됩니다.

![트레이스와 스팬](https://static-site.apiseven.com/wp-content/uploads/2022/08/trace-spans-1024x393.png "트레이스와 스팬" "트레이스와 스팬")

이 글을 쓰는 시점에서, 이 명세는 W3C 권고안이며, 이는 최종 단계입니다.

Trace Context는 이미 많은 구현이 있습니다. 그 중 하나가 OpenTelemetry입니다.

OpenTelemetry를 골든 스탠다드로

IT 운영 부분에 가까울수록 OpenTelemetry에 대해 들어볼 가능성이 높습니다:

OpenTelemetry는 도구, API, SDK의 모음입니다. 이를 사용하여 소프트웨어의 성능과 동작을 분석하는 데 도움이 되는 원격 측정 데이터(메트릭, 로그, 트레이스)를 계측, 생성, 수집, 내보낼 수 있습니다. OpenTelemetry는 여러 언어에서 일반적으로 사용 가능하며 사용하기에 적합합니다. -- https://opentelemetry.io/

OpenTelemetry는 CNCF가 관리하는 프로젝트입니다. OpenTelemetry 이전에는 두 프로젝트가 있었습니다:

  • OpenTracing, 이름에서 알 수 있듯이 트레이스에 초점을 맞춘 프로젝트
  • OpenCensus, 메트릭과 트레이스를 관리하는 것을 목표로 한 프로젝트

두 프로젝트가 합쳐지고 로그를 추가했습니다. OpenTelemetry는 이제 관측 가능성에 초점을 맞춘 "레이어" 세트를 제공합니다:

  • 다양한 언어의 계측 API
  • 다시 다양한 언어의 표준 구현
  • 수집기와 같은 인프라 컴포넌트
  • W3C의 Trace Context와 같은 상호 운용성 형식

OpenTelemetry는 Trace Context의 구현이지만, 더 많은 것을 합니다. Trace Context는 HTTP에 국한되지만, OpenTelemetry는 Kafka와 같은 비웹 컴포넌트를 가로지르는 스팬을 허용합니다. 이는 이 블로그 글의 범위를 벗어납니다.

사용 사례

제가 가장 좋아하는 사용 사례는 전자상거래 상점입니다. 이 경우, 상점은 마이크로서비스로 설계되어 있으며, 각각 REST API를 통해 접근 가능하고 API 게이트웨이 뒤에 보호되어 있습니다. 블로그 글을 위해 아키텍처를 단순화하기 위해 두 개의 마이크로서비스만 사용하겠습니다: catalog는 제품을 관리하고, pricing은 제품의 가격을 처리합니다.

사용자가 앱에 접속하면 홈 페이지가 모든 제품을 가져오고, 각 제품의 가격을 가져와 표시합니다.

여러 컴포넌트를 가로지르는 요청 흐름 예시

더 흥미롭게 만들기 위해, catalog는 Kotlin으로 코딩된 Spring Boot 애플리케이션이고, pricing은 Python Flask 애플리케이션입니다.

트레이싱은 게이트웨이, 두 마이크로서비스, 그리고 가능하다면 데이터베이스를 가로지르는 요청의 경로를 따라갈 수 있게 해야 합니다.

게이트웨이에서의 트레이스

트레이싱의 진입점은 가장 흥미로운 부분입니다. 여기서 트레이스 ID를 생성해야 합니다: 이 경우 진입점은 게이트웨이입니다. 데모를 구현하기 위해 Apache APISIX를 사용하겠습니다:

Apache APISIX는 로드 밸런싱, 동적 업스트림, 카나리 릴리스, 서킷 브레이커, 인증, 관측 가능성 등과 같은 풍부한 트래픽 관리 기능을 제공합니다. -- https://apisix.apache.org/

Apache APISIX는 플러그인 아키텍처를 기반으로 하며 OpenTelemetry 플러그인을 제공합니다:

opentelemetry 플러그인은 OpenTelemetry 명세에 따라 트레이싱 데이터를 보고하는 데 사용할 수 있습니다. 이 플러그인은 HTTP를 통한 바이너리 인코딩된 OLTP만 지원합니다. -- https://apisix.apache.org/docs/apisix/plugins/opentelemetry/

opentelemetry 플러그인을 구성해 보겠습니다:

apisix:
  enable_admin: false #1
  config_center: yaml #1
plugins:
  - opentelemetry #2
plugin_attr:
  opentelemetry:
    resource:
      service.name: APISIX #3
    collector:
      address: jaeger:4318 #4
  1. 데모를 더 쉽게 따라가기 위해 Apache APISIX를 독립 실행 모드로 실행합니다. 프로덕션에서도 좋은 관행입니다.
  2. opentelemetry를 글로벌 플러그인으로 구성합니다.
  3. 서비스 이름을 설정합니다. 이 이름은 트레이스 표시 컴포넌트에 나타납니다.
  4. 트레이스를 jaeger 서비스로 보냅니다. 다음 섹션에서 이를 설명하겠습니다.

모든 경로를 트레이싱하고 싶으므로 각 경로에 플러그인을 추가하는 대신 플러그인을 글로벌로 설정해야 합니다:

global_rules:
  - id: 1
    plugins:
      opentelemetry:
        sampler:
          name: always_on #1
  1. 트레이싱은 성능에 영향을 미칩니다. 더 많이 트레이싱할수록 더 많은 영향을 미칩니다. 따라서 성능 영향과 관측 가능성의 이점을 신중하게 균형을 맞춰야 합니다. 그러나 데모를 위해 모든 요청을 트레이싱하고 싶습니다.

트레이스 수집, 저장 및 표시

Trace Context는 W3C 명세이고 OpenTelemetry는 사실상의 표준이지만, 시장에는 트레이스를 수집, 저장, 표시하는 많은 솔루션이 있습니다. 각 솔루션은 이 세 가지 기능을 모두 제공하거나 일부만 제공할 수 있습니다. 예를 들어, Elastic 스택은 저장과 표시를 처리하지만, 수집을 위해서는 다른 것을 의존해야 합니다. 반면에 JaegerZipkin은 이 세 가지 기능을 모두 충족하는 완전한 제품군을 제공합니다.

Jaeger와 Zipkin은 OpenTelemetry보다 앞서 나왔기 때문에 각각 고유한 트레이스 전송 형식을 가지고 있습니다. 그러나 OpenTelemetry 형식과의 통합을 제공합니다.

이 블로그 글의 범위에서 정확한 솔루션은 관련이 없습니다. 우리는 기능만 필요합니다. 저는 Jaeger를 선택했습니다. 왜냐하면 모든 기능을 하나의 Docker 이미지에 포함하고 있기 때문입니다: 모든 기능은 각각의 컴포넌트가 있지만, 동일한 이미지에 포함되어 있어 구성이 훨씬 쉬워집니다.

이미지의 관련 포트는 다음과 같습니다:

포트프로토콜컴포넌트기능
16686HTTPquery프론트엔드 제공
4317HTTPcollectorgRPC를 통한 OpenTelemetry Protocol (OTLP) 수락, 활성화된 경우
4318HTTPcollectorHTTP를 통한 OpenTelemetry Protocol (OTLP) 수락, 활성화된 경우

Docker Compose 부분은 다음과 같습니다:

services:
  jaeger:
    image: jaegertracing/all-in-one:1.37 #1
    environment:
      - COLLECTOR_OTLP_ENABLED=true #2
    ports:
      - "16686:16686" #3
  1. all-in-one 이미지를 사용합니다.
  2. 매우 중요: OpenTelemetry 형식으로 수집기를 활성화합니다.
  3. UI 포트를 노출합니다.

이제 인프라를 설정했으므로 애플리케이션에서 트레이스를 활성화하는 데 집중할 수 있습니다.

Flask 앱에서의 트레이스

pricing 서비스는 간단한 Flask 애플리케이션입니다. 데이터베이스에서 단일 제품의 가격을 가져오는 단일 엔드포인트를 제공합니다.

@app.route('/price/<product_str>')                           #1-2
def price(product_str: str) -> Dict[str, object]:
    product_id = int(product_str)
    price: Price = Price.query.get(product_id)               #3
    if price is None:
        return jsonify({'error': 'Product not found'}), 404
    else:
        low: float = price.value - price.jitter              #4
        high: float = price.value + price.jitter             #4
        return {
            'product_id': product_id,
            'price': round(uniform(low, high), 2)            #4
        }
  1. 엔드포인트
  2. 경로는 제품의 id를 요구합니다.
  3. SQLAlchemy를 사용하여 데이터베이스에서 데이터를 가져옵니다.
  4. 실제 가격 엔진은 시간이 지나도 동일한 가격을 반환하지 않습니다; 재미를 위해 가격을 약간 무작위화해 보겠습니다.

경고: 단일 가격을 호출당 가져오는 것은 매우 비효율적입니다. 제품 수만큼 호출이 필요하지만, 더 흥미로운 트레이스를 만들기 위해 이렇게 했습니다. 실제로는 경로가 여러 제품 id를 받아들이고 관련된 모든 가격을 하나의 요청-응답으로 가져올 수 있어야 합니다.

이제 애플리케이션을 계측할 시간입니다. 두 가지 옵션이 있습니다: 자동 계측과 수동 계측. 자동은 낮은 노력과 빠른 성과를 제공합니다; 수동은 집중된 개발 시간이 필요합니다. 저는 자동으로 시작하고 필요한 경우에만 수동을 추가하는 것을 권장합니다.

몇 가지 Python 패키지를 추가해야 합니다:

opentelemetry-distro[otlp]==0.33b0
opentelemetry-instrumentation
opentelemetry-instrumentation-flask

몇 가지 매개변수를 구성해야 합니다:

pricing:
  build: ./pricing
  environment:
    OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 #1
    OTEL_RESOURCE_ATTRIBUTES: service.name=pricing #2
    OTEL_METRICS_EXPORTER: none #3
    OTEL_LOGS_EXPORTER: none #3
  1. 트레이스를 Jaeger로 보냅니다.
  2. 서비스 이름을 설정합니다. 이 이름은 트레이스 표시 컴포넌트에 나타납니다.
  3. 로그나 메트릭에는 관심이 없습니다.

이제 표준 flask run 명령 대신 다음과 같이 래핑합니다:

opentelemetry-instrument flask run

이렇게 하면 이미 메서드 호출과 Flask 경로에서 스팬을 수집합니다.

필요한 경우 수동으로 추가 스팬을 추가할 수 있습니다, 예를 들어:

from opentelemetry import trace

@app.route('/price/<product_str>')
def price(product_str: str) -> Dict[str, object]:
    product_id = int(product_str)
    with tracer.start_as_current_span("SELECT * FROM PRICE WHERE ID=:id", attributes={":id": product_id}) as span: #1
        price: Price = Price.query.get(product_id)
    # ...
  1. 구성된 레이블과 속성으로 추가 스팬을 추가합니다.

Spring Boot 앱에서의 트레이스

catalog 서비스는 Kotlin으로 개발된 Reactive Spring Boot 애플리케이션입니다. 두 가지 엔드포인트를 제공합니다:

  • 하나는 단일 제품을 가져오는 엔드포인트
  • 다른 하나는 모든 제품을 가져오는 엔드포인트

둘 다 먼저 제품 데이터베이스를 확인한 다음, 위의 pricing 서비스에 가격을 요청합니다.

Python과 마찬가지로, 자동 및 수동 계측을 활용할 수 있습니다. 먼저 낮은 열매를 따는 자동 계측부터 시작하겠습니다. JVM에서는 에이전트를 통해 이를 달성합니다:

java -javaagent:opentelemetry-javaagent.jar -jar catalog.jar

Python과 마찬가지로, 모든 메서드 호출과 HTTP 진입점에 대해 스팬을 생성합니다. 또한 JDBC 호출을 계측하지만, 우리는 Reactive 스택을 사용하므로 R2DBC를 사용합니다. 기록을 위해, GitHub 이슈가 지원 추가를 위해 열려 있습니다.

기본 동작을 구성해야 합니다:

catalog:
  build: ./catalog
  environment:
    APP_PRICING_ENDPOINT: http://pricing:5000/price
    OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 #1
    OTEL_RESOURCE_ATTRIBUTES: service.name=orders #2
    OTEL_METRICS_EXPORTER: none #3
    OTEL_LOGS_EXPORTER: none #3
  1. 트레이스를 Jaeger로 보냅니다.
  2. 서비스 이름을 설정합니다. 이 이름은 트레이스 표시 컴포넌트에 나타납니다.
  3. 로그나 메트릭에는 관심이 없습니다.

Python과 마찬가지로, 수동 계측을 추가하여 게임을 업그레이드할 수 있습니다. 두 가지 옵션이 있습니다: 프로그래밍 방식과 어노테이션 기반. 전자는 Spring Cloud Sleuth를 도입하지 않는 한 약간 복잡합니다. 어노테이션을 추가해 보겠습니다.

추가 종속성이 필요합니다:

<dependency>
    <groupId>io.opentelemetry.instrumentation</groupId>
    <artifactId>opentelemetry-instrumentation-annotations</artifactId>
    <version>1.17.0-alpha</version>
</dependency>

주의, 아티팩트는 최근에 io.opentelemetry:opentelemetry-extension-annotations에서 재배치되었습니다.

이제 코드에 어노테이션을 추가할 수 있습니다:

@WithSpan("ProductHandler.fetch")                                               //1
suspend fun fetch(@SpanAttribute("id") id: Long): Result<Product> {             //2
    val product = repository.findById(id)
    return if (product == null) Result.failure(IllegalArgumentException("Product $id not found"))
    else Result.success(product)
}
  1. 구성된 레이블로 추가 스팬을 추가합니다.
  2. 매개변수를 속성으로 사용하며, 키를 id로 설정하고 값은 매개변수의 런타임 값입니다.

결과

이제 간단한 데모를 통해 결과를 확인할 수 있습니다:

curl localhost:9080/products
curl localhost:9080/products/1

응답은 흥미롭지 않지만, Jaeger UI를 살펴보겠습니다. 두 트레이스를 찾을 수 있으며, 각 호출당 하나씩입니다:

Jaeger UI의 트레이스 목록

단일 트레이스의 스팬을 자세히 살펴볼 수 있습니다:

단일 트레이스를 구성하는 모든 스팬

위의 UML 다이어그램 없이도 시퀀스 흐름을 추론할 수 있습니다. 더 나아가, 시퀀스는 컴포넌트 내부의 호출을 표시합니다.

각 스팬에는 자동 계측이 추가한 속성과 우리가 수동으로 추가한 속성이 포함되어 있습니다:

스팬의 속성

결론

이 글에서는 API 게이트웨이, 다른 기술 스택을 기반으로 한 두 애플리케이션, 그리고 각각의 데이터베이스를 가로지르는 요청의 경로를 따라가며 트레이싱을 보여주었습니다. 저는 트레이싱의 표면만을 살펴보았습니다: 실제 세계에서는 Kafka와 메시지 큐와 같은 HTTP와 관련 없는 컴포넌트가 포함될 가능성이 높습니다.

그러나 대부분의 시스템은 어떤 식으로든 HTTP에 의존합니다. 설정이 간단하지는 않지만, 너무 어렵지도 않습니다. 컴포넌트를 가로지르는 HTTP 요청을 트레이싱하는 것은 시스템의 관측 가능성을 향한 여정을 시작하는 좋은 출발점입니다.

이 글의 전체 소스 코드는 GitHub에서 찾을 수 있습니다.

더 알아보기:

Tags: