API 성능 이제 이해가 되셨을까요? - Tracing 2편

peterTheAnteater·2025년 4월 5일
8

데브옵스

목록 보기
15/18
post-thumbnail

Nest.js, Traefik, RabbitMQ, Redis, Go 등에서 트레이싱을 설정하고 사용한 실습 자료 입니다.

글을 쓰게 된 동기

는 1편을 확인 해주세요.

API 성능 안 괜찮아 딩딩딩딩딩 🎶

기본 트레이싱 스택 설정

일단 저는 대부분의 기술들을 도커로 올려서 사용합니다. 그럼으로 이번 실습 자료에서도 도커 기반으로 올리겠습니다.

일단 기본적으로 필요한 스택은 아래와 같습니다.

  1. ScyllaDB
  2. Jaeger
  3. opentelemetry-collector-contrib
  4. jaeger-cassandra-schema

ScyllaDB

ScyllaDB는 저희가 예거에 연결을 해서 트레이싱 데이터를 저장할 데이터베이스 입니다. 카산드라와 같이 NoSQL기반 데이터베이스 입니다. 카산드라와 달리 C++로 만들어진 (카산드라는 Java) 스택이라 비교적 더 성능이 좋다는 평가가 있습니다. (JVM이 없고 GC가 없기 때문에 Stop-the-world현상이 없습니다)

Jaeger

예거는 저희가 트레이싱 데이터를 처리하고 API로 제공해주는 역활을 합니다. 데이터 수집도 가능하고 또한 UI도 제공을 해줍니다.

opentelemetry-collector-contrib

짧게 말해서 Otel-collector는 트레이싱 데이터를 분산 시스템 서비스들에서 받아오고 예거로 다시 보내주는 역활을 합니다. OpenTelemetry 를 사용하는 서비스들에서 데이터 수집을 합니다.

jaeger-cassandra-schema

이 스택은 카산드라 (저희 같은 경우에는 ScyllaDB)를 예거가 사용할수 있도록 스키마를 설정 해주는 역활을 해줍니다. 이 스택은 한번만 실행이 되면 됩니다.

.....
그렇게 위 스택들을 사용하면 아래와 같은 컴포즈 파일이 나옵니다.

services:
  jaeger:
    image: jaegertracing/all-in-one:1.67.0
    environment:
      SPAN_STORAGE_TYPE: cassandra
      CASSANDRA_SERVERS: scylla
      CASSANDRA_KEYSPACE: jaeger_v1_test
      CASSANDRA_PORT: 9042
      CASSANDRA_CONSISTENCY: ONE
    ports:
      - "16686:16686"
      - "14250:14250"
      - "14268:14268"
      - "14269:14269"
    volumes:
      - jaeger-data:/data
    depends_on:
      - scylla
    restart: always
    networks:
      - tracing-network
  
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.122.1
    command: ["--config=/etc/otel-collector-config.yml"]
    volumes:
      - ./opentelemetry/otel-collector-config.yml:/etc/otel-collector-config.yml
    ports:
      - "4317:4317"
      - "4318:4318"
    networks:
      - tracing-network
    restart: always
  
  scylla:
    image: scylladb/scylla:latest
    command: --smp 2 --memory 6G --overprovisioned 1
    ports:
      - "9042:9042"
      - "9180:9180"
    volumes:
      - scylla-data:/var/lib/scylla
    networks:
      - tracing-network
    healthcheck:
      test: ["CMD", "cqlsh", "-e", "SELECT now() FROM system.local;"]
      interval: 30s
      retries: 5
      start_period: 30s

  jaeger-schema-init:
    image: jaegertracing/jaeger-cassandra-schema:latest
    environment:
      CASSANDRA_PROTOCOL_VERSION: 4
      CASSANDRA_VERSION: 4
      CQLSH_HOST: scylla
      DATACENTER: test
      MODE: test
    restart: on-failure
    depends_on:
      - scylla
    networks:
      - tracing-network

volumes:
  jaeger-data:
  scylla-data:

networks:
  tracing-network:
    driver: bridge

그리고 otel-collector-config.yml 는 아래와 같이 작성을 하였습니다.

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: ":4317"
      http:
        endpoint: ":4318"

exporters:
  otlp:
    endpoint: jaeger:4317
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlp]

설정파일의 값들이 무슨뜻을 의미하는지는 설명을 해드려야 하지만 귀찮으니 GPT한테 물어보시면 아마 설명을 잘 해줄겁니다.

트레이스를 할 서비스들

저희는 기본적으로 아래 서비스들을 트레이스 할겁니다.

  1. Traefik
  2. Nest.js
  3. Prisma (DB ORM)
  4. Redis, RabbitMQ
  5. Go Consumer

레디스와 레빗엠큐는 따로 트레이싱을 하기보다는 네스트와 고 서비스 사이에서 어떻게 데이터가 들어갔다 나왔다가 했는지 확인을 할 예정입니다.

Traefik

트래픽은 저희가 사용하는 리버스 프록시 겸 로드 밸린서 입니다 (NGINX같은 스택입니다) 저희 서비스 대부분의 요청은 리버스 프록시를 통해서 들어오기 때문에 트레이싱 스팬 (span)을 여기서 부터 시작해줘야 합니다.

트레픽에서 오픈텔레메트리로 트레이싱을 하려면 버젼을 3이상 사용하면 됩니다.

Nest.js

네스트는 저희가 사용하는 API 서비스들중 하나 입니다. 네스트에서 트레이싱은 비교적 쉽습니다. Opentelemtry sdk가 아주 잘 되어 있거든요. 하지만 레디스, 레빗엠큐, 데이터베이스를 트레이싱 하기 위해서 설정을 조금 해줘야 합니다.

Prisma

프리즈마는 저희가 PostgreSQL에서 데이터 I/O를 하기 위해 사용한 ORM입니다. 프리즈마 또한 SDK가 잘 되어있기 때문에 설정이 비교적 쉽습니다.

Redis, Rabbitmq

이 스택들도 네스트 설정 할때 같이 해주면 됩니다.

Go

고 컨슈머는 조금 더 복잡합니다. Trace header를 추출해서 직접 추가를 하는 작업을 해줘야 합니다.

Traefik

트래픽 도커 컴포즈에는 아래와 같은 커맨드를 추가 하시면 트레이싱이 활성화 됩니다.

traefik:
    image: traefik:v3.3
    command:
      - 다른 설정값들.....
      - ......
      - "--tracing=true"
      - "--tracing.otlp=true"
      - "--tracing.otlp.grpc.insecure=true"
      - "--tracing.otlp.grpc.endpoint=otel-collector:4317"
      - "--tracing.serviceName=traefik"
      - "--tracing.sampleRate=1.0"

저 같은 경우에는 오픈텔레메트리 컨테이너 이름을 otel-collector로 했기 때문에 endpoint를 otel-collector:4317로 했습니다.

공식 문서를 보시면 위 값을 커스터 마이즈 할수 있기 때문에 참고 하시면 좋습니다.

Nest.js

네스트는 도커 컴포즈에 설정을 바로 박는게 아니라 파일 하나를 더 생성 해줘야 합니다.

저는 trace.ts라는 파일을 생성해서 아래와 같이 만들었습니다.
참고로 이 파일은 main.ts와 같은 곳에 있습니다.

// trace.ts
'use strict';

import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import { CustomWinstonLogger } from './common/logger/winston.logger';

const logger = new CustomWinstonLogger();

const sdk = new NodeSDK({
    traceExporter: new OTLPTraceExporter({
        url:
            process.env.TRACING_AGENT_URL ||
            'http://otel-collector:4318/v1/traces',
    }),
    instrumentations: [
        new HttpInstrumentation(),
        new ExpressInstrumentation(),
        new NestInstrumentation(),
    ],
    resource: resourceFromAttributes({
        [ATTR_SERVICE_NAME]: process.env.TRACING_SERVICE_NAME || 'nestjs-app',
    }),
});

logger.log('Starting tracing');
sdk.start();

process.on('SIGTERM', () => {
    sdk.shutdown()
        .then(() => logger.log('Tracing terminated'))
        .catch((error) => logger.log('Error terminating tracing', error))
        .finally(() => process.exit(0));
});

export default sdk;

위 같이 설정을 해서 main.ts최상단에 불러오면 됩니다.

// main.ts
import tracing from './trace';
tracing.start();
import { AppModule } from './app.module';
... 나머지 코드...
....
...

놀랍게도 이러면 끝납니다.

Prisma

프리즈마 설정은 이미 네스트용 설정을 했으면 더욱 쉽습니다.

아래 임포트를 trace.ts를 추가해서

import { PrismaInstrumentation } from '@prisma/instrumentation';

instrumentations: []에 추가 해주면 됩니다.

'use strict';

import { PrismaInstrumentation } from '@prisma/instrumentation'; # 여기 추가됨
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import { CustomWinstonLogger } from './common/logger/winston.logger';

const logger = new CustomWinstonLogger();

const sdk = new NodeSDK({
    traceExporter: new OTLPTraceExporter({
        url:
            process.env.TRACING_AGENT_URL ||
            'http://otel-collector:4318/v1/traces',
    }),
    instrumentations: [
        new PrismaInstrumentation(), # 여기 추가됨
        new HttpInstrumentation(),
        new ExpressInstrumentation(),
        new NestInstrumentation(),
    ],
    resource: resourceFromAttributes({
        [ATTR_SERVICE_NAME]: process.env.TRACING_SERVICE_NAME || 'nestjs-app',
    }),
});

// Enable the API to record telemetry
logger.log('Starting tracing');
sdk.start();

// Gracefully shut down the SDK on process exit
process.on('SIGTERM', () => {
    sdk.shutdown()
        .then(() => logger.log('Tracing terminated'))
        .catch((error) => logger.log('Error terminating tracing', error))
        .finally(() => process.exit(0));
});

export default sdk;

쉽죠?

Redis, Rabbitmq

이 스택들도 똑같습니다. trace.ts에 임포트 해와서 instrumentations: []에 추가해주면 됩니다.

'use strict';

import { PrismaInstrumentation } from '@prisma/instrumentation';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import { AmqplibInstrumentation } from '@opentelemetry/instrumentation-amqplib'; # 추가됨
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'; # 추가됨
import { CustomWinstonLogger } from './common/logger/winston.logger';

const logger = new CustomWinstonLogger();

const sdk = new NodeSDK({
    traceExporter: new OTLPTraceExporter({
        url:
            process.env.TRACING_AGENT_URL ||
            'http://otel-collector:4318/v1/traces',
    }),
    instrumentations: [
        new PrismaInstrumentation(),
        new HttpInstrumentation(),
        new ExpressInstrumentation(),
        new NestInstrumentation(),
        new AmqplibInstrumentation(), #rabbitmq 추가
        new IORedisInstrumentation(), #redis 추가
    ],
    resource: resourceFromAttributes({
        [ATTR_SERVICE_NAME]: process.env.TRACING_SERVICE_NAME || 'nestjs-app',
    }),
});

// Enable the API to record telemetry
logger.log('Starting tracing');
sdk.start();

// Gracefully shut down the SDK on process exit
process.on('SIGTERM', () => {
    sdk.shutdown()
        .then(() => logger.log('Tracing terminated'))
        .catch((error) => logger.log('Error terminating tracing', error))
        .finally(() => process.exit(0));
});

export default sdk;

네 이게 끝입니다.

Go Consumer

고는 조금 더 해줘야 하는 작업들이 있습니다. 신기하게도 Node에서 사용하는 OpenTelemetry SDK를 사용하면 알아서 traceparent헤더를 트레픽에서 받아서 한개의 Span으로 처리를 하던데 고는 그렇게 안되더라구요? 그래서 직접 추가를 해줘야 합니다.

솔직히 고 언어는 이 글을 읽는 분들 중 관심 있을 분들이 거의 없을꺼 같아서 대충 넘기겠습니다. 고는 에초에 네스트 처럼 파일 하나 딸깍으로 끝내는게 아니라 context.contexttrace.Span을 코드에서 넘겨서 함수나 트랜잭션 마다 시작하고 끝내줘야합니다.

예시:

tracer := otel.Tracer("worker")
ctx, span := tracer.Start(ctx, "DecodeAndValidate",
		trace.WithAttributes(attribute.String("message_id", msg.MessageId)),
)
defer span.End()

결과

결과를 보고싶으면 Grafana에 Jaeger를 추가해서 보셔도 되고 그냥 localhost:16686을 들어가서 확인 해도 됩니다. 16686은 예거 UI 포트번호 입니다.

대충 아래와 같이 뜨는데

한번 모든 서비스들을 거치는 예시 요청을 날려보면...

1개의 Parent Span으로 잘 된걸 확인 할수있습니다.

눌러서 세부적인 트레이스를 확인 해보면

잘 된거를 확인 할수 있습니다. traefik을 통해서 들어와서 nestjs-app을 들어오는데 미들웨어와 handler, controller 와 그 외 함수들을 거치고 Prisma를 통해서 쿼리가 어떻게 나가는지도 잘 보이는걸 확인할수있습니다.

그 이후 고 컨슈머를 통해 나가는건 crawler-workerpublish <default>를 통해서 옮겨진걸 확인 할수있는게 이 부분이 레빗엠큐를 통해서 나간겁니다.

그리고 각 트랜잭션과 오퍼레이션 별 시간이 얼마나 걸렸는지도 확인을 할수 있었는데 덕분에 서비스 최적화를 어떻게 하면 되는지 알수 있었습니다.

결론

트레이싱은 비교적 설정이 어려운 편이고 관리하기 어렵지만 리턴값은 확실합니다. 트레이싱 덕분에 데이터가 어떻게 플로우 되고 어디서 오류가 생기는지 확실히 판별하는데 도움이 되는거 같습니다.

퍼포먼스 확인도 쉽게 가능하니 최적화를 어디서 해야하는지 알기도 쉬운건 정말 좋은거같습니다.

이 글을 읽으시는분들도 프로젝트/서비스에 트레이싱 사용하셔서 서비스 발전을 진행시켜보세요!

profile
소프트웨어 개발과 밀당하는 개발자

1개의 댓글

comment-user-thumbnail
2025년 4월 6일

오호 한번 적용해보겠습니다 !!

답글 달기