Skip to content

대규모 분산 시스템 추적 플랫폼, Pinpoint

HyoSang edited this page Mar 12, 2018 · 1 revision

대규모 분산 시스템 추적 플랫폼, Pinpoint

오늘날 인터넷 서비스는 다양한 컴포넌트의 조합으로 구성된다. 3계층을 넘어서 n계층(multitier) 아키텍처로 변경되고 있다. SOA(service oriented architecture)나 마이크로서비스 형식의 아키텍처는 이제 현실이 되었다.

n계층 아키텍처로 변화함에 따라 시스템의 복잡도도 증가했다. n계층 아키텍처에서 문제가 발생하면 많은 컴포넌트와 서버의 상태를 모두 살펴봐야 한다. 또한 개별 컴포넌트를 분석해 전체 그림을 파악하는 데는 한계가 있다. 즉, 가시성이 부족하다. 복잡도가 높을수록 문제의 원인을 찾는 데 시간이 오래 걸리고 문제의 원인을 발견하지 못하는 경우도 많아진다.

결국 시스템 복잡도가 높아지며 발생하는 문제를 해결하기 위해 n계층 아키텍처를 효과적으로 추적할 수 있는 새로운 플랫폼을 개발하기로 했다.

이 글에서는 Pinpoint의 n계층 아키텍처 추적에 사용된 트랜잭션 추적 기술과 bytecode instrumentation을 살펴본다. 그리고 애플리케이션의 바이트코드를 수정하고 성능 데이터를 기록하는 Pinpoint Agent의 성능을 최적화하기 위해 사용한 방법도 설명하겠다.

n계층 아키텍처 추적에 사용된 트랜잭션 추적 기술

Google Dapper의 분산 트랜잭션 추적 방법

분산 트랜잭션 추적의 핵심은 다음 그림처럼 Node 1에서 Node 2로 메시지를 전송했을 때, 분산된 Node 1과 Node 2가 처리한 메시지의 관계를 찾아내는 것이다.

메시지의 관계를 찾을 때 어려운 점은 Node 1이 보낸 N개의 메시지와 Node 2에 도작한 N'개의 메시지를 보고, 메시지 간의 관계를 엮을 수 있는 방법이 없다는 것이다. 즉 Node 1에서 X번째 메시지를 보냈을 때, Node 2가 받은 N'개의 메시지 중에서 X번째 메시지를 선택할 수 없다.

하지만 Google Dapper는 이 문제를 간단한 방법으로 해결했다. 메시지 전송 시 애플리케이션 수준에서 메시지를 엮을 수 있는 태그를 메시지에 추가한 것이다. HTTP를 예로 들면, HTTP 요청 전송 시 HTTP 헤더에 메시지 태그 정보를 넣고 이 정보를 메시지 간의 연결 고리로 활용해 메시지를 추적한다.

Pinpoint는 Google Dapper 스타일의 추적 방법을 변형해 호출 추적에 사용한다. 원격 호출 시 분산 트랜잭션의 추적을 위해 애플리케이션 수준에서 태그 데이터를 호출 헤더에 추가한다. 태그 데이터는 키의 집합으로 구성되며, 이 집합을 TraceId라고 정의한다.

Pinpoint의 자료 구조

  • Span: RPC(remote procedure call) 추적을 위한 기본 단위다. RPC가 도착했을 때 처리한 작업을 나타내며 추적에 필요한 데이터가 들어 있다. 코드 수준의 가시성을 확보하기 위해, Span의 자식으로 SpanEvent라는 자료구조를 가지고 있다. Span은 TraceId를 가지고 있다.
  • Trace: Span의 집합으로, 연관된 RPC(Span)의 집합으로 구성된다. Span의 집합은 TransactionId가 같다. Trace는 SpanId와 ParentSpanId를 통해 트리 구조로 정렬된다.
  • TraceId: TransactionId와 SpanId, ParentId로 이루어진 키의 집합이다. TransactionId는 메시지의 아이디이며, SpanId와 ParentId는 RPC의 부모 자식 관계를 나타낸다. - TransactionId(TxId): 분산된 노드를 거쳐 다니는 메시지의 아이디로, 전체 서버군에서 중복되지 않아야 한다. - SpanId: RPC 메시지를 받았을 때 처리되는 작업(job)의 아이디를 정의한다. RPC가 노드에 도착했을 때 생성한다. - ParentSpanId(pSpanId): 호출한 부모의 SpanId를 나타낸다.

TraceId의 작동 방법

다음과 같이 4개의 노드와 3번의 RPC가 존재하는 경우를 예로 들어 TraceId가 어떻게 작동하는지 설명하겠다.

그림 2에서 TransactionId(TxId)는 3개의 RPC가 한 개의 연관된 트랜잭션이라는 것을 표현한다. 하지만 TransactionId만으로는 각 RPC 간의 관계를 정렬할 수 없다. RPC 간의 정렬을 위해 SpanId와 ParentSpanId(pSpanId)가 필요하다. 노드를 Tomcat으로 비유하면, SpanId는 HTTP 요청이 Tomcat에 도착해 요청 처리를 수행하는 스레드다. ParentSpanId는 RPC 호출 시 자신을 호출한 부모 노드의 SpanId다.

Pinpoint는 TransactionId로 연관된 N개의 Span를 찾아낼 수 있고, SpanId와 ParentSpanId로 N개의 Span을 트리로 정렬할 수 있다.

TransactionId는 AgentId, JVM(Java virtual machine) 시작 시간, SequenceNumber로 구성된다.

  • AgentId: JVM 실행 시 사용자가 임의로 정하는 아이디다. AgentId는 Pinpoint가 설치된 전체 서버군에서 중복되는 것이 없어야 한다. AgentId의 유일성을 보장하는 쉬운 방법은 호스트 이름($HOSTNAME)을 사용하는 것이다. 보통 호스트 이름은 중복되지 않기 때문이다. 서버 안에 JVM을 여러 개 기동해야 한다면 호스트 이름에 접미어(postfix)를 추가해 아이디 중복을 피할 수 있다.
  • JVM 시작 시간: 0부터 시작하는 SequenceNumber의 유일성을 보장하기 위해 JVM의 시작 시간이 필요하다. 이 값은 사용자의 실수로 동일한 AgentId가 설정됐을 경우 아이디의 충돌 확률 줄이는 역할도 한다.
  • SequenceNumber: Pinpoint Agent가 내부적으로 발급하는 아이디로, 0부터 순차적으로 증가하는 값이다. 개별 메시지마다 발급한다.

코드 수정이 필요 없는 bytecode instrumentation

분산 트랜잭션 추적이 좋은 기능이라고 해도 이를 위해 코드를 수정하는 것은 부담스러운 일이다.

bytecode instrumentation은 수동 방식(라이브러리)과 자동 방식 중에서 자동 방식에 해당한다.

  • 수동 방식: Pinpoint가 API를 제공하고 개발자는 Pinpoint의 API를 사용해 중요 포인트에 데이터를 기록하는 코드를 개발한다.
  • 자동 방식: Pinpoint가 라이브러리의 어떤 API를 가로챌지 결정해 코드를 개발한다. 이를 통해 개발자가 개입하지 않아도 자동으로 기능이 적용되게 한다.
장점 단점
수동 방식 Pinpoint 개발팀의 자원 소모가 적다. API가 단순해질 수 있고, 그에 따라 버그 발생 가능성이 낮아진다. 사용자가 코드를 수정해야 한다. 추적 수준이 낮다.
자동 방식 사용자가 코드를 수정하지 않아도 된다.바이트코드의 정보가 많기 때문에 정밀한 데이터를 수집할 수 있다 Pinpoint 개발팀의 자원 소모가 크다(필자의 판단으로 수동의 10배 이상). 추적할 라이브러리 코드를 순간적으로 파악해 추적 지점을 판단할 수 있는 수준 높은 개발자가 필요하다. 난이도가 높은 개발 방법인 bytecode instrumentation을 사용하므로 버그 발생 가능성이 높다.

개발 자원 측면에서 bytecode instrumentation을 사용하면 개발 자원이 많이 필요한 반면 서비스 개발자의 자원은 적게 필요하다. 따라서 적용하는 서비스의 수가 많아질수록 유리해지는 방법이다.

bytecode instrumentation의 장점

1. API를 노출하지 않음

API를 노출해 개발자가 API를 사용하면 API 제공자가 API를 쉽게 변경할 수 없다는 제약이 생긴다.

API를 쉽게 변경할 수 없다는 제약이 있다면 API 제공자는 기능을 발전시키기 어렵다. 이 문제를 해결할 모법 답안은 'API의 확장성을 고려해 디자인한다'지만 쉬운 일이 아님은 모두가 알고 있을 것이다. API 디자인이 잘못되지 않는다는 것은 미래를 정확하게 예측할 수 있다는 것인데 그것은 거의 불가능한 일이다.

bytecode instrumentation을 사용하면 추적 API를 사용자에게 노출하지 않아도 되므로 API 의존성 문제를 겁내지 않고 디자인을 지속적으로 개선할 수 있다.

2. 손쉬운 적용과 해제

bytecode instrumentation은 문제가 생겼을 때 애플리케이션에 영향을 준다는 단점이 있다. 하지만 코드를 변경할 필요가 없으므로 쉽게 적용하고 해제할 수 있다.

JVM 구동 시 JVM 시작 스크립트에 다음과 같은 Pinpoint Agent 설정 3개를 추가하면 쉽게 Pinpoint를 적용할 수 있다.

  • javaagent:$AGENT_PATH/pinpoint-bootstrap-$VERSION.jar
  • Dpinpoint.agentId=
  • Dpinpoint.applicationName=<동일 서비스임을 나타내는 이름(AgentId의 집합)>

bytecode instrumentation의 작동 방법

Pinpoint는 클래스 로드 시점에 애플리케이션 코드를 가로채 성능 정보와 분산 트랜잭션 추적에 필요한 코드를 주입한다(inject).

Pinpoint는 API 인터셉트 부분과 성능 데이터 기록 부분을 분리했다. 추적 대상 메서드에 인터셉터를 주입해 앞뒤로 before() 메서드와 after() 메서드를 호출하게 하고 before() 메서드와 after() 메서드에 성능 데이터를 기록하는 부분을 구현했다.

Pinpoint Agent의 성능 최적화

가변 길이 인코딩과 포맷에 최적화된 데이터 기록

보통 long형 정수를 고정 길이로 인코딩하면 데이터의 크기가 8바이트다. 하지만 가변 길이로 인코딩(variable-length encoding)하면 숫자의 크기에 따라 1~10바이트가 된다.

그림 4에서 호출된 3개 메서드의 호출 시간을 알려면 6개 지점의 시간을 측정해야 한다. 이때 고정 길이 인코딩을 사용하면 48바이트(6 × 8)를 소모한다.

반면 Pinpoint Agent는 가변 길이 인코딩을 사용하고 해당 포맷에 맞게 데이터를 기록한다. 그리고 루트 메서드 시작 시간을 기준으로 삼아 다른 지점의 시간은 기준과의 차이(벡터 값)로 구한다. 벡터 값은 작은 숫자이므로 바이트를 적게 소모한다. 그림 4에서는 13바이트를 소모했다.

반복되는 API 정보와 SQL, 문자열을 상수 테이블로 치환

메서드 A라는 정보를 Pinpoint Collector로 매번 보내면 데이터가 크므로 Pinpoint Agent는 메서드를 아이디로 치환해 HBase에 아이디와 메서드 정보를 상수 테이블로 저장하고 메서드 아이디로 추적 데이터를 생성한다. 그리고 사용자가 웹에서 추적 데이터를 조회하면 상수 테이블에서 해당 아이디의 메서드 정보를 찾아 재조합한다. SQL이나 자주 사용하는 문자열 데이터도 같은 방법으로 데이터 크기를 줄인다.

대량의 요청은 샘플링으로 처리

요청량이 적은 환경(개발 환경)에서는 모든 데이터를 수집하고, 요청량이 많은 환경(서비스 환경)에서는 적은 양(1~5%)의 데이터만 수집해도 전체 애플리케이션의 상태를 확인하는 데 무리가 없다. 샘플링을 통해 애플리케이션의 부하를 최소화하고 네트워크와 서버 인프라의 추가 투자 비용을 절감할 수 있다.

비동기 데이터 전송으로 애플리케이션 스레드 중단 최소화

데이터 인코딩이나 원격 메시지 전송은 다른 스레드를 통해 비동기로 동작하므로 애플리케이션 스레드를 중단(block)시키지 않는다.

UDP로 데이터 전송

Google Dapper와 달리 Pinpoint는 데이터를 빨리 확인하기 위해 데이터를 네트워크로 전송한다. 네트워크는 서비스와 같이 사용하는 공용 인프라로, 네트워크 폭주 시 문제가 발생할 수 있다. 이런 상황에서 Pinpoint Agent는 서비스에 네트워크 우선권을 주기 위해서 UDP 프로토콜을 사용한다.

애플리케이션 적용 예

  1. 요청이 TomcatA에 도착하면 Pinpoint Agent는 TraceId를 발급한다.
    • TX_ID: TomcatA^TIME^1
    • SpanId: 10
    • ParentSpanId: -1(Root)
  2. Spring의 Controller 정보를 기록한다.
  3. HttpClient.execute() 메서드의 호출을 가로채 HttpGet에 TraceId를 설정한다.
    1. 자식 TraceId를 생성한다. - TX_ID: TomcatA^TIME^1 -> TomcatA^TIME^1 - SPAN_ID: 10 -> 20 - PARENT_SPAN_ID: -1 -> 10 (부모의 SpanId)
    2. 자식 TraceId를 HTTP 헤더에 설정한다. - HttpGet.setHeader(PINPOINT_TX_ID, "TomcatA^TIME^1") - HttpGet.setHeader(PINPOINT_SPAN_ID, "20") - HttpGet.setHeader(PINPOINT_PARENT_SPAN_ID, "10")
  4. 태그된 요청이 TomcatB로 전송된다.
    1. TomcatB는 전송된 요청에서 헤더를 확인한다. - HttpServletRequest.getHeader(PINPOINT_TX_ID)
    2. 헤더에서 TraceId를 인식해 자식 노드로 동작한다. - TX_ID: TomcatA^TIME^1 - SPAN_ID: 20 - PARENT_SPAN_ID: 10
  5. Spring Controller 정보를 기록하고 요청의 메시지 처리를 종료한다.
  6. TomcatB의 요청 처리가 끝나면 Pinpoint Agent는 추적 데이터를 Pinpoint Collector에 보내 HBase에 저장한다.
  7. TomcatB의 HTTP 호출이 종료된 후 TomcatA의 요청 처리도 종료된다. Pinpoint Agent는 추적 데이터를 Pinpoint Collector로 전송해 HBase에 저장한다.
  8. UI는 추적 데이터를 HBase에서 읽고 트리를 정렬해 콜 스택을 생성한다.
Clone this wiki locally