服务器磁盘告警,登上去 df -h 一看根分区 100%,服务已经因为写不进文件挂了。一层层 du -sh 找过去,最后定位到应用目录下的 app-error.log,单个文件几个 G。这个文件从部署那天起就没被清理过,一直在往后追加。
这篇把 PM2 的两件事写清楚。一件是用 ecosystem.config.js 管理进程配置,替代那种在命令行敲一长串 pm2 start 参数的做法;另一件是用 pm2-logrotate 做日志切割,让日志按大小和时间自动滚动、自动淘汰旧文件。最后把这一整套塞进 Dockerfile,Next.js standalone 产物加 PM2 cluster 模式,容器起来就是多实例带日志管理的状态。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 为什么要用 ecosystem 配置文件,而不是在命令行传参数
ecosystem.config.js每个字段的作用与取值建议- cluster 模式下实例数怎么定,日志文件为什么会带编号
- 日志撑爆磁盘的真正原因,以及
pm2 flush为什么只能救急 - pm2-logrotate 的安装、参数含义与推荐配置
- 把 PM2 塞进 Docker 镜像的完整 Dockerfile 与注意事项
pm2-runtime和pm2 start的区别,为什么容器里必须用前者- 部署后怎么验证日志确实在按规则切割
# 一、先说为什么要用 ecosystem 文件
很多人第一次用 PM2 都是这么启动的。
pm2 start server.js --name my-app -i 2 --output ./logs/out.log --error ./logs/err.log
能跑,但有几个问题。参数写在命令行里,没进版本控制,换台机器部署得靠翻历史命令;实例数、日志路径这些东西改一次就要把整条命令重敲一遍;团队里两个人敲的参数不一样,线上跑的配置和你以为的不是一回事。
ecosystem 文件解决的就是这个。它是一份普通的 JS 模块,跟着代码一起进仓库,谁部署都是同一份配置。
// 在nodejs/nextjs项目根目录创建 ecosystem.config.js
module.exports = {
apps: [
{
name: 'my-app-name',
script: './server.js', // 服务启动脚本
watch: false, // 禁用文件监视,提高性能
instances: 2, // 指定要启动的实例数量 // 0 和 max 同义
exec_mode: 'cluster', // 启用集群模式,指定要启动的实例数量
// 启用文件日志记录
output: './logs/app-out.log', // 标准输出日志文件
error: './logs/app-error.log', // 错误日志文件
// log: './logs/combined.log', // 合并日志文件(可选)
// 日志配置
log_date_format: 'YYYY-MM-DD HH:mm Z',
merge_logs: false, // 为每个实例单独日志
time: true, // 在日志中添加时间戳
env: {}
}
]
}
启动就是一行。
pm2-runtime ecosystem.config.js
apps 是数组,一个文件里可以描述多个应用。做微服务或者一台机器上跑好几个 Node 服务的时候,一份 ecosystem 就能把它们全管起来,pm2 start ecosystem.config.js --only my-app-name 还能单独启其中一个。
# 二、ecosystem 每个字段在做什么
上面那份配置看着简单,几个字段背后的行为差别不小,逐个说一下。
script 指向启动入口。Next.js 用 standalone 输出模式的时候,构建产物里会生成一个 server.js,路径就是它。这一点后面 Dockerfile 那节还会再提,因为 COPY --from=builder /app/.next/standalone ./ 之后 server.js 正好落在工作目录根下,和这里的 ./server.js 对得上。
watch: false 是生产环境必须的。开着 watch,PM2 会监听文件变化自动重启,本地开发方便,线上就是灾难,一次误操作 touch 了某个文件,整个服务重启。而且监听本身在文件多的项目里会持续占 CPU。
instances 和 exec_mode 是一对。exec_mode: 'cluster' 让 PM2 用 Node 的 cluster 模块起多个进程,共享同一个端口,请求由主进程分发。instances: 2 就是起两个。写 0 或者 'max' 表示按 CPU 核心数来,写 -1 表示核心数减一,留一个核给系统。
那实例数到底该写几。我的经验是先看容器分到多少 CPU。如果是 Docker 里跑并且限了 --cpus=1,起再多实例也没用,反而每个进程都要单独占一份内存,Next.js 一个实例常驻内存两三百兆,起四个就快 1G 了。写死 2 而不是 max,很大程度上就是为了防止容器里 os.cpus() 读到的是宿主机核心数,一下起了十几个进程把内存打满。
这个坑我踩过,宿主机 16 核,容器限了 1 核,instances: 'max' 直接起了 16 个进程,OOM 被 kill 掉,PM2 又自动拉起来,反复循环。
output 和 error 分别是标准输出和错误输出的落盘路径。PM2 的配置 schema 里这两个是 out_file 和 error_file 的别名,两种写法都认,看老项目配置的时候别被这两组名字搞混。注释掉的 log 字段是合并日志,开了它 out 和 error 会写到同一个文件里,排查时序问题方便,但 error 会被淹没在正常日志里,我一般不开。
merge_logs: false 这条决定了日志文件名长什么样。cluster 模式下每个实例都在写日志,如果合并成一个文件,多进程同时追加会出现内容交错。设成 false 之后 PM2 会给每个实例的日志文件加上实例编号,也就是后面会看到的 app-out-1.log 和 app-out-2.log。排查问题时能直接定位是哪个实例出的错,这个设计是真的舒服。
time: true 和 log_date_format 是一对。前者开启时间戳前缀,后者定义格式。'YYYY-MM-DD HH:mm Z' 里的 Z 是时区偏移,服务器和容器时区不一致的时候,这个字段能救你一命,不然你看到的日志时间和用户反馈的时间对不上,白排查半天。
env: {} 是注入给进程的环境变量。空对象表示不额外注入,走容器或者宿主机的环境。真要用的话可以写成 env: { NODE_ENV: 'production', PORT: 3000 },另外 PM2 还支持 env_production 这种带后缀的写法,配合 --env production 切换。
# 三、日志为什么会把磁盘撑爆
回到开头那个磁盘满的问题。
PM2 默认的日志行为是纯追加,写满了不会停,也不会自己删。只要进程一直在跑,日志文件就一直在长。Next.js 这类应用每个请求可能都会打几行日志,一天几十万请求,几个 G 是很正常的量。
更麻烦的是这个增长完全没有反馈。磁盘从 60% 涨到 90% 的过程中没有任何人会注意到,直到写不进去,服务开始报 ENOSPC,才有人上去看。
有人会说手动清一下不就行了。
$ pm2 flush
[PM2] Flushing /root/.pm2/pm2.log
[PM2] Flushing:
[PM2] /root/.pm2/logs/pm2-logrotate-out.log
[PM2] /root/.pm2/logs/pm2-logrotate-error.log
[PM2] Flushing:
[PM2] /app/logs/app-out-1.log
[PM2] /app/logs/app-error-1.log
[PM2] Flushing:
[PM2] /app/logs/app-out-2.log
[PM2] /app/logs/app-error-2.log
[PM2] Logs flushed
pm2 flush 会把所有日志文件清空。它确实能立刻腾出空间,但这是救急手段,不是方案。一是它清得太干净,出问题时你想回看昨天的日志已经没了;二是它得有人记得去执行,靠人记事情就等于没有保障。
真正要做的是让日志自己滚动。
# 四、用 pm2-logrotate 做自动切割
PM2 有个官方模块叫 pm2-logrotate,专门干这件事。它会起一个常驻的工作进程,定时检查日志文件大小,超过阈值就把当前文件改名归档、重新开一个空文件写,同时按保留数量删掉最老的归档。
安装是一行命令。
pm2 install pm2-logrotate
注意这里是 pm2 install 不是 npm install。PM2 的模块系统和 npm 包不是一回事,装完之后模块本体在 ~/.pm2/module_conf.json 有一份配置记录,pm2 list 里也能看到它作为一个进程在跑。
装完先看看默认配置。
$ pm2 conf pm2-logrotate
Module: pm2-logrotate
$ pm2 set pm2-logrotate:max_size 10M
$ pm2 set pm2-logrotate:retain 50
$ pm2 set pm2-logrotate:compress false
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
$ pm2 set pm2-logrotate:workerInterval 1
$ pm2 set pm2-logrotate:rotateInterval 0 0 * * *
$ pm2 set pm2-logrotate:rotateModule true
Module: module-db-v2
$ pm2 set module-db-v2:pm2-logrotate [object Object]
pm2 conf 的输出很贴心,它直接把每一项打印成可以复制执行的 pm2 set 命令,想改哪项就复制哪行改个值。
各参数的含义整理一下。
| 配置项 | 简介 |
|---|---|
| Compress | 是否通过gzip压缩日志 |
| max_size | 单个日志文件的大小,比如上图中设置为1K(这个其实太小了,实际文件大小并不会严格分为1K) |
| retain | 保留的日志文件个数,比如设置为10,那么在日志文件达到10个后会将最早的日志文件删除掉 |
| dateFormat | 日志文件名中的日期格式,默认是YYYY-MM-DD_HH-mm-ss,注意是设置的日志名+这个格式,如设置的日志名为abc.log,那就会生成abc_YYYY-MM-DD_HH-mm-ss.log名字的日志文件 |
| rotateModule | 把pm2本身的日志也进行分割 |
| workerInterval | 设置启动几个工作进程监控日志尺寸,最小为1 |
| rotateInterval | 设置强制分割,默认值是0 0 * * *,意思是每天晚上0点分割,这个足够了个人觉得 |
有两个参数容易理解错,单独说一下。