[번역] Zipkin

rin·2020년 8월 19일
0

Document 번역

목록 보기
19/22
post-thumbnail

Zipkin-Instrumenting a library를 번역합니다.

Instrumenting a library

이는 고급 주제이다. 더 읽기 전에 사용하는 플랫폼에 이미 계측 라이브러리가 존재하는지 확인하라. 직접 계측 라이브러리를 작성하려면 먼저 해야할 일이 있다. Zipkin Gitter chat channel으로 와서 알려주길 바란다. 이 과정에서 기꺼이 도움을 받을 수 있을 것이다.

Overview

라이브러리를 만들기 위해서는 다음 요소를 이해해야한다.

  1. 코어 데이터 구조 : 수집되어 Zipkin으로 전달되는 정보
  2. trace 식별자 : Zipkin에 전달된 데이터가 논리적 순서로 재조립되는데 필요한 태그
    식별자 생성 : 이러한 ID를 생성하는 방법과 상속되야하는 ID
    Communicating trace information : trace 및 해당 ID와 함께 Zipkin으로 전송되는 추가 정보
  3. Timestamp 및 기간 : 특정 작업이 발생할 때의 시간 정보를 기록하는 방법

Core data structures

코어 데이터 구조는 Thrift comments에 자세히 설명되어 있다.

Annotation

어노테이션은 무언가가 발생한 시점을 기록하는데 사용된다. RPC 요청의 시작과 끝을 정의하는데 사용되는 핵심 주석set이 있다.

  • cs (Client Sent) : 클라이언트가 요청한 순간. span의 시작을 나타낸다.
  • sr (Server Received) : 서버가 클라이언트의 요청을 받고 처리를 시작한 순간. sr 타임스탬프 - cs 타임스탬프 = 네트워크 대기 시간
  • ss (Server Sent) : 요청의 처리가 완료된 순간. (즉, 응답이 클라이언트로 전송되는 순간.) ss 타임스탬프 - sr 타임스탬프 = 서버가 요청을 처리하는데 걸린 시간
  • cr (Client Received) : 클라이언트가 서버 측에서 보낸 응답을 받는데 성공한 순간. Span의 종료를 나타낸다. cr 타임스탬프 - cs 타임스탬프 = 클라이언트가 서버에 요청을 보낸 뒤 응답을 받는 순간까지 걸린 전체 시간. 이 어노테이션이 기록될 때 RPC는 완료된 것으로 간주된다.

RPC 대신 메세지 브로커를 사용할 때 다음 어노테이션은 흐름의 방향을 명확히하는데 도움을 준다.

  • ms (Message Sent) : producer가 브로커에게 메시지를 보낸다.
  • mr (Message Received) : consumer가 브로커로부터 메시지를 수신했다.

RPC와 달리 메세징 span은 spanID를 공유하지 않는다. 예를 들어 메세지의 각 consumer는 생성된 span의 또 다른 자식 span이다.

request가 살아있는 동안에 이해를 돕기위한 다른 주석을 기록 할 수 있다. 예를 들어 서버가 고비용의 작업을 시작하고 끝낼 때 주석을 추가하면 요청 처리 전후에 얼마나 많은 시간이 소요되는지와 작업에 소요된 시간을 계산할 수 있다.

BinaryAnnotation

Binary 어노테이션에는 시간 컴포넌트가 없다. 이는 RPC에 대한 추가 정보를 제공하기 위한 것이다. 예를들어 HTTP 서비스를 호출 할 때의 URI를 제공하면 나중에 서비스로 들어오는 request를 분석하는데 도움이 된다. 바이너리 어노테이션은 Zipkin Api나 UI에서 검색에도 사용할 수 있다.

Endpoint Annotations과 binary annotations은 연결된 endpoint를 가진다. 두 가지 예외를 제외하고 이 endpoint는 추적중인 프로세스와 연결된다. 예를 들어, Zipkin UI의 서비스 이름 드롭다운(UI 말하는 듯)는 Annotation.endpoint.serviceName 또는 BinaryAnnotation.endpoint.serviceName에 해당한다. 가용성을 위해 Endpoint.serviceName의 cardinality(행의 수)를 바인딩해야한다. 이 때 변수나 난수를 포함해서는 안된다.

Span

특정 RPC에 해당하는 어노테이션과 바이너리 어노테이션의 집합니다. span에는 traceId, spanId, parentId, RPC이름과 같은 식별 정보가 포함된다.

span은 일반적으로 작다. 예를 들어, 직렬화된 형태에서는 종종 KiB(킬로 이진 바이트) 이하로 측정된다. span이 KiB 이상으로 커지만 Kafka 메세지 크기(1MiB)와 같은 한계치에 도달하는 것과 같은 문제가 발생할 것이다. 메세지 크기 제한을 높이더라도 span이 크면 비용이 증가하고 추적 시스템의 유용성이 떨어진다. 따라서 시스템 동작을 설명하는데 도움이 되는 데이터만 저장하고 그렇지 않은 데이터는 저장하지 않도록 주의한다.

Trace

단일 root span을 공유하는 span 집합.

  1. trace는 traceId를 공유하는 모든 span을 수집하여 빌드된다.
  2. 그 다음 spanId 및 parentId를 기반으로 트리에 span이 정렬된다.
  3. request가 시스템을 통과하는 경로에 대한 개요를 제공한다.

Trace identifiers

span 집합을 전체 trace로 재조립하려면 세가지 정보가 필요하다. trace 식별자는 128-bit일 수 있지만 trace 내 span의 식별자는 항상 64-bit이다.

Trace Id
64-bit 혹은 128-bit의 ID이다. trace의 모든 span은 이 ID를 공유한다.

Span Id
특정한 span의 ID이다. root span의 경우 trace ID와 동일하다.

Parent Id
하위 span에만 표시되는 선택적 ID이다. 부모 ID가 없는 span은 trace의 root(=root span)로 간주된다.

Generating identifiers

Span이 식별되는 방법을 살펴보도록 하겠다.

request에 첨부된 trace 정보가 없으면 임의의 traceID와 spanID를 생성한다. traceID의 하위 64-bit는 spanID로 재사용 될 수도 있으나 완전히 다를 수도 있다.

request에 이미 trace 정보가 첨부된 경우 서비스는 해당 정보를 SR(server receive)로 사용해야하며 SS(server send) 이벤트는 CR, CS와 동일한 span의 일부이다.

서비스가 다운 스트림 서비스를 호출하는 경우 새 span이 이전 span의 자식으로 생성된다. 동일한 traceId, 새로운 spanId로 식별되며 이전 span의 spanId가 부모 ID로 설정된다. 당연히 새 spanId는 64-bit의 임의의 값이어야한다.

❗️ NOTE
서비스가 여러 다운 스트림 호출을 수행하는 경우 이 프로세스가 반복된다. 즉, 각 후속 span은 동일한 traceId와 부모Id를 가지지만 새로운 (다른) spanId를 갖는다.

Communicating trace information

전체 trace가 합쳐지기 위해선 업스트림과 다운 스트림 서비스 간에 전달되어야한다. 필수적인 다섯가지 정보는 다음과 같다.

  • Trace Id
  • Span Id
  • Parent Id
  • Sampled : request에 대한 추적 정보를 기록해야할지 다운스트림 서비스에 알린다.
  • Flags : feature flags를 만들고 전달하는 기능을 제공한다. "debug" request 임을 다운 스트림 서비스에 알리는 방법이다.

포맷은 여기를 참조하라.

Finagle은 HTTP 및 Thrift 요청과 함께 이러한 정보를 전달하는 메커니즘을 제공한다. 추적이 효과적이기 위해선 다른 프로토콜에 정보를 추가해야한다.

계측의 샘플링 여부는 시스템의 가장자리에서 이루어진다.

업스트림 시스템의 샘플링 정책을 다운 스트림 서비스에서도 동일하게 적용해야한다. 들어오는 요청에 "Sampled" 정보가 없는 경우 라이브러리는 이 요청을 샘플링할지 여부를 결정하고 다운 스트림 요청에도 이 정책을 포함시켜야한다. 이것은 샘플링 된 것과 그렇지 않은 것을 이해하는데 있어 계산을 단순화한다. 또한 요청이 완전히 추적되거나 전혀 추적되지 않도록 함으로써 샘플링 정책을 더 쉽게 이해하고 구성할 수 있다.

디버그 플래그는 샘플링 규칙에 관계없이 trace를 강제로 샘플링한다. 디버그 플래그는 Zipkin 서버 측에서 구성되는 storage 계층 샘플링에도 적용된다.

HTTP Tracing
HTTP 헤더는 trace 정보를 전달하는데 사용된다.

  • 헤더의 B3 부분은 Zipkin의 원래 이름인 BigBrotherBird를 따서 명명되었다.

ID는 16 진수 문자열로 인코딩된다.

  • X-B3-TraceId : 128 또는 64 개의 하위 16 진수 인코딩 비트 (필수)
  • X-B3-SpanId : 64 개의 하위 16 진수 인코딩 비트 (필수)
  • X-B3-ParentSpanId : 64 개의 하위 16 진수 인코딩 비트 (root span에는 없음)
  • X-B3-Sampled : Boolean ( "1" 또는 "0", 없을 수 있음)
  • X-B3-Flags : "1"은 디버그를 의미한다. (없을 수 있음).

B3에 대한 자세한 내용은 여기를 참조한다.

Thrift Tracing
Finagle 클라이언트와 서버는 연결 설정 시 thrift message의 헤더에 있는 추가 정보를 처리할 수 있을지 협상한다. 한 번 협상된 trace 데이터는 각 thrift message 앞에 붙게된다. Once negotiated trace data is packed into the front of each thrift message.

Timestamps and duration

Span을 기록하는 것은 시간 정보나 메타데이터가 구조화되고 Zipkin에 보고되는 순간이다. 이 프로세스에서 가장 중요한 부분 중 하나는 시각(timestamps)과 기간(duration)을 적절하게 기록하는 것이다.

Timestamps are microseconds

모든 Zipkin 타임 스탬프는 밀리초가 아닌 eposh 마이크로초 단위이다. 이 값은 가능한 최대한으로 정확한 측정을 사용해야한다. 예를 들어 clock_gettime나 단순히 epoch 밀리초에 1000을 곱할 수 있다. Timestamps 필드는 유효하지 않은 음수인 경우에도 64-bit의 부호있는 정수로 저장된다.

마이크로초 정밀도는 주로 진행중인 작업의 "local spans"를 지원한다. 높은 정밀도를 사용하여 먼저 일어난 일이 무엇인지 파악할 수 있다.

모든 타임스탬프는 결함이 있는데, 호스트간의 clock skew(두 클럭의 판독 값 사이의 순간적인 차이)와 시간을 재설정하는 순간을 포함된다. 이러한 이유로 span은 가능한 기간을 기록해야한다.
All timestamps have faults, including clock skew between hosts and the chance of a time service resetting the clock backwards. For this reason, spans should record their duration when possible.

Span duration is also microseconds
Zipkin이 마이크로초 단위를 사용하는 이유는 다음과 같다.

  1. 타임 스탬프와 동일한 단위를 사용하면 계산이 쉬워진다. 예를 들어 span을 트러블슈팅할 때 동인한 단위의 용어로 식별하는 것이 더 쉽다.
  2. span을 기록할 때 오버 헤드는 가변적이며 마이크로초 이상일 수 있다. 오버 헤드보다 높은 정밀도를 사용하는 것은 더 산만해질 수 있다.

When to set Span.timestamp and duration

Span.timestamp 및 duration은 span을 시작한 host에서만 설정해야한다.

가장 간단한 로직은 다음과 같다. :

unless (logging "sr" in an existing span) {
 set Span.timestamp and duration
}

Zipkin은 공유된 동일한 trace와 spanId를 병합한다. 가장 일반적인 경우는 클라이언트 (cs, cr)과 서버(sr, ss)가 보고한 span을 병합하는 것이다. 예를 들어, 클라이언트 "cs"를 로깅하고 B3 헤더를 통해 전파하는 span이 시작되면 서버는 "sr"을 로깅하며 해당 span을 이어간다.

이 경우 클라이언트가 span을 시작했으므로 Span.timestampSpan.duration을 기록해야하며 그 값은 "cs", "cr"간의 차이와 일치해야한다. 서버가 이 span을 시작한 것이 아니므로 서버는 Span.timestamp와 Span.duration을 설정할 수 없다.

또다른 일반적인 경우는 서버가 웹 브라우저와 같이 계측되지 않는 클라이언트로부터 root span을 시작하는 경우이다. B3 헤더나 유사 헤더에 아무것도 없기 때문에 trace가 시작되어야함을 인지한다. trace를 시작했기때문에 root span에 Span.timestamp 및 Span.duration을 기록해야한다.

❗️ NOTE
span이 불완전한 경우 Span.timestamp를 설정할 수는 있지만, 정확한 수행 정보가 충분하지 않으므로 Span.duration은 설정할 수 없다.

Span.timestamp 및 duration이 설정되지 않는다면?
Span.timestamp 와 Span.duration은 Zipkin이 시작된지 3년 후인 2015년에 추가된 필드이다.
따라서 모든 라이브러리가 이를 기록하는 것은 아니다. 이런 경우 Zipkin은 그러한 정보를 쿼리 시간(수집 시간 X)에 저장하지만 이는 이상적이지 않다.

조회할 데이터가 존재하지 않으면 duration query는 작동하지 않는다. 또한 진행중인 local span은 어노테이션이 필수가 아니기 때문에 타임스탬프가 설정돼있지 않다면 질의 할 수 없다.

duration이 계측에 의해 설정되지 않은 경우, Zipkin은 쿼리 시간에 duration을 파생하려고 시도하며 문제가 있는 timestamp 연산 방법을 사용해야한다. 예를 들어, span 내에서 NTP(Network Time Protocol) 업데이트가 발생하면 Zipkin이 계산하는 기간이 잘못된다.

마지막으로, 많은 사람들이 단일 호스트 span으로 이동하려고 한다.이에 대한 마이그레이션 경로는 이중 호스트 RPC span을 두 개로 분할하는 것이다. 계측이 소유한 span에 대해서만 timestamp를 기록하는 경우, 분할 수집기는 서버에서 시작된 root span과 클라이언트에서 시작된 이중 호스트 span을 구별하는 heuristic을 가진다.

요점은 Span.timestamp와 Span.duration을 기록하지 않도록 선택하면 데이터의 정확성과 기능성이 떨어진단 것이다. 보고하기전에 이를 신뢰가능하도록 기록하는 것은 매우 쉽기때문에 모든 Zipkin 계측기는 이를 수행하거나 수행하기위해 누군가에게 도움을 요청해야한다.

Message Tracing

메세지 추적은 producer와 consumer가 span ID를 공유하지 않기 때문에 RPC 추적과 다르다.

일반적인 RPC 추적에서 클라이언트 및 서버 주석은 동일한 span에 있으나 메세징에서는 지정된 메세지에 대해 여러 consumer가 있을 수 있기 때문에 그렇게 작동하지 않는다. consumer에게 전파된 trace context는 부모 개체이다.

메세징 추적에는 응답 경로가 없다. "ms"와 "mr"이란 두 가지 주석만 사용된다. RPC 추적과 마찬가지로 생산자 producer와 각 consumer가 별도의 span을 사용하므로 Span.timestamp와 Span.duration을 설정하는 것이 좋다.

따라서, producer는 span에 "ms" 어노테이션을 추가하고 이를 zipkin에 보고한다. 그 다음 각 consumer는 "mr" 어노테이션이 추가된 자식 span을 만든다.

다음은 메세지 추적 다이어그램이다.


   Producer Tracer                                    Consumer Tracer
+------------------+                               +------------------+
| +--------------+ |     +-----------------+       | +--------------+ |
| | TraceContext |======>| Message Headers |========>| TraceContext | |
| +--------------+ |     +-----------------+       | +--------------+ |
+--------||--------+                               +--------||--------+
   start ||                                                 ||
         \/                                          finish ||
span(context).annotate("ms")                                \/
             .address("ma", broker)          span(context).annotate("mr")
                                                          .address("ma", broker)

다음은 Brave Tracer를 사용한 프로세스 예이다.

Producer side:

// outbound span에 trace 식별자 추가
tracing.propagation().injector(Message::addHeader)
       .inject(span.context(), message);

producer.send(message);

// producer 측을 시작하고 종료한다.
span.kind(Span.Kind.PRODUCER)
    .remoteEndpoint(broker.endpoint())
    .start().finish();

// The above will report to zipkin trace identifiers, a "ms" annotation with the endpoint of the producer, and a "ma" (Message Address) with the endpoint of the broker
// 위 내용은 zipkin에 trace 식별자, producer의 엔드포인트가 있는 "ms" 주석, broker의 엔드포인트가 있는 "ma"(메시지 주소)를 보고한다.

Consumer side:

// 메시지 헤더로부터 span 구문 분석
TraceContextOrSamplingFlags result =
    tracing.propagation().extractor(Message::getHeader).extract(message);

// 해당 컨텍스트를 가져와 동일한 스팬 ID 재사용
span = tracer.newChild(result.context())

// 메시지가 도착했음을 나타내는 consumer 측을 시작하고 종료한다.
span.kind(Span.Kind.CONSUMER)
    .remoteEndpoint(broker.endpoint())
    .start().finish();

// 위 내용은 zipkin에 trace 식별자, consumer의 엔드포인트가 있는 "mr" 주석, broker의 엔드포인트가 있는 "ma"(메시지 주소)를 보고한다.

많은 consumer는 대량으로 작업하며 동시에 많은 메세지를 받는다. 각 consumer span의 trace context를 해당 메세지 헤더에 삽입하는 것이 도움이 될 수 있다. 이를 통해 프로세서는 trace트리의 올바른 위치에 자식을 만들 수 있다.

다음은 kafka의 poll api로 이를 수행하는 예이다.

public ConsumerRecords<K, V> poll(long timeout) {
  ConsumerRecords<K, V> records = delegate.poll(timeout);
  for (ConsumerRecord<K, V> record : records) {
    handleConsumed(record);
  }
  return records;
}

void handleConsumed(ConsumerRecord record) {
  // notifies zipkin the record arrived
  Span span = startAndFinishConsumerSpan(record);
  // allows a processor to see the parent ID (the consumer trace context)
  injector.inject(span.context(), record.headers());
}
profile
🌱 😈💻 🌱

0개의 댓글