Traffic-Hunter

APM (Application Performance Management) 를 제작하면서 기술적인 내용과 2달 간의 노력을 이야기 해보겠다.
Github: Traffic-Hunter Github

Overview

프로젝트 소개

  • APM (Application Performance Management)은 Application의 Observerblility (Metric, Log, Trace) 를 수집하여 해당 Application의 성능을 측정하고 모니터링 하는 제품이다.

  • Traffic-Hunter는 토이 프로젝트의 간단한 성능 모니터링과 최신 프로젝트를 진행하는 소규모 스타트업의 성능 측정 모니터링을 할 수 있게 도와주는 오픈 소스로 개발이 되었다.

APM의 제작 계기

  • 여러 APM 제품들이 존재하는데 대표적으로 Pinpoint, Data dog, Elastic APM 등 오픈 소스로 개발된 모니터링 툴이 존재한다.
  • 솔직히 여러 APM 제품들은 여러가지 많은 Observerblility를 제공한다. 풍부한 기능이 탑재된 만큼 여러 기준으로 성능을 모니터링 할 수 있다.
  • 그런데 사용자 입장에선 설정 해주어야 할 것들이 많고, 세팅부터가 간단하지 않다는 단점이 존재한다. 소규모 프로젝트를 진행할 때 분산 트랜잭션 추적 등 대규모 프로젝트의 적합한 제품들을 써야한다는 부담이 있다.

    실제로 에이전트 환경 설정은 YAML을 이용한 가독성 좋고 간편한 환경 설정을 지원한다.

agent:
   name: myAgent
   jar: /path/my-agent.jar
   server-uri: localhost:9100
   target-uri: localhost:8080     
   interval: 5
     retry:
       max-attempt: 10
       backoff:
         interval-millis: 1000
         multiplier: 2
  • 그러하여, 소규모 프로젝트의 성격에 맞게 최소한의 정보를 수집하였고, 환경 세팅이 간편하도록 설계 그리고 사용자가 잘 세팅할 수 있도록 가이드를 제시해주었다. (개발해보니까 내 맘대로 되지 않은 경우도 있지만.. ㅎ)
  • 여러 APM 제품도 고려하고 적용된 제품들이 많은데 유저의 편의성을 위해 Zero-Code 개념을 도입했다. Zero-Code는 에이전트가 직접 해당 Application의 Byte-code를 조작하여 코드를 주입시킨다. 이러한 기술 때문에 사용자는 자신의 코드를 변경할 필요가 없다는 장점이 있다.

기술 스택에 관한 고민.

모니터링 모델 선택

  • Push model 대표적인 Push model인 툴은 Pinpoint, elastic APM, 이 있다. Push model은 에이전트가 직접 서버로 데이터를 밀어 넣는다고 해서 Push model이다.

    • 장점 : 구조가 단순하다. 에이전트가 직접 데이터를 수집하고 데이터를 던지기 때문에 서버에 부하가 낮다. 서버의 확장이 용이하다. 실시간성이 뛰어나다.

    • 단점 : 해당 어플리케이션의 부담을 줄 수 있다. 가용성 측면에선 Pull이 더 유리하다.

  • Pull model 대표적인 Pull model인 툴은 Prometheus가 존재한다. Pull model은 서버가 직접 해당 어플리케이션의 데이터를 가져온다고 해서 Pull model이다.

    • 장점: 유연성이 좋다. Service Discovery를 통해서 유연성을 확보한다. 메트릭 구조에 따른 수정에 관한 유연성이 뛰어나다. 가용성이 좋다.

    • 단점: 서버의 확장이 어렵다. 디스크를 용량을 증설하는 것이 방법, 실시간 성이 낮다. 서버가 직접 데이터를 수집하므로 서버에 부담이 존재.

결정적으로 구현에 어려움이 있냐 없냐로 따졌을 땐 Push model이 구현이 더 편하다. Service Discovery의 구현이 생각보다 어렵다. Push모델은 모니터링 방식 중에 구현이 간편하고 구조가 단순하여 구현하기 편하다.

DB를 선택할 때, 확장성이 DB, 단일 DB로 충분히 퍼포먼스를 뽑아낼 수 있는 DB를 고려하였다. TimescaleDB는 단일 DB로서 최고의 퍼포먼스를 뽑아낼 수 있다. 심지어 Multi-Node관한 기술을 Deprecated했다. 그만큼 단일 DB로써 자신이 있다고 하고 단일 DB의 퍼포먼스를 최대한 보여주겠다고 했다.

TimescaleDB Multi-Node Depreacated

그런데 모순이 존재한다. Push model은 확장성이 뛰어나다는 장점이 있고, 현재 모델은 Push 모델이다. 단일 DB로 쓴다고 하면 Push 모델의 장점이 상쇄된다고 생각이 들지 않는가?

처음에는 판단 미스라고 생각했고, 후회도 했다. (이럴거면 Pull 모델할걸...)

내 생각은 이러하다. 모순이 존재하지만 '비즈니스도 확장해야하고 모니터링도 서비스도 확장해야 된다고?' 생각했을때 사용자에게 큰 부담을 주는 것이 아닌가란 생각을 했고 최대한 구현하기 편한 모델 (Push model) 을 선택하되 섣부른 확장은 안된다고 결론을 내렸고 단일 DB에서 최대한 퍼포먼스를 뽑아낼 수 있으면 좋다고 생각한다.

나중에 팀원도 생기고 버전업을 하면서 충분한 논의를 한 뒤에 발전된 APM을 보여주도록 하겠다.

언어 버전?? 어떤거?

먼저, java 21을 선택한 이유가 있다. 프로젝트 주요 타겟은 토이 프로젝트의 간단한 성능 측정, 최신 프로젝트를 사용하는 소규모 스타트업을 겨냥해서 제작하였다.

토이 프로젝트, 최신 프로젝트는 대부분 java 21을 사용할 것이라고 예상했고, java 21이 나온지 1년이 넘었다.

그리고 빈번한 I/O 작업의 이유가 있다. 에이전트는 서버에 다양한 메트릭 정보들을 Web socket으로 넘겨줘야하는데 이러한 작업들의 효율성을 높여주기 위해 Virtual Thread를 도입하였다.

또한, 에이전트는 해당 Application에 리소스 부담을 최소화 시켜야한다. Virtual Thread 는 생성과 소멸 비용이 굉장히 저렴하다. Platform Thread 에 비해 최소 10배 이상 저렴하다고 하니 해당 Application의 리소스 절약 측면에서 효율성을 가질 수 있다고 생각하였다.

java 21을 선택함으로써, 단점은 존재한다. 우선 다양한 프로젝트에 적용하기엔 한계가 있다는 점. Virtual Thread의 Pinning 문제가 존재한다.

Java Instrument

Zero-Code의 개념을 실현하기 위해 Java Instrument를 채택하였다.
Java Intrument는 java 5때 릴리즈 된 것으로 Application의 계측하는 데에 필요한 서비스를 제공해준다.

유저의 편의성을 제공하기 위해 Zero-Code의 개념을 도입한건데 Transaction의 실행시간을 측정하기 위해선 유저가 직접 코드에 실행시간을 측정하는 코드를 넣어야한다. 메서드 하나 당 실행시간 코드를 주입?? 피로도가 크고 SDK 형식으로 제공한다고 해도 유저는 코드를 수정해야한다는 부담이 있다.

이를 해결하기 위해 내가 직접 유저의 Application의 Byte-Code를 조작하여, 이러한 부담을 해소시켜주었다.

public static class TransactionAdvise {

        public static final Tracer tracer = TraceManager.configure(new TraceExporter());

        @OnMethodEnter
        public static SpanScope enter(@Origin final String method) {

            Span currentSpan = Span.current();

            Span span = tracer.spanBuilder(method)
                    .setParent(Context.current().with(currentSpan))
                    .setAttribute("method.name", method)
                    .setStartTimestamp(Instant.now())
                    .startSpan();

            Scope scope = span.makeCurrent();

            return new SpanScope(span, scope);
        }

        @OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class)
        public static void exit(@Enter final SpanScope spanScope, @Thrown final Throwable throwable) {

            if (Objects.nonNull(throwable)) {

                String exception = throwable.getClass().getName()
                        + " "
                        + "("
                        + throwable.getMessage()
                        + ")";

                spanScope.span().recordException(throwable);
                spanScope.span().setStatus(StatusCode.ERROR, exception);
            }

            spanScope.span().end(Instant.now());
            spanScope.scope().close();
        }
    }

이 코드는 유저의 Application에 Byte-code를 주입하여 실행시간을 측정하는 코드이다. 훗날 Tracing을 구현하기 위해 Span을 도입하였다.

Agent의 네트워크 개선

에이전트가 서버로 보낼 데이터는 텍스트 (JSON) 데이터이다. 텍스트는 굉장히 압축 효율이 좋은 편이며, 데이터의 사이즈가 작으면 작을 수록 네트워크 효율이 좋아진다.

이러한 네트워크 효율성을 올리기 위해 데이터를 압축하여 전송하기로 판단했다.

유념해야할 것이 있다.

  • 압축은 CPU 집약적인 작업이라 타깃 Application에 부담을 주는가?
  • 압축을 할만큼 네트워크 소모가 큰가?
  • 압축 효율이 뛰어난가?

이에 대한 생각은 다음과 같다.

CPU 집약적인 작업이지만, GZIP 압축이며 압축률이 BZIP보다 뛰어나지 않아 오버헤드가 크게 없다는 점. 그리고 요즘 사양을 생각한다면 괜찮다고 판단했다.

GZIP 압축은 BZIP 압축보다 효율은 뛰어나지 않지만 GZIP 압축으로도 충분히 데이터 사이즈를 개선할 수 있다. 압축률이 높을 수록 CPU빨을 많이 탄다.

실제 테스트 해본 결과. Wireshark 사용

실제 에이전트는 5초 마다 데이터를 보낸다. 약 600Byte를 보내는데, 하루 동안의 대역폭을 계산 해보겠다.

대역폭을 계산 해보면,

총 데이터량은 1회 데이터양 x 데이터를 보낸 횟수 = 600Byte x 17820 (하루에 5초 동안 보낸 데이터 개수) = 약 1MB

평균 대역폭은 bit로 계산한다. 8을 곱하는 이유이고 24시간을 초로 계산하면 86400이다.

(1,036,800 * 8) / 86400 = 120bps

다음과 같은 계산이 나온다.

이건 시스템 메트릭의 bps이다.

TrafficHunter는 트랜잭션 요청 로그도 전송한다.

하루 동안 총 1만 명의 사람이 이용하는 서비스의 APM이라면, 10,000 x 1Kb = 10Mb

(10Mb * 8) / 86400 = 925bps

총 1Kbps의 대역폭을 보여준다.

개선 전엔 개선 후 데이터양의 2배 차이가 나는데

대략 계산 해보면 개선 전 대역폭은 2Kbps의 대역폭을 보여준다.

압축을 하나마나 굉장히 작은 대역폭이긴 한데 APM이 발전하면 메트릭의 숫자도 많아지고, 에이전트의 개수도 많아지고 하다보면 데이터의 양을 개선해야 될 일이 생긴다.

데이터 압축을 고려하면 더욱 효율적인 네트워크 구성이 가능하다라는 것을 보여주고 싶었다.

Server의 효율적인 데이터 채널링 구성

기존 설계는 에이전트 마다 새로운 데이터를 처리해주는 Channel을 할당해주어, 데이터를 비동기로 처리하는 것이 목적이였다. 각각의 Channel은 에이전트 마다 독립적으로 데이터를 처리할 수 있으니 효율적이라고 생각했다.

  • 초기 설계 구조

하지만, 에이전트의 개수가 많아질수록 Channel의 개수도 많아지며 그만큼 로드되는 Channel 인스턴스의 개수가 늘어나 서버 리소스를 많이 소모한다는 단점이 있다.

  • 개선된 설계 구조

이러한 구조를 수집하는 데이터 성격으로 Channel을 나누었다. 이러면 에이전트의 개수만큼 Channel을 할당할 필요도 없다. 예를 들어 100개의 에이전트면 Channel이 100개가 할당되어야 하는데 개선된 구조에는 2개의 Channel 인스턴스만 로드되면 된다.

이러한 측면에서 서버 리소스의 절약을 하였다.

물론, 초기 설계 구조보다 동시 처리 능력을 떨어질 수 있다고 생각하지만 토이 프로젝트나, 소규모 프로젝트이면 이정도 구조로도 충분한 처리 능력을 보여준다고 생각한다.

Server의 DB Connection 고갈에 대한 대응

DB Connection 고갈에 대한 대응은 다음과 같다.

spring을 기준으로 본다면

  • HikariCP pool_size를 조정한다.
  • 최소한의 DB Task로 원자성을 가져야한다. 다른 작업과 DB Task와 하나의 트랜잭션으로 묶인다면 Connection이 작업하는 시간이 길어진다.

나는 두 번째 방법으로 최소한의 DB Task 작업의 원자성을 가지도록 개선했다.

초기 모델은 이러했다. Processor에서 압축된 바이너리 데이터를 압축해제하고 역직렬화 하고 Validator는 데이터를 검증하고 Repository에선 데이터를 저장한다.

이런 일련의 과정은 하나의 문제를 낳는다.

handler에선 저 일련의 과정을 하나의 트랜잭션으로 처리하기 때문이다. 그럼 Connection은 DB 외에도 CPU 집약적인 작업까지 하나의 Task로 보고 처리한다.

그럼 이러한 요청이 지속적으로 들어오면 의도치않게 Connection 고갈 문제에 빠지기 쉽다.

그럼 어떻게 문제를 해결해야할까?

트랜잭션을 최소한의 DB Task로 묶는 것이다. 즉 트랜잭션을 분리를 해야한다.

어떻게 분리해야하나?

  1. 미리 CPU, I/O 작업을 진행한 후 결과를 파라미터로 넘겨준다.
  2. 분리한 트랜잭션을 이벤트로 처리

1번 같은 경우는 구조를 변경해야한다. 이미 인터페이스 채널이 존재하고 틀이 있는 구조를 변경하기란 쉽지 않았다. 구조를 변경하지 않고 트랜잭션을 분리하려면 이벤트로 처리하는 것이 옳다고 판단했다.

1번 방법.

public class SysteminfoMetricChannel<SystemInfo> implements MetricChannel {

    private final MetricProcessor processor;

    private final ApplicationEventPublisher publisher;

    @Override
    public MetricHeaderSpec getHeaderSpec() {
        return MetricHeaderSpec.SYSTEM;
    }

    @Override
    public void open(final MetadataWrapper<SystemInfo> object, byte[] payload) {

        if(validator.validate(object)) {
            throw new TrafficHunterException("Metric validation failed");
        }

        TransactionMeasurement measurement = transactionMapper.map(object);

        repository.save(measurement);
    }
}

이 코드는 인터페이스를 변경해야하고 MetadataWrapper는 제네릭을 요구하기 때문에 코드를 변경하기가 꽤 까다롭다.

기존의 코드를 유지하고 트랜잭션 분리를 하기 위해 2번 방법을 이용했다.

2번 방법.

public class SysteminfoMetricChannel implements MetricChannel {

    private final MetricProcessor processor;

    private final ApplicationEventPublisher publisher;

    @Override
    public MetricHeaderSpec getHeaderSpec() {
        return MetricHeaderSpec.SYSTEM;
    }

    @Override
    public void open(final byte[] payload) {

        MetadataWrapper<SystemInfo> object = processor.processSystemInfo(payload);

        log.info("process system info: {}", object);

        publisher.publishEvent(new SystemInfoMetricEvent(object));
    }
}

@EventListener
@Transactional
public void handle(final SystemInfoMetricEvent event) {

    MetadataWrapper<SystemInfo> object = event.systemInfo();

    if(validator.validate(object)) {
        throw new TrafficHunterException("Metric validation failed");
    }

    MetricMeasurement measurement = systemInfoMapper.map(object);

    repository.save(measurement);
}

코드의 변경 없이 이벤트를 호출하고 이벤트 메서드 범위에 트랜잭션을 걸어서 분리에 성공하였다.

Traffic-Hunter의 DB는 어떤 것이 좋을까?

초기에는 시계열 데이터를 저장하는 용도로 InfluxDB를 채택했다. 가장 많이 쓰고 No-sql 특성상 Schema-less로 유연성도 좋고 확장성도 좋다. 누적되는 데이터를 확장하거나 버전의 변경으로 인해 데이터 형식이 바뀐다던지 이러한 이유로 InlfuxDB를 채택했다.

하지만, 단점이 발견 되고 InfluxDB를 채택하지 않았다.

  • 러닝커브
    InfluxDB는 Flux 쿼리로 동작하게 된다. 아직까지 SQL에 익숙한 나는 빠르게 개발에 돌입해야하는데 Flux 쿼리를 학습해야함으로써 개발 지연으로 판단했다.

  • 데이터 평탄화
    이게 TimescaleDB로 마이그레이션한 계기가 되었다. 다루는 데이터의 양은 꽤 있다. Cpu, Memory, Thread, GC, web-server request, web-server-thread, DBCP 역직렬화를 진행할 때 이걸 평탄화하는 작업은 개발 부담을 증가시키는 요인이 되었다. 쓰면서 항상 불만이 많았다.. ㅎㅎ..

이러한 단점으로 인해 InfluxDB는 함께하지 못했다.

TimescaleDB

TimescaleDB는 Influx의 단점을 모조리 해소시켜주었다.

  1. PostgreSQL의 확장판으로 SQL 쿼리 작성이 가능하다. (러닝 커브 해소) 실제 TimescaleDB 공식문서에서도 "사용자에게 더 큰 학습 곡선을 강요하고 호환되는 도구와 커뮤니티 지원이 부족합니다." 라고 서술되어있다.

  2. TimescaleDB는 PostgreSQL 확장판이라 특성상 RDB이긴 하지만, 데이터를 JSON으로 저장함으로써 No-sql 스럽게 사용할 수 있다. 스키마 변경에 따른 유연성 제공.

  3. hyper_table을 제공함으로써, 시계열 데이터를 핸들링하는데에 최적화 되어있다.

이러한 이유로 현재 TimescaleDB를 이용하여 APM을 구현하였다.

Visualization

개발 초기엔 직접 웹 뷰를 제작하여 제공하려고 했다.

나에게 다음과 같은 질문을 던져보았다.

  • UI를 제품으로 내놓을 수 있도록 제작할 수 있을 것인가?

    • 솔직히 자신이 없었다. 제품으로 내놓을 수 있을만큼 완성도 있는 UI를 제공하기는 무리였다.
  • 프론트와 백을 혼자서 개발해야하는데 MVP 개발 기한안에 제작할 수 있는가?

    • 에이전트와 서버를 개발하는데에 1달 하고도 10일이 걸렸다. MVP 개발은 2달 안에는 만족해야하기 때문에 무리라고 생각했다.
  • 실시간 그래프를 그려야하는데 랜더링 문제를 해결할 수 있는가?

    • 솔직히 React나 Vue를 잘 모른다. 제품을 내놓기엔 턱없이 부족한 실력..

이 세가지 질문만으로도 내가 뷰를 작성해야된다는 것은 무리라고 생각한다.

그러하여, 한가지 대책을 내놓았는데 Grafana를 써보자는 것이였다. 다행히 TimescaleDB는 Grafana를 지원한다. 정확히는 Postgresql이 지원하는데 TimescaleDB도 당연히 지원한다.

Grafana를 선택한 이유는 직접 내가 뷰를 개발하지 않아도 되고, 멋스러운 UI가 이미 제공되어 있기 때문이다. 패널만 설정하면 되니 부담은 없었다. Grafana를 구축하고 대시보드를 제작하는데에 3일 밖에 걸리지 않았다. 개발 기한에 충분히 만족하였다.

그런데, 행복한 나날도 잠시.. 문제에 직면했다.

Grafana의 문제점

실시간 그래프를 뷰잉해주는데에는 굉장히 좋은 툴이다. 하지만, 트랜잭션 트레이싱을 구현하는데 어려움을 겪고 있다.

그리고 계획을 하나 하고 있는데 메트릭 데이터 분석에 용이하도록 API를 제공해줘야 하는데 Grafana에선 그러기 쉽지 않았다. Grafana라는 틀이 존재해서 자유롭게 기능을 확장하기 힘든 점.

트랜잭션 트레이싱은 Traces라는 패널을 제공해주지만, 일반 Sql 쿼리로 구현이 안되는 것 같다. 심지어 자료도 없어서 어렵다. Grafana는 Tempo와 Jaeger 분산 트레이싱 스토리지를 제공하는데 이걸 위해서 유저에게 이걸 또 설치 해야한다는 부담은 간과할 수 없다.

결국은 다음 릴리즈때 팀원을 구하던지, 시간을 좀 들여서 내가 구현 한다던지 직접 독자적인 뷰를 제작하여 제공 해줘야 한다고 결론지었다.

현재 남아있는 문제점.

  • 독자적인 뷰를 구축하는 것.

  • 에이전트는 여러 플러그인을 제공해야한다. Java로 제작된 프로그램. ex) Kafka, Apache Cassandra, Elasticsearch 등

  • 에이전트는 서버와 Websocket으로 통신하는데 압축, 직렬화, 역직렬화가 오버헤드가 크다고 판단되면, gRPC도 고려해야한다.

약 2달동안 APM을 제작하면서 모니터링의 중요성을 알게되었고, 오픈소스로 처음 릴리즈하게 되었다.

앞으로 더 좋은 APM 오픈소스를 제작하기 위해 공부하고 여러 컨트리뷰터들을 구하기 위해 노력할 것이다.

profile
swaegr253@naver.com

0개의 댓글

Powered by GraphCDN, the GraphQL CDN