EFK 스택 구성하기(feat. Node, ECS Fargate)

Alli_Eunbi·2023년 5월 1일
0

로그란?

로그는 에러 추적을 위해 필요하다.
에러가 발생했을때 어떤 api가 호출 되었는지, 해당 api가 어떤 함수를 트리거 하는지, DB는 무엇을 호출하는지 등등의 정보가 있어야 정확하게 어디서부터 문제를 해결해야하는지 파악할 수 있다.
특히 MSA와 같은 아키텍쳐 모델들은 특정 서비스에서 장애가 생길 경우, 로깅이 정확할수록 상호 작용하는 모든 서비스들을 확인할 필요 없이 어떤 서비스의 에러인지 정확하게 파악하고 처리할 수 있다.
이런 로그 추적 시스템들을 분산 로그 추적 시스템(Distributed Tracing)이라 한다.
앞으로 남길 로그들은 총 세가지가 있다.

  1. Metrics
  • Metrics 는 특정 데이터 또는 항목에 대해 집계하여 수치를 분석한 데이터를 의미한다.(숫자 데이터) 예를 들면, cpu 사용량 또는 memory 사용량을 평균을 내어 30초마다 평균 어느정도를 사용하는지 파악하여 시간대별로 표로 만들어 보여줄 수 있다.
    AWS에서 ec2 cloudwatch metrics 같은 것들이 대표적인 metrics 이다.

  1. Traces
  • Traces는 이벤트(api와 같은 이벤트) 발생시 해당 이벤트가 어떤 로직을 호출하여 결론에 도달하는지 기록한다. Trace들을 이해하기 전에, Span이라는 것을 이해해야 한다.
  • Span은 분산 시스템에서 한 작업을 나타내는 개념이다. 분산 시스템에서는 한 작업이 여러 컴퓨터 또는 서비스에 걸쳐 실행될 수 있다. 이 때 각 단계마다 span을 만들어 작업을 추적한다. 각 span은 시작 시간과 종료 시간, 그리고 이 작업을 수행하는 서비스의 이름과 버전 등의 메타데이터를 포함한다. 이러한 정보는 추적 시스템에서 이용되어 작업의 성능을 측정하고 문제가 발생했을 때 디버깅에 활용된다.
  • Trace는 여러 개의 span으로 이루어져 있으며, 여러 작업 간의 연관성을 추적할 수 있다. 예를 들어, 한 요청에 대한 처리 과정을 추적하려면 해당 요청의 span을 모두 모아 하나의 trace로 만들 수 있다. Trace를 사용하면 분산 시스템에서의 여러 작업 간의 상관 관계를 파악할 수 있으며, 전체 시스템의 성능을 분석할 수 있다.
    Span은 span context라는 메타데이터를 가지고 있으며, span context는 아래의 내용을 포함한다.
속성설명
traceId전체 작업을 추적하기 위한 고유 식별자
spanId현재 "span"의 고유 식별자
parentSpanId현재 "span"의 부모 "span"의 고유 식별자
traceState분산 추적 시스템에서 사용하는 다른 메타데이터를 포함. 이러한 메타데이터는 일반적으로 사용자 정의 속성이며, "span context"를 통해 전달

이외에도 span에는 시작시간, 종료시간 및 tag("span"과 관련된 추가 정보를 제공하는 데 사용. 예를 들어, "HTTP" 요청의 경우, "tags"는 요청 URL, HTTP 메소드, 응답 코드 등을 포함) 등이 있습니다.

  • 요약하여 설명하자면, 회원의 ID를 조회하는 api가 호출 될 경우 이 호출 자체가 trace가 되고 조회를 위해 DB를 검색하거나 validation을 처리하는 함수 등이 trace 아래의 작은 작업 단위의 span이 된다.
  1. Logs
  • Logs는 말 그대로 log이다.(글자 데이터) console.log 와 같이 개발자들이 남겨둔 로그와 같은 것들이 있다. 이런 log들은 개발 단계에서는 매우 유용하게 사용하지만, 운영 단계에서는 꽤 많은 사용량을 차지하기 때문에, if(statusCode < 400) 일 경우 로그를 남기지 않는 등의 처리가 필요하다. 로그는 섬세하게 남길 수록 에러 추적시 편리하므로 어떤 서비스에서 어떻게 에러가 났는지 확인할 수 있도록 해야한다.
    필자와 같은 경우는 console.error(${서비스명(혹은 함수명}, ~~ 에러 발생)과 같이 남겨 빠르게 해당 함수를 찾고 어떤 에러인지 파악 할 수 있도록 하였다.
    또한 이런 로그들은 단순한 log만 남기는 것보다 traceId도 포함되게 하는 것이 좋은데, 이유는 에러가 발생한 TraceId를 가지고 opensearch(혹은 elasticsearch)에서 로그 검색이 가능하기 때문이다.
    이를 위해 pino나 winston 같은 로그 라이브러리를 사용하기도 한다.

OpenSearch로 로그 전송하기

aws는 firelens를 사용하여 편리하게 ecs 배포 설정이 가능하다.

AWS FireLens는 ECS의 task와 Fluent Bit(또는 Fluentd) 사이드카를 배포하고 로그를 라우팅할 수 있는 로그 드라이버입니다. AWS FireLens를 사용하면 배포 스크립트를 수정하지 않고도 컨테이너 로그를 스토리지 및 분석 도구로 보낼 수 있습니다. AWS Fargate 에 대한 몇 가지 구성 업데이트를 통해 대상을 선택하고 선택적으로 필터를 정의하여 필요한 위치로 컨테이너 로그를 보내도록 FireLens 에 지시합니다.

  • !! executionRoleArn 반드시 Cloudwatch logs에 대한 권한이 있어야 한다

  • Fluent-bit를 사용하여 로그 전송
    아래와 같이 Fargate를 사용하여 작업을 정의해주면 된다. ecs fargate 설정은 JSON으로 새 계정 생성을 클릭후 아래를 복사 붙여 넣기 하면 된다. ({}안의 내용은 변경)

    "containerDefinitions": [
        {
            "name": "{appName}",
            "image": "{appImage}",
            "portMappings": [
                {
                    "name": "{your app}-port-tcp",
                    "containerPort": {app port},
                    "hostPort": {app port},
                    "protocol": "tcp"
                }
            ],
            "essential": true,
            "logConfiguration": {
                "logDriver": "awsfirelens",
                "options": {
                    "AWS_Auth": "On",
                    "AWS_Region": "ap-northeast-2",
                    "Host": "{opensearch url}",
                    "Index": "{Index}",
                    "Name": "es",
                    "Port": "443",
                    "Suppress_Type_Name": "On",
                    "tls": "On"
                }
            }
        },
        {
            "name": "log-router",
            "image": "906394416424.dkr.ecr.ap-northeast-2.amazonaws.com/aws-for-fluent-bit:stable",
            "memoryReservation": 50,
            "essential": true,
            "user": "0",
            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-create-group": "true",
                    "awslogs-group": "firelens-container",
                    "awslogs-region": "ap-northeast-2",
                    "awslogs-stream-prefix": "firelens"
                }
            },
            "firelensConfiguration": {
                "type": "fluentbit",
                "options": {
                    "config-file-type": "file",
                    "config-file-value": "/fluent-bit/configs/parse-json.conf"
                }
            }
        }
    ],

참고로 Suppress_Type_Name 을 설정해줘야 es7 이상, 혹은 opensearch 최신 버전에서 mapping type 삭제로 인해 생기는 에러를 막을 수 있다. (사용하는 버전이 elasticsearch 6 버전 이하면 Suppress_Type_Name을 삭제하고 "type":{타입이름}을 설정해줘야 한다.)

참조) https://velog.io/@junhoskills10/ECS-TaskDefintion-FireLens-%ED%86%B5%ED%95%A9-%ED%99%9C%EC%84%B1%ED%99%94

Opensearch에서 만들어진 log 확인

opensearch 햄버거 메뉴 -> Stack management를 클릭 후 indices 클릭

Create Index Pattern 클릭 후 {인덱스명} 혹은 {인덱스명}- 과 같이 입력한 이후

타임필드를 클릭하여 시계열로 데이터를 뽑을 수 있도록 field를 설정해주면 된다.

이후 다시 햄버거 메뉴를 클릭하여 Discover에 들어가면
내가 설정한 인덱스를 볼 수 있으며, 인덱스별로 들어온 로그를 시간순으로 확인할 수 있다.


Jaegger로 Trace 로깅하기

Jaeger는 분산 시스템에서의 추적(tracing)을 위한 오픈소스 도구이다.

Jaeger는 Google의 Dapper 및 Uber의 Zipkin 추적 시스템에서 영감을 받아 개발되었다. Jaeger는 CNCF(Cloud Native Computing Foundation)의 오픈소스 프로젝트 중 하나로서, Kubernetes 및 Istio와 같은 CNCF 프로젝트와 통합이 가능하며, 여러 언어 및 프레임워크에서 사용할 수 있는 API를 제공한다.

Jaegger는 사용하는 포트가 많은데 기본 포트는 아래와 같다.

  1. Collector port: 14267/tcp, 14268/tcp
    Jaeger Collector가 수집한 추적 데이터를 수신하는 데 사용됩니다.
    14267/tcp는 gRPC 프로토콜을 사용하며, 14268/tcp는 Thrift 프로토콜을 사용합니다.

  2. Query port: 16686/tcp
    Jaeger UI가 사용하는 포트입니다.
    웹 브라우저에서 Jaeger UI를 열면, 해당 포트를 통해 UI가 띄워집니다.

  3. Agent port: 5775/udp, 6831/udp, 6832/udp
    Jaeger Agent가 수집한 추적 데이터를 Collector로 전송하는 데 사용됩니다.

  • 5775/udp는 컨테이너 환경에서의 호스트 네트워크 모드에서 사용됩니다.
  • 6831/udp와 6832/udp는 다른 프로세스나 호스트에서 Agent와 통신하기 위해 사용됩니다.
  1. HTTP port: 16686/tcp
    Jaeger UI에 대한 HTTP 요청을 처리하는 데 사용됩니다.

  2. Admin HTTP port: 14269/tcp
    Jaeger Collector와 Agent의 관리 기능에 대한 HTTP 요청을 처리하는 데 사용됩니다.

  3. Health check port: 16687/tcp
    Jaeger 서비스의 상태를 확인하는 데 사용됩니다.

이중에서 주로 개발자가 사용하는 포트는 14268 포트와 16686 포트이다.

{Jaegger IP 주소}:14268/api/traces로 트레이싱 정보를 날리고 {Jaegger IP 주소}:16686 로 Jaegger ui에 접속하기 때문이다.

여기에 추가적으로 사용할 포트는 아래와 같다.

  • 9411 : zipkin은 여러 다양한 백엔드 저장소와 통합이 가능하며, 대표적으로는 Cassandra, Elasticsearch, MySQL 등을 지원 (OpenTelemtry SDK로 받은 tracing을 Opensearch 전송용으로 사용)

Jaegger 역시 간단하게 ecs fargate로 띄울 수 있다.
Jaegger all-in-one 이미지를 사용하면 수월하게 띄울 수 있다.

    "containerDefinitions": [
        {
            "name": "dev-container-jaeger",
            "image": "jaegertracing/all-in-one:latest",
            "cpu": 0,
            "portMappings": [
                {
                    "name": "dev-container-jaeger-14269-tcp",
                    "containerPort": 14269,
                    "hostPort": 14269,
                    "protocol": "tcp"
                },
                {
                    "name": "dev-container-jaeger-14268-tcp",
                    "containerPort": 14268,
                    "hostPort": 14268,
                    "protocol": "tcp"
                },
                {
                    "containerPort": 6832,
                    "hostPort": 6832,
                    "protocol": "udp"
                },
                {
                    "containerPort": 6831,
                    "hostPort": 6831,
                    "protocol": "udp"
                },
                {
                    "containerPort": 5775,
                    "hostPort": 5775,
                    "protocol": "udp"
                },
                {
                    "name": "dev-container-jaeger-14250-tcp",
                    "containerPort": 14250,
                    "hostPort": 14250,
                    "protocol": "tcp"
                },
                {
                    "name": "dev-container-jaeger-16685-tcp",
                    "containerPort": 16685,
                    "hostPort": 16685,
                    "protocol": "tcp"
                },
                {
                    "name": "dev-container-jaeger-5778-tcp",
                    "containerPort": 5778,
                    "hostPort": 5778,
                    "protocol": "tcp"
                },
                {
                    "name": "dev-container-jaeger-16686-tcp",
                    "containerPort": 16686,
                    "hostPort": 16686,
                    "protocol": "tcp"
                },
                {
                    "name": "dev-container-jaeger-9411-tcp",
                    "containerPort": 9411,
                    "hostPort": 9411,
                    "protocol": "tcp"
                }
            ],
            "essential": true,
            "command": [
                "--collector.zipkin.host-port",
                "9411"
            ],
            "environment": [
                {
                    "name": "ES_SERVER_URLS",
                    "value": "{opensearch url}"
                },
                {
                    "name": "SPAN_STORAGE_TYPE",
                    "value": "elasticsearch"
                }
            ],
            "mountPoints": [],
            "volumesFrom": [],
            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-create-group": "true",
                    "awslogs-group": "/ecs/{ecs 서비스 이름}",
                    "awslogs-region": "ap-northeast-2",
                    "awslogs-stream-prefix": "ecs"
                }
            }
        }
    ],

위를 복사 붙여넣기 하여 Jaegger가 잘 뜬 것을 확인하면, 이제 앱 서비스에서 Jaegger 쪽으로 api를 호출하여 trace 정보를 넘겨야 한다.

Node는 아래와 같이 tracing.ts를 작성하면 된다.

import {
  SimpleSpanProcessor,
  ConsoleSpanExporter,
} from '@opentelemetry/sdk-trace-base';
import { api, node, NodeSDK } from '@opentelemetry/sdk-node';
import process from 'process';
const { BasicTracerProvider } = require('@opentelemetry/sdk-trace-base');
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { JaegerExporter } from '@opentelemetry/exporter-jaeger';
import { B3Propagator } from '@opentelemetry/propagator-b3';
import { WinstonInstrumentation } from '@opentelemetry/instrumentation-winston';

import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';

const jaegerExporter = new JaegerExporter({
  endpoint: 'http://{Jaegger IP}:14268/api/traces',
});

const { trace } = require('@opentelemetry/api');
const traceConsoleExporter = new ConsoleSpanExporter();


// 로컬로 실행할 경우 Jaeger에 trace 기록 X, console로만 trace 찍음.
const traceExporter =
  process.env.NODE_ENV === `local` ? traceConsoleExporter : jaegerExporter;

const spanProcessor =
  process.env.NODE_ENV === `development`
    ? new SimpleSpanProcessor(traceExporter)
    : new BatchSpanProcessor(traceExporter);

// Set B3 Propagator
api.propagation.setGlobalPropagator(new B3Propagator());

export const otelSDK = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: `{서비스 명}`,
  }),
  spanProcessor,
  instrumentations: [
    new HttpInstrumentation(),
    new ExpressInstrumentation(),
    new NestInstrumentation(),
    new WinstonInstrumentation(),
  ],
});

// gracefully shut down the SDK on process exit
process.on('SIGTERM', () => {
  otelSDK
    .shutdown()
    .then(
      () => console.log('SDK shut down successfully'),
      (err) => console.log('Error shutting down SDK', err),
    )
    .finally(() => process.exit(0));
});

이후 main.ts에서 아래와 같이 실행시키면 된다.

async function bootstrap() {
  // 제일 처음에 otelSDK를 실행 시켜야 함.
  await otelSDK.start();
  
  const app = await NestFactory.create(AppModule, {cors: true});
 
 // winston을 사용하여 로그를 남김
  app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));

  await app.listen(3000);
}
bootstrap();
  
profile
BACKEND

0개의 댓글