
Nest.js, Traefik, RabbitMQ, Redis, Go 등에서 트레이싱을 설정하고 사용한 실습 자료 입니다.
는 1편을 확인 해주세요.
일단 저는 대부분의 기술들을 도커로 올려서 사용합니다. 그럼으로 이번 실습 자료에서도 도커 기반으로 올리겠습니다.
일단 기본적으로 필요한 스택은 아래와 같습니다.
ScyllaDB는 저희가 예거에 연결을 해서 트레이싱 데이터를 저장할 데이터베이스 입니다. 카산드라와 같이 NoSQL기반 데이터베이스 입니다. 카산드라와 달리 C++로 만들어진 (카산드라는 Java) 스택이라 비교적 더 성능이 좋다는 평가가 있습니다. (JVM이 없고 GC가 없기 때문에 Stop-the-world현상이 없습니다)
예거는 저희가 트레이싱 데이터를 처리하고 API로 제공해주는 역활을 합니다. 데이터 수집도 가능하고 또한 UI도 제공을 해줍니다.
짧게 말해서 Otel-collector는 트레이싱 데이터를 분산 시스템 서비스들에서 받아오고 예거로 다시 보내주는 역활을 합니다. OpenTelemetry 를 사용하는 서비스들에서 데이터 수집을 합니다.
이 스택은 카산드라 (저희 같은 경우에는 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한테 물어보시면 아마 설명을 잘 해줄겁니다.
저희는 기본적으로 아래 서비스들을 트레이스 할겁니다.
레디스와 레빗엠큐는 따로 트레이싱을 하기보다는 네스트와 고 서비스 사이에서 어떻게 데이터가 들어갔다 나왔다가 했는지 확인을 할 예정입니다.
트래픽은 저희가 사용하는 리버스 프록시 겸 로드 밸린서 입니다 (NGINX같은 스택입니다) 저희 서비스 대부분의 요청은 리버스 프록시를 통해서 들어오기 때문에 트레이싱 스팬 (span)을 여기서 부터 시작해줘야 합니다.
트레픽에서 오픈텔레메트리로 트레이싱을 하려면 버젼을 3이상 사용하면 됩니다.
네스트는 저희가 사용하는 API 서비스들중 하나 입니다. 네스트에서 트레이싱은 비교적 쉽습니다. Opentelemtry sdk가 아주 잘 되어 있거든요. 하지만 레디스, 레빗엠큐, 데이터베이스를 트레이싱 하기 위해서 설정을 조금 해줘야 합니다.
프리즈마는 저희가 PostgreSQL에서 데이터 I/O를 하기 위해 사용한 ORM입니다. 프리즈마 또한 SDK가 잘 되어있기 때문에 설정이 비교적 쉽습니다.
이 스택들도 네스트 설정 할때 같이 해주면 됩니다.
고 컨슈머는 조금 더 복잡합니다. Trace header를 추출해서 직접 추가를 하는 작업을 해줘야 합니다.
트래픽 도커 컴포즈에는 아래와 같은 커맨드를 추가 하시면 트레이싱이 활성화 됩니다.
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로 했습니다.
공식 문서를 보시면 위 값을 커스터 마이즈 할수 있기 때문에 참고 하시면 좋습니다.
네스트는 도커 컴포즈에 설정을 바로 박는게 아니라 파일 하나를 더 생성 해줘야 합니다.
저는 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';
... 나머지 코드...
....
...
놀랍게도 이러면 끝납니다.
프리즈마 설정은 이미 네스트용 설정을 했으면 더욱 쉽습니다.
아래 임포트를 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;
쉽죠?
이 스택들도 똑같습니다. 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;
네 이게 끝입니다.
고는 조금 더 해줘야 하는 작업들이 있습니다. 신기하게도 Node에서 사용하는 OpenTelemetry SDK를 사용하면 알아서 traceparent헤더를 트레픽에서 받아서 한개의 Span으로 처리를 하던데 고는 그렇게 안되더라구요? 그래서 직접 추가를 해줘야 합니다.
솔직히 고 언어는 이 글을 읽는 분들 중 관심 있을 분들이 거의 없을꺼 같아서 대충 넘기겠습니다. 고는 에초에 네스트 처럼 파일 하나 딸깍으로 끝내는게 아니라 context.context와 trace.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-worker로 publish <default>를 통해서 옮겨진걸 확인 할수있는게 이 부분이 레빗엠큐를 통해서 나간겁니다.
그리고 각 트랜잭션과 오퍼레이션 별 시간이 얼마나 걸렸는지도 확인을 할수 있었는데 덕분에 서비스 최적화를 어떻게 하면 되는지 알수 있었습니다.
트레이싱은 비교적 설정이 어려운 편이고 관리하기 어렵지만 리턴값은 확실합니다. 트레이싱 덕분에 데이터가 어떻게 플로우 되고 어디서 오류가 생기는지 확실히 판별하는데 도움이 되는거 같습니다.
퍼포먼스 확인도 쉽게 가능하니 최적화를 어디서 해야하는지 알기도 쉬운건 정말 좋은거같습니다.
이 글을 읽으시는분들도 프로젝트/서비스에 트레이싱 사용하셔서 서비스 발전을 진행시켜보세요!
오호 한번 적용해보겠습니다 !!