起因: 因为mq报错,需要去openSearch board 看log,查看到底是哪里出错了,在filter上加上了logger:项目名, 以及log#message is one of error, 过滤完后却看不到任何报错,但去k8s 里面的error 输出又能看到。
首先分析整一个日志输出的流程
this.logger.error(...) --(pino logger send it to)--> stdio --(devops stream it to)--> openSearch DashBoards
这种架构的核心理念是:应用只负责"产生"日志,不负责"搬运"或"存储"日志。
下面我为你拆解这个链路的每一个环节,让你明白数据是怎么流动的:
1. this.logger.error(...) (生产者)
- 角色: 你的 Node.js 应用程序。
- 工具: Pino。它是一个高性能的 Node.js 日志库。
- 动作: 当你调用
error时,Pino 会把你的错误对象、上下文信息、时间戳等序列化成一行 JSON 字符串。
- 例如:
{"level":50,"time":1634...,"msg":"something wrong"} - 关键点: Pino 非常快,因为它只是把字符串"吐"出来,不做任何网络请求或文件写入操作(避免阻塞主线程)。
2. stdio (标准输出/缓冲区)
- 角色: 操作系统层面的"管道" (Standard Output / Standard Error)。
- 动作: 你的应用把那行 JSON 字符串打印到了控制台(Terminal)。
- 在容器中: 如果你的应用跑在 Docker 或 Kubernetes 里,这个
stdio实际上是被容器运行时(Runtime)接管的。 - 关键点: 这是应用与基础设施的解耦点。 你的代码完全不需要知道日志最后去了哪里(是文件、是 ES、还是 S3),它只管往控制台打印。
3. devops stream it to (搬运工)
- 角色: 日志采集代理 (Log Shipper / Agent)。
- 工具: 通常是 Fluentd, Fluent Bit, Filebeat 或者 Logstash。这些是由 DevOps 部署在服务器后台的独立进程。
- 动作:
-
- 这个"搬运工"时刻监听着你应用容器的
stdio输出。 - 一旦发现有新的一行日志出来,它就把它"抓"走。
- 它可能会给日志贴上标签(比如:这是来自 staging 环境,app-v1 版本)。
- 异步发送: 它通过网络把日志推送到远程服务器。
- 这个"搬运工"时刻监听着你应用容器的
- 关键点: 如果这里网络断了,或者搬运工挂了,你的应用完全不知道(因为应用只管往 stdout 写,写完就完了)。这也解释了为什么有时候应用没报错,但你在远程日志平台查不到日志。
4. openSearch DashBoards (仓库)
- 角色: 集中式日志服务器 (Centralized Logging Server)。
- 动作: 接收"搬运工"发来的数据,进行索引(Indexing),存储,并提供查询界面(比如 Kibana)。
总结这个架构的优缺点
|--------|--------------------------------------------------------------------------------------------------------------------|
| 维度 | 说明 |
| 优点 | 应用性能高:应用只写内存/流,不涉及慢速的磁盘IO或网络IO。 职责分离:开发只管打日志,运维只管收日志,互不干扰。 安全:如果日志服务器挂了,不会导致你的业务应用崩溃。 |
| 缺点 | 丢失风险:如果日志生成的太快,超过了"搬运工"的处理速度,或者容器突然崩溃,缓冲区里的极少量日志可能会丢失。 调试延迟 :从你代码执行 error 到你在网页上看到它,可能会有几秒钟的延迟(Latency)。 |
那好,既然现在知道了整一个日志生成以及存储的链,下一步那为什么会产生这个问题呢?
问题产生的原因
那就是在部署项目的时候 pod外面还会包着一层PM2,而恰恰就是因为这个PM2 会再去给logger进行一层封装,封装到log#message里,而我原本项目里若是跑cron job(或者eventbus),就不会再包装PM2,因为不是PM2启动的,而是用yarn启动的,这样log就不会再掉进log#message,而是pino默认的#msg
PM2是什么?
PM2 (Process Manager 2) 是 Node.js 应用程序在生产环境下的标准进程管理工具。
简单来说,如果你在本地开发时运行 node app.js,那是在"裸奔";而在服务器上部署时,你需要一个"管家"来帮你照看这个程序,PM2 就是这个管家。
它的核心作用是让你的应用程序永远保持在线(Alive),并在不需要停机的情况下更新代码。
1. 为什么需要 PM2?(对比 node app.js)
|-------------|--------------------------------|---------------------------------------|
| 特性 | 直接运行 (node app.js ) | 使用 PM2 (pm2 start app.js ) |
| 程序崩溃 | 进程直接退出,服务挂掉,需要手动重启 | 自动重启 (Auto Restart),用户几乎无感知 |
| CPU 利用率 | 单线程,只能利用 1 个 CPU 核心 | 负载均衡 (Cluster Mode),利用服务器所有核心 |
| 代码更新 | 必须停止服务 -> 更新 -> 启动 (有由于停机时间) | 平滑重载 (0-Second Reload),不中断服务 |
| 后台运行 | 关闭终端窗口,程序就结束了 | 守护进程 (Daemon),关掉终端程序依然在后台运行 |
| 服务器重启 | 程序不会自动启动 | 开机自启 (Startup Hooks),服务器重启后自动拉起服务 |
2. PM2 的核心功能详解
A. 进程守护 (Daemonize)
它将 Node.js 进程放入后台运行。你即使退出了 SSH 连接,服务依然在跑。
B. 负载均衡与集群模式 (Cluster Mode)
Node.js 本质是单线程的。如果你的服务器是 8 核 CPU,直接运行只能用到 1 核。 PM2 的集群模式可以启动多个实例(通常等于 CPU 核数),共享同一个端口,由 PM2 自动分发流量,极大提升并发处理能力。
- 命令 :
pm2 start app.js -i max(启动最大核数的实例)
C. 日志管理
PM2 会自动捕获程序的 stdout (标准输出) 和 stderr (错误输出),并将其写入日志文件。这与你刚才问的 Pino 配合完美:Pino 生成 JSON 日志打印到控制台,PM2 负责把这些打印的内容收集到文件中并管理(如日志轮转)。
D. 监控 (Monitoring)
PM2 自带监控终端,可以实时查看 CPU、内存使用率和请求响应情况。
3. 常用命令速查表
|------------|-------------------------------------|---------------------|
| 目的 | 命令 | 说明 |
| 启动 | pm2 start app.js --name "my-api" | 启动并命名应用 |
| 启动(集群) | pm2 start app.js -i max | 利用所有 CPU 核心启动 |
| 查看列表 | pm2 list / pm2 ls | 查看所有正在运行的应用 |
| 查看日志 | pm2 logs | 实时查看所有应用的日志 |
| 监控 | pm2 monit | 打开图形化监控面板 |
| 重启 | pm2 restart <id/name> | 重启应用 |
| 平滑重载 | pm2 reload <id/name> | 0 秒停机重载(推荐用于生产环境更新) |
| 停止/删除 | pm2 stop <id> / pm2 delete <id> | 停止或移除应用 |
| 生成自启 | pm2 startup | 生成开机自启动脚本 |
| 保存当前状态 | pm2 save | 保存当前运行列表,以便重启后恢复 |
4. PM2 配置文件 (ecosystem.config.js)
在实际项目中,我们不会每次都敲长命令,而是使用一个配置文件来管理。
在项目根目录运行 pm2 init 会生成 ecosystem.config.js:
JavaScript
module.exports = {
apps : [{
name: "my-api-service", // 应用名称
script: "./dist/main.js", // 启动脚本路径
instances: "max", // 开启集群模式,利用所有 CPU
exec_mode: "cluster", // 显式指定集群模式
env_production: {
NODE_ENV: "production", // 注入环境变量
LOG_LEVEL: "info"
},
error_file: "./logs/err.log", // 错误日志路径
out_file: "./logs/out.log", // 普通日志路径
merge_logs: true, // 集群模式下合并日志
}]
}
启动方式: pm2 start ecosystem.config.js
PM2 可以让其变成多进程,但注意不是多线程。
虽然这两个词听起来效果差不多(都能利用多核 CPU 提升性能),但在写代码时的逻辑是完全不同的。
我可以给你打个比方来解释这个区别:
1. 形象的比喻:麦当劳收银台
- 原本的 NestJS (单线程) : 这就好比一家麦当劳只开了一个收银台。 不管你的厨房(服务器 CPU)有多大、有多少个厨师,点餐的队伍只能排成一列。如果前面有一个人点单很慢(计算密集型任务),后面所有人都要等。其他 7 个收银台(CPU 核心)都是空的,没人用。
- PM2 Cluster 模式 (多进程) : PM2 做的不是"让那个收银员长出三头六臂(多线程)",而是直接把这个收银员"克隆"了 7 个。 现在你有了 8 个完全一样的收银台同时工作。顾客(请求)来了,PM2 会负责把顾客分配到空闲的收银台去。
结论 :虽然不是多线程,但它确实让你的服务能同时处理多个请求了,吞吐量(RPS)直接翻倍。
2. 这里的"坑"在哪里?(非常重要!)
既然是"克隆"了多个应用实例,它们之间是不共享记忆(内存)的。这会给 NestJS 开发带来 3 个巨大的副作用,如果你直接上 PM2 而不修改代码,大概率会出 Bug:
⚠️ 坑一:全局变量失效
- 场景 :你在代码里写了一个变量
let onlineUserCount = 0来统计在线人数。用户 A 访问了进程 1,count变成了 1。用户 B 访问了进程 2,进程 2 里的count还是 0。 - 解决 :必须用 Redis。把所有需要共享的状态(Session、缓存、计数器)全部存到 Redis 里,而不是存到 Node.js 的内存变量里。
⚠️ 坑二:定时任务 (Cron Job) 重复执行
- 场景 :你在 NestJS 里写了一个
@Cron('0 8 * * *')每天早上 8 点发送日报邮件。 - 后果 :如果你开启了 4 个 PM2 实例,这 4 个实例都会在 8 点触发任务。用户会同时收到 4 封一模一样的邮件。
- 解决:
-
- 抽离法:专门搞一个不进 Cluster 的独立进程只跑定时任务。
- Redis 锁:任务执行前先去 Redis 抢锁,抢到的那个进程才执行。
⚠️ 坑三:WebSocket (Socket.io) 连不上
- 场景 :用户 A 的 WebSocket 连接到了进程 1。你想给用户 A 发消息,但你的发送指令可能发到了进程 2。进程 2 根本找不到用户 A 的连接,导致消息丢失。
- 解决 :必须配置 Redis IoAdapter。Redis 会充当中间人,负责在不同的进程之间广播消息。
好了,接下来就说一下解决办法
import { once } from 'events';
import build from 'pino-abstract-transport';
import SonicBoom from 'sonic-boom';
export default async function (opts: { destination?: string | number }) {
const destination = new SonicBoom({
dest: opts.destination || 1,
sync: false,
});
await once(destination, 'ready');
return build(
async function (source) {
for await (const obj of source) {
const wrapped = { message: obj };
const toDrain = !destination.write(JSON.stringify(wrapped) + '\n');
if (toDrain) {
await once(destination, 'drain');
}
}
},
{
async close() {
destination.end();
await once(destination, 'close');
},
},
);
}
这段代码是为 Node.js 的 Pino 日志库 定义的一个自定义传输器(Custom Transport)。
简单来说,它的主要作用是:拦截 Pino 生成的每一条日志,将其包裹在 **{ message: ... }**结构中,然后高性能地写入到输出端(通常是控制台或文件)。
以下是详细的解读:
1. 核心目的:为了日志采集格式
从注释来看,这段代码是为了解决日志聚合工具(如 ELK, CloudWatch 等)的兼容性问题。
如果不加这段代码:Pino 的日志通常是扁平的 JSON,例如:
-
JSON
{ "level": 30, "time": 1630000000, "msg": "User logged in", "pid": 123 }
加上这段代码后 :所有的日志字段会被塞进一个顶层的 message 字段中:
-
JSON
{
"message": {
"level": 30,
"time": 1630000000,
"msg": "User logged in",
"pid": 123
}
}
原因 :注释提到 "infrastructure log aggregation tools work" ,说明公司的日志采集系统可能只索引或优先搜索顶层的 message 字段。这通常是为了在没有使用 PM2 等工具托管应用时,让日志系统能更好地搜索到结构化数据。
2. 技术细节拆解
这个函数主要做了三件事:
A. 初始化高性能写入流 (SonicBoom)
const destination = new SonicBoom({
dest: opts.destination || 1, // 默认为 1 (即 stdout/控制台),或者指定文件路径
sync: false, // 异步写入,性能更高
});
await once(destination, 'ready'); // 等待流准备好
它使用了 sonic-boom,这是一个专门为 Pino 设计的极速写入库,比 Node.js 原生的 fs 或 console.log 快很多。
B. 转换与写入 (build + source 循环)
return build(async function (source) {
for await (const obj of source) {
const wrapped = { message: obj }; // 【关键步骤】包裹日志对象
const toDrain = !destination.write(JSON.stringify(wrapped) + '\n');
// ...
}
});
- 它利用
pino-abstract-transport构建传输器。 - 它遍历日志流 (
source)。 - Log Wrapping :将收到的
obj放入{ message: obj }。 - JSON 序列化:将包裹后的对象转为字符串并加上换行符写入。
C. 处理背压 (Backpressure)
if (toDrain) {
await once(destination, 'drain');
}
这是一个非常重要的性能保护机制。
- 如果日志产生速度 快于 写入速度,
destination.write会返回false(表示缓冲区满了)。 - 代码会暂停(
await),等待drain事件(缓冲区排空),然后再继续处理下一条日志。 - 这防止了在高并发日志场景下内存溢出(OOM)。
3. 总结
这段代码是在做一个"中间件"式的日志格式转换器。
- 输入:Pino 的标准 JSON 日志。
- 处理 :嵌套进
message属性,并处理写入流的背压。 - 输出:格式化后的 JSON 字符串,输出到标准输出 (stdout) 或文件。
ENDING~