【NestJs】使用Winston+ELK分布式链路追踪日志采集

8 阅读4分钟

在微服务架构中,日志分散在多个服务中,如何快速定位问题?本文将带你从零搭建一套完整的 ELK 日志系统,并实现基于 TraceId 的分布式链路追踪。

背景

在单体应用时代,查日志可能只是 grep 一下的事情。但在微服务架构中,一个请求可能经过网关、认证服务、业务服务、AI服务等多个节点,日志分散在不同的服务器和文件中。当线上出现问题时,如何快速串联起完整的请求链路?

核心痛点:

  • 日志分散,难以定位问题所在服务
  • 缺乏统一的日志收集和查询平台
  • 无法追踪请求在微服务间的调用链路
  • 错误日志信息不完整,排查效率低

解决方案:ELK + TraceId

技术架构

整体架构图

┌─────────────────────────────────────────────────────────────────────────┐
│                           ELK 日志架构                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   ┌──────────────┐     ┌──────────────┐     ┌──────────────┐          │
│   │ Admin 服务    │     │ Client 服务   │     │ Chat 服务     │          │
│   │ iscream-admin│     │iscream-client│     │ iscream-chat │          │
│   └──────┬───────┘     └──────┬───────┘     └──────┬───────┘          │
│          │                    │                    │                   │
│          │   Winston + Elasticsearch Transport    │                   │
│          │                    │                    │                   │
│          └────────────────────┼────────────────────┘                   │
│                               ▼                                         │
│                    ┌─────────────────────┐                             │
│                    │   Elasticsearch     │                             │
│                    │   索引:            │                             │
│                    │   - iscream-admin   │                             │
│                    │   - iscream-client  │                             │
│                    │   - iscream-chat    │                             │
│                    └──────────┬──────────┘                             │
│                               │                                         │
│                               ▼                                         │
│                    ┌─────────────────────┐                             │
│                    │      Kibana         │                             │
│                    │  日志查询 & 可视化   │                             │
│                    └─────────────────────┘                             │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

技术栈

组件版本用途
Elasticsearch8.12.0日志存储与检索
Kibana8.12.0日志可视化界面
Winston4.x日志收集框架
winston-elasticsearch0.19.xES 日志传输
winston-daily-rotate-file-日志文件轮转
AsyncLocalStorageNode.js 内置TraceId 上下文传递

一、ELK 基础设施搭建

1.1 Docker Compose 配置

创建独立的 ELK 配置文件,方便单独管理:

# docker-compose.elk.yml
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
    container_name: elasticsearch
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - xpack.security.enrollment.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ports:
      - "9200:9200"
      - "9300:9300"
    volumes:
      - elasticsearch-data:/usr/share/elasticsearch/data
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 5
    restart: unless-stopped

  kibana:
    image: docker.elastic.co/kibana/kibana:8.12.0
    container_name: kibana
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    ports:
      - "5601:5601"
    depends_on:
      elasticsearch:
        condition: service_healthy
    restart: unless-stopped

volumes:
  elasticsearch-data:

1.2 启动 ELK

# 启动服务
docker-compose -f docker-compose.elk.yml up -d

# 验证 ES 是否启动
curl http://localhost:9200

# 预期返回
{
  "name": "elasticsearch",
  "cluster_name": "docker-cluster",
  "version": { "number": "8.12.0" }
}

# docker-compose.elk.yml
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
    container_name: elasticsearch
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - xpack.security.enrollment.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ports:
      - "9200:9200"
      - "9300:9300"
    volumes:
      - elasticsearch-data:/usr/share/elasticsearch/data
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 5
    restart: unless-stopped

  kibana:
    image: docker.elastic.co/kibana/kibana:8.12.0
    container_name: kibana
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    ports:
      - "5601:5601"
    depends_on:
      elasticsearch:
        condition: service_healthy
    restart: unless-stopped

volumes:
  elasticsearch-data:

1.2 启动 ELK

# 启动服务
docker-compose -f docker-compose.elk.yml up -d

# 验证 ES 是否启动
curl http://localhost:9200

# 预期返回
{
  "name": "elasticsearch",
  "cluster_name": "docker-cluster",
  "version": { "number": "8.12.0" }
}

二、LoggerModule 动态模块封装

2.1 设计思路

在微服务架构中,我们有多个服务(admin、client、chat),每个服务需要:

  • 独立的服务标识(serviceName)
  • 独立的 ES 索引
  • 统一的日志格式

使用 NestJS 动态模块模式,让每个服务可以灵活配置:

// 在 admin 服务中使用
LoggerModule.forRoot({
  serviceName: 'iscream-admin',
})

// 在 client 服务中使用
LoggerModule.forRoot({
  serviceName: 'iscream-client',
})

2.2 配置接口定义

// logger.interface.ts
export interface LoggerModuleConfig {
  /**
   * 服务名称,用于标识日志来源
   * @example 'iscream-admin' | 'iscream-client' | 'iscream-chat'
   */
  serviceName: string;

  /** 日志级别,默认 'info' */
  logLevel?: string;

  /** 是否启用 Elasticsearch 日志输出 */
  enableElasticsearch?: boolean;

  /** Elasticsearch 节点地址 */
  elasticsearchNode?: string;

  /** Elasticsearch 索引名 */
  elasticsearchIndex?: string;

  /** 日志文件目录 */
  logDir?: string;
}

export const LOGGER_MODULE_CONFIG = 'LOGGER_MODULE_CONFIG';

2.3 动态模块实现

// logger.module.ts
@Global()
@Module({})
export class LoggerModule {
  /**
   * 同步配置方式
   */
  static forRoot(config: LoggerModuleConfig): DynamicModule {
    const loggerConfigProvider: Provider = {
      provide: LOGGER_MODULE_CONFIG,
      useValue: config,
    };

    return {
      module: LoggerModule,
      imports: [ConfigModule],
      providers: [loggerConfigProvider, LoggerService],
      exports: [LoggerService],
      global: true,
    };
  }

  /**
   * 异步配置方式(支持依赖注入)
   */
  static forRootAsync(options: {
    useFactory: (...args: any[]) => Promise<LoggerModuleConfig> | LoggerModuleConfig;
    inject?: any[];
  }): DynamicModule {
    const loggerConfigProvider: Provider = {
      provide: LOGGER_MODULE_CONFIG,
      useFactory: options.useFactory,
      inject: options.inject || [],
    };

    return {
      module: LoggerModule,
      imports: [ConfigModule],
      providers: [loggerConfigProvider, LoggerService],
      exports: [LoggerService],
      global: true,
    };
  }
}

2.4 LoggerService 核心实现

// logger.service.ts
@Injectable()
export class LoggerService implements NestLoggerService {
  private logger: Logger;
  private readonly config: Required<LoggerModuleConfig>;

  constructor(
    private configService: ConfigService,
    @Inject(LOGGER_MODULE_CONFIG) moduleConfig: LoggerModuleConfig,
  ) {
    // 合并配置:模块配置 > 环境变量 > 默认值
    this.config = {
      serviceName: moduleConfig.serviceName,
      logLevel: moduleConfig.logLevel || 
                this.configService.get('LOG_LEVEL') || 'info',
      enableElasticsearch: moduleConfig.enableElasticsearch ?? 
                          this.configService.get('ELASTICSEARCH_ENABLED') === 'true',
      elasticsearchNode: moduleConfig.elasticsearchNode || 
                        this.configService.get('ELASTICSEARCH_NODE') || 
                        'http://localhost:9200',
      // 索引名自动根据 serviceName 生成
      elasticsearchIndex: moduleConfig.elasticsearchIndex || 
                         this.configService.get('ELASTICSEARCH_INDEX') || 
                         `${moduleConfig.serviceName}-logs`,
      logDir: moduleConfig.logDir || 'logs',
    };
    this.logger = this.createLogger();
  }

  private createLogger(): Logger {
    const { serviceName, logLevel, enableElasticsearch, 
            elasticsearchNode, elasticsearchIndex, logDir } = this.config;
    
    const transports: transport[] = [
      // 控制台输出
      new winston.transports.Console({ /* ... */ }),
      // 文件轮转
      new DailyRotateFile({ filename: `${logDir}/application-%DATE%.log`, /* ... */ }),
      new DailyRotateFile({ filename: `${logDir}/error-%DATE%.log`, level: 'error', /* ... */ }),
    ];

    // ES 输出(生产环境启用)
    if (enableElasticsearch) {
      transports.push(new ElasticsearchTransport({
        level: logLevel,
        clientOpts: { node: elasticsearchNode },
        index: elasticsearchIndex,
        transformer: (logData: any) => ({
          '@timestamp': logData.timestamp,
          level: logData.level,
          message: logData.message,
          context: logData.context || 'Application',
          trace: logData.trace,
          service: serviceName,
          environment: process.env.NODE_ENV || 'development',
          pid: process.pid,
        }),
      }));
    }

    return winston.createLogger({
      level: logLevel,
      defaultMeta: { service: serviceName },
      transports,
      exitOnError: false,
    });
  }

  // 标准日志方法
  log(message: string, context?: string) {
    this.logger.info(message, { context });
  }

  error(message: string, trace?: string, context?: string) {
    this.logger.error(message, { context, trace });
  }
  
  // ... warn, debug, verbose
}

三、TraceId 链路追踪实现

3.1 为什么需要 TraceId?

┌─────────────────────────────────────────────────────────────────┐
│                    没有 TraceId 的问题                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  用户请求 → Client 服务 → Chat 服务 → AI 服务                    │
│                 │            │           │                       │
│                 ▼            ▼           ▼                       │
│              日志 A        日志 B       日志 C                    │
│              (无关联)      (无关联)     (无关联)                   │
│                                                                 │
│  问题:三个日志分散在 ES 中,如何知道它们属于同一个请求?          │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                    有 TraceId 的解决方案                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  用户请求 → Client 服务 → Chat 服务 → AI 服务                    │
│                 │            │           │                       │
│                 ▼            ▼           ▼                       │
│              日志 A        日志 B       日志 C                    │
│           traceId:123   traceId:123  traceId:123                 │
│                                                                 │
│  在 Kibana 中搜索 traceId:123,即可看到完整链路!                 │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

3.2 AsyncLocalStorage 存储方案

使用 Node.js 内置的 AsyncLocalStorage,实现请求级别的上下文存储:

// trace-id.store.ts
import { AsyncLocalStorage } from 'async_hooks';

interface TraceContext {
  traceId: string;
  userId?: string | number;
  ip?: string;
  [key: string]: any;
}

export class TraceIdStore {
  private static readonly storage = new AsyncLocalStorage<TraceContext>();

  /** 获取当前 TraceId */
  static getTraceId(): string | undefined {
    return this.storage.getStore()?.traceId;
  }

  /** 获取完整上下文 */
  static getContext(): TraceContext | undefined {
    return this.storage.getStore();
  }

  /** 在 TraceId 上下文中执行函数 */
  static run<T>(context: TraceContext, callback: () => T): T {
    return this.storage.run(context, callback);
  }

  /** 生成唯一 TraceId */
  static generateTraceId(): string {
    const timestamp = Date.now().toString(36);
    const random = Math.random().toString(36).substring(2, 10);
    return `${timestamp}-${random}`;
  }
}

3.3 HTTP 中间件实现

// trace-id.middleware.ts
@Injectable()
export class TraceIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    // 优先从请求头获取(支持跨服务传递)
    const traceId = 
      (req.headers['x-trace-id'] as string) ||
      (req.headers['x-request-id'] as string) ||
      TraceIdStore.generateTraceId();

    // 获取客户端信息
    const ip = 
      (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
      req.socket.remoteAddress ||
      'unknown';

    const userId = (req as any).user?.id;

    // 创建追踪上下文
    const context = { traceId, ip, userId, method: req.method, url: req.originalUrl };

    // 设置响应头(前端可获取)
    res.setHeader('X-Trace-Id', traceId);

    // 在上下文中执行后续逻辑
    TraceIdStore.run(context, () => next());
  }
}

3.3 HTTP 中间件实现

// trace-id.middleware.ts
@Injectable()
export class TraceIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    // 优先从请求头获取(支持跨服务传递)
    const traceId = 
      (req.headers['x-trace-id'] as string) ||
      (req.headers['x-request-id'] as string) ||
      TraceIdStore.generateTraceId();

    // 获取客户端信息
    const ip = 
      (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
      req.socket.remoteAddress ||
      'unknown';

    const userId = (req as any).user?.id; // 在这里 Middleware一般是在Guard之后执行,所以拿不到UserId,可以通过补一个Interceptor的方式setContext或者改用Interceptor实现TraceId的传递

    // 创建追踪上下文
    const context = { traceId, ip, userId, method: req.method, url: req.originalUrl };

    // 设置响应头(前端可获取)
    res.setHeader('X-Trace-Id', traceId);

    // 在上下文中执行后续逻辑
    TraceIdStore.run(context, () => next());
  }
}

3.3 HTTP 中间件实现

// trace-id.middleware.ts
@Injectable()
export class TraceIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    // 优先从请求头获取(支持跨服务传递)
    const traceId = 
      (req.headers['x-trace-id'] as string) ||
      (req.headers['x-request-id'] as string) ||
      TraceIdStore.generateTraceId();

    // 获取客户端信息
    const ip = 
      (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
      req.socket.remoteAddress ||
      'unknown';

    const userId = (req as any).user?.id;

    // 创建追踪上下文
    const context = { traceId, ip, userId, method: req.method, url: req.originalUrl };

    // 设置响应头(前端可获取)
    res.setHeader('X-Trace-Id', traceId);

    // 在上下文中执行后续逻辑
    TraceIdStore.run(context, () => next());
  }
}

四、跨服务 TraceId 传递

4.1 问题分析

┌─────────────────────────────────────────────────────────────────┐
│                    gRPC 调用的 TraceId 断裂问题                   │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Client 服务(网关)                                              │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ 1. HTTP 请求进来                                          │   │
│  │ 2. TraceIdMiddleware 生成 TraceId: "abc-123"             │   │
│  │ 3. 调用 Chat 服务(gRPC)                                  │   │
│  │    ❌ 没有把 TraceId 传过去!                             │   │
│  └─────────────────────────────────────────────────────────┘   │
│                         │                                       │
│                         ▼ gRPC 调用                              │
│                                                                 │
│  Chat 服务(微服务)                                              │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ 4. GrpcTraceInterceptor 接收请求                          │   │
│  │    ❌ Metadata 中没有 TraceId,生成新的 "xyz-789"         │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  结果:两个服务的 TraceId 不一致!                                │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

4.2 解决方案:gRPC Metadata 传递

客户端注入 TraceId

在 gRPC 客户端调用时,自动注入 TraceId 到 Metadata:

import { firstValueFrom, Observable } from "rxjs";
import { TraceIdStore } from "@app/common";
import { Metadata } from '@grpc/grpc-js';

/**
 * 将服务中的指定方法转换为返回Promise的异步方法
 * 主要用于将基于RxJS Observable的服务方法转换为更易使用的Promise形式
 * 代码原理:
 * 1. 通过获取服务原型的属性名,筛选出白名单中指定的方法
 * 2. 对每个选中的方法进行包装,将其转换为async函数
 * 3. 如果原始方法返回Observable,则使用firstValueFrom将其转换为Promise
 * 4. 保留原始方法的上下文和参数传递
 * 
 * @param service - 需要处理的服务实例对象
 * @param whiteList - 需要转换的方法名列表,默认为空数组
 */
export const promisifyServiceMethods = (service: any, options: { url?: string, whiteList?: string[], serviceName?: string, injectTraceId?: boolean } = {}) => {
    const { whiteList = [], injectTraceId = true } = options ;
    // 获取服务原型上的所有属性名,并筛选出白名单中存在的函数方法
    const methodNames = Object.keys(service)
        .filter((property) => typeof service[property] === 'function' && !whiteList.includes(property));
    // 遍历所有需要转换的方法名
    //console.log('promisifyServiceMethods', methodNames);
    for (const methodName of methodNames) {
        const originalMethod = service[methodName];
        // 使用async函数包装原始方法
        service[methodName] = async (...args: any[]) => {
            try {
                
                /* 添加traceId 到 metadata */
                if(injectTraceId){
                    let traceId = TraceIdStore.getTraceId();
                    var userId = TraceIdStore.getContext()?.userId;
                    if(traceId){
                        let metadata = args[1];
                        if(!(metadata instanceof Metadata)) metadata = new Metadata();
                        metadata.add('traceId', traceId);
                        if(userId) metadata.add('userId', userId);
                        args[1] = metadata;
                    }
                }
                
                // 调用原始方法并获取结果(保留上下文和参数)
                const result = await originalMethod.apply(service, args);
                // 如果结果是Observable,则转换为Promise并返回其第一个值
                if (result instanceof Observable) {
                    return await firstValueFrom(result);
                }
                // 非Observable类型直接返回结果
                return result;
            }catch (error) {
                /* 报错信息挂载服务名和服务地址 用于过滤器捕获进行重连重试熔断降级等 */
                if (options.serviceName) {
                     error.serviceName = options.serviceName
                     error.url = options.url
                }
                throw error;
            }
        };
    }
}; 

export type PromisifyServiceMethods<T> = {
    [K in keyof T]: T[K] extends (...args: infer A) => Observable<infer R>
      ? (...args: A) => Promise<R>
      : T[K];
  };

};

使用promisifyServiceMethods

@Injectable()
export class ChatService {
    @Inject(CLOUD_SERVICE) 
    private nacosService: CloudService;

    get chatService() {
        // 从NacosService中获取健康的GrpcClient 
        const client = this.nacosService.getRandomClient(CHAT_SERVICE_NAME) as any;
        // 得到Grpc的Service
        const chatService = client.getService(CHAT_SERVICE_NAME) as ChatServiceClient;
        //调用promisifyServiceMethods将Observable转换Promise并且注入TraceId到Metadata
        promisifyServiceMethods(chatService, { url: client.url!, serviceName: CHAT_SERVICE_NAME ,whiteList:['chatWithAgent']});
        return chatService as unknown as PromisifyServiceMethods<ChatServiceClient>;
    }
    
    async chat(msg:string){
       return await this.chatService.chat({msg});
    }
}

服务端提取 TraceId

// GrpcTraceInterceptor
@Injectable()
export class GrpcTraceInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const metadata = context.switchToRpc().getContext();
    
    // 从 Metadata 提取 TraceId
    const traceId = metadata?.get('trace-id')?.[0] || TraceIdStore.generateTraceId();
    const userId = metadata?.get('user-id')?.[0];

    const traceContext = { traceId: traceId as string, userId: userId as string };

    return new Observable((observer) => {
      TraceIdStore.run(traceContext, () => {
        next.handle().subscribe(observer);
      });
    });
  }
}

4.3 完整链路图

┌─────────────────────────────────────────────────────────────────┐
│                    完整 TraceId 传递链路                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  用户请求                                                       │
│     │                                                           │
│     ▼                                                           │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  Client 服务                                             │   │
│  │                                                          │   │
│  │  1. TraceIdMiddleware                                    │   │
│  │     └─ TraceId: "abc-123"                                │   │
│  │                                                          │   │
│  │  2. 调用 gRPC 方法时注入 Metadata:                        │   │
│  │     { 'trace-id': 'abc-123', 'user-id': '456' }          │   │
│  └─────────────────────────────────────────────────────────┘   │
│                         │                                       │
│                         ▼ gRPC + Metadata                        │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  Chat 服务                                               │   │
│  │                                                          │   │
│  │  3. GrpcTraceInterceptor                                 │   │
│  │     └─ 从 Metadata 提取 TraceId: "abc-123" ✅            │   │
│  │                                                          │   │
│  │  4. 日志自动注入 TraceId                                  │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  ✅ 同一 traceId,可在 Kibana 中串联查询!                        │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

五、异常日志规范化

5.1 AllExceptionFilter 改造

// all-exception.filter.ts
@Catch()
export class AllExceptionFilter implements ExceptionFilter {
  constructor(private readonly logger: LoggerService) {}

  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();

    const httpStatus = exception instanceof HttpException
      ? exception.getStatus()
      : HttpStatus.INTERNAL_SERVER_ERROR;

    const errorMessage = this.getErrorMessage(exception);

    // 规范化错误日志
    this.logger.error(
      `[${request.method} ${request.originalUrl}] ${errorMessage}`,
      exception.stack,
      'ExceptionFilter'
    );

    response.status(200).json({
      code: httpStatus,
      msg: errorMessage,
      data: null,
    });
  }
}

5.2 注册到全局

// app.module.ts
providers: [
  {
    provide: APP_FILTER,
    useClass: AllExceptionFilter,
  },
]

六、Kibana 配置与使用

6.1 创建 Data View

  1. 打开 Kibana:http://localhost:5601
  2. 进入 Discover 页面
  3. 点击 Create data view
  4. 配置:
    • Name: iscream-logs
    • Index pattern: iscream-*(匹配所有服务)
    • Timestamp field: @timestamp

6.2 常用查询

# 查看某个服务的日志
service: "iscream-client"

# 按 TraceId 查询完整链路
traceId: "abc-123"

# 查看错误日志
level: "error"

# 查看某个用户的所有操作
userId: "456"

# 组合查询
service: "iscream-chat" AND level: "error" AND traceId: "abc-123"

七、项目收益

实施这套方案后,我们获得了:

  1. 快速定位问题:通过 TraceId 串联完整链路,排查时间从小时级降到分钟级
  2. 统一日志平台:所有服务日志集中管理,告别分散的日志文件
  3. 可观测性提升:错误趋势、接口性能一目了然
  4. 开发效率提升:标准化的日志模块,新服务开箱即用

参考资料


本文基于 NestJS 微服务架构实践,涵盖了从 ELK 搭建到 TraceId 链路追踪的完整方案。如有问题欢迎评论区交流!