Elastic APM Java 에이전트에 플러그인을 기여할 때의 팁 | Elastic Blog
엔지니어링

Elastic APM Java 에이전트에 플러그인 기여를 위한 쿡북

원칙적으로 APM 에이전트는 존재하는 것으로 알려진 모든 프레임워크와 라이브러리를 자동으로 계측하고 추적할 수 있어야 합니다. 그러나 실제로 APM 에이전트가 지원하는 항목은 용량과 우선순위의 조합에 따라 결정됩니다. 당사의 지원되는 기술 및 프레임워크 목록은 소중한 Elastic 사용자의 의견을 토대로 한 우선순위에 따라 계속해서 확장되고 있지만, 혹시 Elastic APM Java 에이전트를 사용 중인데 즉시 지원되지 않는 항목 중에 필요한 항목이 있는 경우 이를 추적할 수 있는 몇 가지 방법이 있습니다.

예를 들어 퍼블릭 API를 사용하여 자체 코드를 추적하고 탁월한 사용자 지정 메서드 추적 구성을 사용하여 타사 라이브러리의 특정 메서드에 대한 기본 모니터링을 수행할 수 있습니다. 그러나 타사 코드의 특정 데이터에 대한 가시성을 확장하려면 추가 작업이 필요할 수 있습니다. 다행히 Elastic 에이전트는 오픈 소스이므로 우리가 할 수 있는 것은 여러분도 하실 수 있습니다. 추가 작업을 진행하시는 경우 이를 커뮤니티와 공유하는 것도 좋은 방법입니다. 그러면 다양한 피드백을 받을 수 있고 작성한 코드가 추가 환경에서 실행된다는 큰 이점이 있습니다.

Elastic에서는 사용자가 우리에게 기대하는 것처럼 우리가 이행해야 하는 몇 가지 표준에 부합하는 한, 우리의 역량을 확장시키는 모든 기여를 기쁜 마음으로 환영합니다. 예를 들어 OkHttp 클라이언트 호출을 지원하는 이 PR 또는 JAX-RS 지원에 대한 이 익스텐션을 확인해 보십시오. 따라서 Elastic 코드 베이스에 기여하려는 경우, 키보드를 두드리며 코딩을 시작하기 전에 이 플러그인 구현 안내서에서 설명하는 테스트 사례와 함께 제시된 몇 가지 사항을 염두에 두어야 합니다.

테스트 사례: Elasticsearch Java REST 클라이언트 계측

Elastic에서는 에이전트를 릴리즈하기 전에 자체 데이터 저장소 클라이언트를 지원하고자 했으며, Elasticsearch Java REST 클라이언트 사용자가 다음 사항을 알 수 있게 하고자 했습니다.

  1. Elasticsearch에 대한 쿼리 발생 여부
  2. 해당 쿼리에 걸린 시간
  3. 어떤 Elasticsearch 노드가 쿼리 요청에 응답했는지
  4. 상태 코드 등 쿼리 결과에 대한 일부 정보
  5. 오류가 발생했을 때
  6. _search 작업에 대한 쿼리 자체

또한, 첫 번째 단계로 동기화 쿼리만 지원하고 비동기화 쿼리는 적절한 인프라가 마련될 때까지 연기하기로 했습니다.

관련 코드를 추출하여 gist에 업로드해 두었으며 이 게시물 전반에서 이를 참조했습니다. GitHub 리포지토리에 있는 것은 실제 코드는 아니지만 모두 작동하며 관련이 있는 코드입니다.

Java 에이전트 관련 측면

Java 에이전트 코드를 작성할 때는 특별히 고려해야 할 사항이 있습니다. 테스트 사례를 검토하기 전에 이를 간략하게 살펴보겠습니다.

바이트코드 계측 도구

걱정하지 마세요. 바이트코드로 무언가를 작성할 필요는 없습니다. Elastic에서는 이를 위해 매우 훌륭한 Byte Buddy 라이브러리(즉, ASM 사용)를 사용합니다. 계측된 메서드의 시작에 삽입할 내용을 작성하는 데 사용하는 주석을 예로 들 수 있습니다. 여러분이 작성하는 코드 중 일부는 실제로 작성한 곳에서 실행되는 것이 아니라 다른 사람의 코드에 컴파일된 바이트코드로 삽입된다는 것을 기억하시면 됩니다(개방성의 큰 이점). 그리고 정확히 어떤 코드가 삽입되는지 알 수 있습니다.

Byte Buddy의 바이트코드 삽입 지시문 예제

클래스 가시성

이것이 가장 규정하기 힘들고 가장 많은 함정이 숨어있는 요소일 수 있습니다. 코드의 각 부분이 로드되는 위치와 런타임에 사용할 수 있다고 가정할 수 있는 부분이 무엇인지 잘 알아야 합니다. 플러그인을 추가할 때 코드는 계측된 라이브러리/애플리케이션의 컨텍스트에서 하나와 핵심 에이전트 코드의 컨텍스트에서 하나 등 최소한 두 개의 별도 위치에서 로드됩니다. 예를 들어 Elasticsearch 클라이언트와 함께 제공되는 Apache HTTP 클라이언트 클래스인 HttpEntity에 대한 종속성이 있습니다. 이 코드는 클라이언트의 클래스 중 하나에 삽입되므로 이 종속성이 유효하다는 것을 알 수 있습니다. 반면에 핵심 에이전트 클래스인 IOUtils를 사용하는 경우 핵심 Java 및 핵심 에이전트 이외의 그 어떤 종속성도 가정할 수 없습니다. Java 클래스 로딩 개념에 익숙하지 않은 경우, 예를 들어 이 개요를 읽고 개괄적으로라도 그 개념을 파악하는 것이 유용할 수 있습니다.

오버헤드

누구나 성능을 항상 고려 사항에 포함합니다. 비효율적인 코드를 작성하려는 사람은 아무도 없습니다. 그러나 에이전트 코드를 작성할 때는 일반적으로 코드 작성 시 오버헤드를 감수하는 성능 절충점을 결정할 권한이 우리에게 없습니다. 우리는 모든 측면에서 원활하며 효율적이어야 합니다. 우리는 다른 사람의 파티에 초대받은 손님이며 작업을 원활하게 수행하는 것이 예의입니다.

에이전트 성능 오버헤드에 대한 심층적인 개요와 튜닝 방법은 흥미로운 이 블로그 게시물을 참조하시기 바랍니다.

동시성

일반적으로 각 이벤트의 첫 번째 추적 작업은 풀의 많은 스레드 중 하나인 요청 처리 스레드에서 실행됩니다. 우리는 이 스레드에서 가능한 한 작업을 적게 수행하고 빠르게 수행하여 더 중요한 비즈니스를 처리할 수 있게 남겨두어야 합니다. 이러한 작업의 부산물은 동시성 문제에 노출되는 공유 컬렉션에서 처리됩니다. 예를 들어 바로 이 입력에서 생성한 Span 객체는 요청 처리 스레드에서 이 코드 전반에 걸쳐 여러 번 업데이트되지만, 나중에 다른 스레드에서 직렬화하고 APN 서버로 전송하는 데 사용됩니다. 또한, 동기화 작업을 추적하는지 잠재적 비동기화 작업을 추적하는지 알아야 합니다. 어떤 스레드에서 시작된 추적이 다른 스레드에서 계속될 수 있다면 이 부분도 고려해야 합니다.

다시 테스트 사례

다음은 편의상 세 단계로 나누어 Elasticsearch REST 클라이언트 플러그인을 구현하는 데 필요한 사항을 설명합니다.

경고 한 마디: 지금부터는 매우 기술적인 부분을 다룹니다.

1단계: 계측할 항목 선택

이는 프로세스에서 가장 중요한 단계입니다. 우리가 약간의 조사를 거쳐 제대로 수행한다면 적절한 메서드를 찾고 손쉽게 진행할 수 있습니다. 고려할 사항:

  • 관련성: 다음과 같은 메서드를 계측해야 합니다.
    • 정확하게 캡처하려는 사항을 캡처할 수 있어야 합니다. 예를 들어 종료 시간에서 시작 시간을 빼는 메서드는 우리가 생성하려는 스팬 기간을 정확하게 반영하도록 해야 합니다.
    • 거짓 긍정이 없습니다. 메서드가 호출되면 우리는 항상 알고 싶어 합니다.
    • 거짓 부정이 없습니다. 스팬 관련 작업이 실행될 때 메서드가 항상 호출됩니다
    • 입력하거나 종료할 때 모든 관련 정보를 사용할 수 있도록 합니다.
  • 순방향 호환성: 자주 변경되지 않는 중앙 API를 목표로 합니다. 추적 라이브러리의 마이너 버전이 릴리즈될 때마다 코드를 업데이트하고 싶지는 않습니다.
  • 역방향 호환성: 이 계측 도구는 어느 하위 버전까지 지원됩니까?

제가 클라이언트 코드에 대해 전혀 모르고(Elastic의 코드임에도 불구하고) 다운로드하여 최신 버전을 확인했더니 당시 버전이 6.4.1이었습니다. Elasticsearch Java REST 클라이언트는 상위 수준과 하위 수준 API를 모두 제공하며 상위 수준 API는 하위 수준 API에 따라 달라지며 모든 쿼리는 결국 하위 수준 API를 통과하게 됩니다. 따라서 두 가지를 모두 지원하려면 당연히 하위 수준 클라이언트만 살펴보면 될 것입니다.

코드를 파고들다가 여기 GitHub에서 Response performRequest(Request request)라는 서명이 있는 메서드를 발견했습니다. 동일한 메서드에 대해 4개의 추가 오버라이드가 있으며, 모두 이 메서드를 호출하고 모두 사용되지 않음으로 표시됩니다. 또한, 이 메서드는 performRequestAsyncNoCatch를 호출합니다. 후자를 호출하는 유일한 다른 메서드는 void performRequestAsync(Request request, ResponseListener responseListener)라는 서명이 있는 메서드입니다. 조금 더 조사를 해보니 비동기화 경로가 동기화 경로와 정확히 동일합니다. 즉, 실제 요청을 수행하기 위해 performRequestAsyncNoCatch를 호출하는 하나의 사용되는 오버라이드를 호출하는 4개의 사용되지 않는 추가 오버라이드가 있습니다. 따라서 관련성의 경우 performRequest 메서드는 입력/종료 시 사용 가능한 요청 및 응답 정보로 모든 동기화 요청만 정확하게 캡처하므로 100% 점수를 받았습니다. 훌륭합니다! Byte Buddy에 이야기한 우리가 원하는 이 메서드의 계측 방식은 관련 matcher 제공 메서드를 오버라이드하는 방식입니다.

계측할 클래스 및 메서드를 결정하는 방식

미래를 생각하면 이 새로운 중앙 API는 안정성을 위한 좋은 선택인 것 같았지만, 뒤돌아보면 그렇게 좋은 선택은 아니었습니다. 버전 6.4.0 및 이전 버전에는 이 API가 없습니다...

중앙 API는 계측 도구에 아주 적합한 후보였기 때문에 저는 이것을 사용하고 Elasticsearch REST 클라이언트에 대한 지속적인 지원을 얻고 이전 버전을 위한 다른 계측 도구를 추가하기로 결정했습니다. 후보를 찾기 위해 이와 비슷한 프로세스를 진행했고 5.0.2에서 6.4.0까지의 버전을 위한 솔루션 하나와 6.4.1 이상의 버전을 위한 솔루션 하나로 두 개의 솔루션을 선택했습니다.

2단계: 코드 설계

우리는 Maven을 사용하며, 우리가 새로운 기술을 지원하기 위해 도입하는 각각의 새로운 계측 도구는 플러그인이라고 부르는 모듈입니다. 제 경우, 이전 및 새로운 Elasticsearch REST 클라이언트를 테스트하길 원했고 각각의 계측 도구가 서로 조금씩 다르기 때문에 각 클라이언트에 자체 모듈/플러그인을 사용하는 것이 타당했습니다. 두 가지 모두 동일한 기술을 지원하므로 공통 상위 모듈 아래에 중첩하여 다음과 같은 구조를 사용하게 되었습니다.

실제 플러그인 코드만 에이전트에 패키징되는 것이 중요하므로 pom.xml에서 라이브러리 종속성이 provided로 범위가 지정되고 테스트 종속성은 test로 범위가 지정되었는지 확인하십시오. 타사 코드를 추가하는 경우 이는 공유되어야 합니다. 즉, 루트 Elastic APM Java 에이전트 패키지 이름을 사용하도록 다시 패키징해야 합니다.

실제 코드의 경우 플러그인을 추가하기 위한 최소 요구 사항은 다음과 같습니다.

계측 도구 클래스

추상 ElasticApmInstrumentation 클래스의 구현. 계측 도구에 적합한 클래스와 메서드를 식별하도록 지원하는 것이 그 역할입니다. 유형 및 메서드 매칭으로 애플리케이션 구동 시간이 상당히 연장될 수 있으므로, 예를 들어 이름에 특정 문자열이 포함되어 있지 않은 클래스 또는 찾고 있는 유형에 대한 가시성이 전혀 없는 클래스 로더가 로드한 클래스는 제외하는 등 계측 도구 클래스는 매칭 프로세스를 향상하는 몇 가지 필터를 제공합니다. 또한, 구성을 통해 계측 도구를 켜고 끌 수 있는 몇 가지 메타 정보를 제공합니다.

ElasticApmInstrumentation은 서비스로 사용된다는 점에 유의하십시오. 즉, 각 구현을 제공자 구성 파일에 나열해야 합니다.

서비스 제공자 구성 파일

ElasticApmInstrumentation 구현은 런타임에 리소스 디렉터리 META-INF/services에 있는 제공자 구성 파일을 통해 식별되는 서비스 제공자입니다. 제공자 구성 파일의 이름은 서비스의 정규화된 이름이며 한 줄에 하나씩 서비스 제공자의 정규화된 이름 목록이 들어 있습니다.

Advice 클래스

추적된 메서드에 삽입될 실제 코드를 제공하는 클래스입니다. 일반적인 인터페이스를 구현하지는 않지만, 보통은 Byte Buddy의 @Advice.OnMethodEnter 및/또는 @Advice.OnMethodExit 작업을 사용합니다. 이렇게 하면 Byte Buddy에 메서드의 시작 부분과 종료하기 직전에 어떤 코드를 삽입할지 지시할 수 있습니다(조용히 또는 Throwable을 throw). 풍부한 Byte Buddy API를 사용하면 다음과 같이 여러 가지 멋진 작업을 수행할 수 있습니다.

최종적으로 제 Elasticsearch REST 클라이언트 모듈 구조는 다음과 같습니다.

3단계: 구현

위에서 언급했듯이 에이전트 코드를 작성할 때는 몇 가지 고려해야 할 사항이 있습니다. 이러한 개념이 이 플러그인에 어떻게 구현되었는지 살펴보겠습니다.

스팬 생성 및 유지 관리

Elastic APM은 스팬을 사용하여 HTTP 요청 처리, DB 쿼리 수행, 원격 호출 등 특별히 관심 있는 항목의 각 이벤트를 반영합니다. 에이전트가 기록한 각 스팬 트리의 루트 스팬을 트랜잭션이라고 합니다(자세한 내용은 데이터 모델 설명서 참조). 이 사례의 경우, 서비스에 기록된 루트 이벤트가 아니므로 스팬을 사용하여 Elasticsearch 쿼리를 설명합니다. 이 사례에서처럼 플러그인은 일반적으로 스팬을 생성하고, 활성화하고, 데이터를 추가하고, 결국에는 비활성화한 후, 종료합니다. 활성화와 비활성화는 코드 내 어디에서나 현재 활성 스팬을 얻을 수 있는 스레드 컨텍스트 상태를 유지하는 작업입니다(스팬을 생성할 때와 같이). 스팬은 종료되어야 하며 활성화된 스팬은 비활성화되어야 하므로 이 점에서는 try/finally를 사용하는 것이 가장 좋습니다. 그 외에도 오류가 발생하면 이 또한 보고해야 합니다.

사용자 코드를 손상하지 마시고(부작용을 피하십시오)

매우 ‘방어적인’코드를 작성하는 것 외에도 우리는 항상 코드가 예외를 throw할 수 있다고 가정합니다. 따라서 advice에 suppress = Throwable.class를 사용합니다. 이렇게 하면 Byte Buddy에 advice 코드 실행 중에 throw된 모든 Throwable 유형에 Exception 핸들러를 추가하라고 지시하여 삽입된 코드가 실패하더라도 사용자 코드가 계속 실행되도록 할 수 있습니다.

또한, 계측된 코드 상태를 변경하고 결과적으로 그 동작에 영향을 미칠 수 있는 advice 코드로 인해 부작용이 발생하지 않도록 해야 합니다. 제 경우, 이것은 Elasticsearch 쿼리의 요청 본문을 읽는 것과 관련이 있습니다. getContent API를 통해 요청 콘텐츠 스트림을 가져와서 본문을 읽습니다. 이 API의 일부 구현은 각 호출에 대해 새로운 InputStream 인스턴스를 반환하고 다른 구현은 요청당 여러 호출에 대해 동일한 인스턴스를 반환합니다. 우리는 어떤 구현이 런타임에 사용되는지만 알 수 있을 뿐이므로, 본문을 읽는 것이 클라이언트가 이를 읽는 것을 차단하지 않도록 해야 합니다. 다행히도 정확하게 이를 알려주는 isRepeatable API가 있습니다. 우리가 이를 보장하지 못하면 클라이언트 기능이 손상될 수 있습니다.

클래스 가시성

기본적으로 Instrumentation 클래스는 Advice 클래스이기도 합니다. 그러나 각각의 역할로 인해 둘 사이에는 중요한 차이점이 하나 있습니다. 계측하려는 해당 라이브러리가 실제로 사용 가능한지 또는 전혀 사용되지 않는지와 관계없이 Instrumentation 메서드는 항상 호출됩니다. 반면에 Advice 코드는 특정 라이브러리의 관련 클래스가 탐지된 경우에만 사용됩니다. 제 Advice 코드에는 요청에 사용되는 URL, 요청 본문, 응답 코드 등의 정보를 얻기 위해 Elasticsearch REST 클라이언트 코드에 대한 종속성이 있습니다. 따라서 별도 클래스에서 Advice 코드를 컴파일하고 필요할 때 Instrumentation 클래스에서만 참조하는 것이 안전합니다. Advice 코드는 계측된 라이브러리에 대한 종속성을 가지는 경우가 더 많으므로 일반적으로 이 방법을 사용하는 것이 좋습니다.

성능 오버헤드 고려 사항

우리가 하려는 것 중 하나는 _search 쿼리를 가져오는 것, 즉 InputStream 형식으로 액세스할 수 있는 HTTP 요청 본문을 읽는 것입니다. 본문 콘텐츠를 어딘가에 저장해야 한다는 사실에 대해 우리가 할 수 있는 일은 많지 않습니다. 따라서 메모리 오버헤드는 최소한 추적된 각각의 요청에 대해 읽을 수 있는 본문의 길이가 됩니다. 그러나 가비지 수집으로 인해 CPU 및 일시 중지로 변환되는 메모리 할당과 관련해서는 할 수 있는 일이 많습니다. 따라서 ByteBuffer를 다시 사용하여 스트림에서 읽은 바이트를 복사하고, CharBuffer를 사용하여 직렬화되고 APM 서버 및 심지어 CharsetDecoder로 전송될 때까지 쿼리 콘텐츠를 저장합니다. 이렇게 하면 요청별로 메모리를 할당하고 할당을 해제할 필요가 없습니다. 따라서 좀 더 복잡한 코드(IOUtils 클래스의 코드)로 인한 오버헤드가 줄어듭니다.

최종 결과 

테스트 사례에 설명되지 않은 일반 팁

중첩된 호출 주의

경우에 따라 API 메서드를 계측할 때 하나의 계측된 메서드가 다른 계측된 메서드를 호출하는 시나리오가 발생할 수 있습니다. 수퍼 메서드를 호출하는 오버라이드 메서드 또는 다른 API를 래핑하는 API 구현을 예로 들 수 있습니다. 동일한 작업에 대해 여러 스팬이 보고되기를 원하지 않기 때문에 이러한 시나리오를 인지하고 있는 것이 중요합니다. 이러한 시나리오가 언제 적용되고 적용되지 않는지에 대한 규칙은 없습니다. 다양한 시나리오/설정에서 다양한 동작이 이루어지게 될 것이므로 이 경우 팁은 이를 인지하고 코딩을 해야 한다는 것입니다.

자가 모니터링 주의

추적 코드가 그 역시 추적될 동작을 호출하지 않도록 하십시오. 그나마 나은 시나리오에서는 추적 프로세스 자체의 결과인 추적된 작업을 보고하게 됩니다. 최악의 경우에는 스택 오버플로가 발생할 수 있습니다. JDBC 추적을 예로 들 수 있습니다. DB 정보를 가져오려고 할 때 java.sql.Connection#getMetaData API를 사용하면 DB 쿼리가 발생하고 추적되어 java.sql.Connection#getMetaData가 다시 호출됩니다.

비동기식 작업 주의

비동기식 실행은 스팬/트랜잭션이 하나의 스레드에서 생성된 후 다른 스레드에서 활성화될 수 있음을 의미합니다. 각 스팬/트랜잭션은 정확하게 한 번 종료되어야 하며, 항상 활성화된 스레드에서 비활성화되어야 합니다. 따라서 이 부분을 반드시 인지하고 있어야 합니다.

요약

오픈 소스 프로젝트 작업의 주요 이점 중 하나는 커뮤니티와의 긴밀한 관계입니다. Elastic 코드 베이스에 대한 피드백, 제안 및 기여 받기를 매우 기쁘게 생각합니다. 주저하지 마시고 코드를 제공해 주시고, 시작하기 전에 APM 포럼 또는 GitHub 리포지토리를 통해 중복 작업을 방지하기 위한 접근 방식을 논의해 주십시오.