在微服务架构中,日志分散在多个服务中,如何快速定位问题?本文将带你从零搭建一套完整的 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 │ │
│ │ 日志查询 & 可视化 │ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
技术栈
| 组件 | 版本 | 用途 |
|---|---|---|
| Elasticsearch | 8.12.0 | 日志存储与检索 |
| Kibana | 8.12.0 | 日志可视化界面 |
| Winston | 4.x | 日志收集框架 |
| winston-elasticsearch | 0.19.x | ES 日志传输 |
| winston-daily-rotate-file | - | 日志文件轮转 |
| AsyncLocalStorage | Node.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
- 打开 Kibana:
http://localhost:5601 - 进入 Discover 页面
- 点击 Create data view
- 配置:
- Name:
iscream-logs - Index pattern:
iscream-*(匹配所有服务) - Timestamp field:
@timestamp
- Name:
6.2 常用查询
# 查看某个服务的日志
service: "iscream-client"
# 按 TraceId 查询完整链路
traceId: "abc-123"
# 查看错误日志
level: "error"
# 查看某个用户的所有操作
userId: "456"
# 组合查询
service: "iscream-chat" AND level: "error" AND traceId: "abc-123"
七、项目收益
实施这套方案后,我们获得了:
- 快速定位问题:通过 TraceId 串联完整链路,排查时间从小时级降到分钟级
- 统一日志平台:所有服务日志集中管理,告别分散的日志文件
- 可观测性提升:错误趋势、接口性能一目了然
- 开发效率提升:标准化的日志模块,新服务开箱即用
参考资料
本文基于 NestJS 微服务架构实践,涵盖了从 ELK 搭建到 TraceId 链路追踪的完整方案。如有问题欢迎评论区交流!