1. 先搞明白 Nginx 日志为什么要切分
1.1 不切分时 access.log 一年能涨到多大
做过线上运维或者自己搭过服务器的人应该都见过这种场景:nginx/logs/access.log这个文件从第一天起就老老实实地待在原地,日积月累,几个月后动辄几个 GB。别以为几个 GB 没什么大不了,等哪天磁盘报警、df -h一看使用率 100% 的时候,你才意识到问题有多严重。
我实测过一个日均 PV 在 20 万左右的中型站点,access.log 一天能产生约 1.5GB 日志。如果放任不管,一个月就是 45GB,一年就是 500GB 以上。这还只是 access.log,如果再算上 error.log,磁盘压力会更大。更麻烦的是,日志文件越大,排查问题越痛苦——你想从 20GB 的文件里找出某一次请求的记录,grep一次可能要等上几分钟,而且大量无效 IO 会影响正在运行的业务。
日志切分的核心目的其实就两个:防止单个日志文件无限膨胀撑爆磁盘,以及让日志文件按时间维度组织,方便检索和归档。说直白点,就是把一个永远在增长的大文件,按天、按小时拆成一个个小文件。
1.2 不切分会引发哪些连锁问题
不切分日志,表面上看只是磁盘空间会被慢慢吃光,但实际上连锁反应比想象中多:
磁盘写满后 Nginx 的行为会变得不可控。当磁盘空间不足时,Nginx 可能无法写入新的日志条目,极端情况下会导致 worker 进程异常,甚至整个服务响应变慢。你排查半天以为是代码问题,最后发现是磁盘满了。
日志轮转(rotation)时机一旦错过,文件句柄就成了一个隐患。如果运维脚本直接删掉了正在被 Nginx 占用的日志文件,但 Nginx 还持有旧文件的文件描述符,磁盘空间并不会立刻释放。这个问题在 Linux 下特别典型:文件被
rm了,但进程还在写,df显示磁盘满,du却找不到大文件。日志检索效率急剧下降。线上排查问题的时候,时间窗口通常很明确,比如"下午 3 点 10 分到 3 点 20 分之间的一批请求异常"。如果日志是按天切分的,你只需要搜索当天的文件;如果是一个 30GB 的巨型文件,你连打开它都要小心翼翼。
所以,日志切分不是"优化建议",而是上线之前就应该做的基础设施配置。接下来我会结合自己做过的方案,把 logrotate、cronolog、自定义脚本这三种主流做法讲透,再附上实战配置和踩坑记录。
2. 三种切分方案的核心逻辑与选型
市面上流传的日志切分方案有很多,但归根结底只有三类:系统自带的 logrotate、第三方工具 cronolog、自己的 shell 脚本。三者的适用场景和复杂度完全不同,我梳理了一份对比表。
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| logrotate | 由 cron 定时调用,通过 rename 和信号通知 Nginx 重开日志 | 配置简单、支持压缩、保留周期管理 | 最小粒度通常是每天,小时级需要额外配置 | 绝大多数 Linux 服务器 |
| cronolog | 通过管道把日志输出交给 cronolog,由它按时间自动切分 | 支持分钟/小时级切分,无需依赖 cron | 多一层管道进程,偶发进程退出会导致日志丢失 | 需要细粒度切分的场景 |
| 自定义脚本 | 用 bash 或 Python 写逻辑:mv 日志、发送信号、清理旧文件 | 最灵活,可定制任意逻辑 | 维护成本高,边界情况多 | 有特殊需求的大型团队 |
2.1 logrotate:Linux 生态里的默认答案
logrotate 几乎是每个 Linux 发行版默认自带的一个日志管理工具。它本身不关心你的日志是谁产生的,它只负责"到点了就把旧日志挪走,通知对应进程重开日志"。对 Nginx 来说,这个流程长这样:
- logrotate 在每天固定时间(通常由 cron 触发)执行;
- 将当前
access.log重命名为access.log.1(或带日期的名字); - 通过
/usr/bin/kill -USR1 <nginx_master_pid>通知 Nginx 重新打开日志文件; - Nginx 收到 USR1 信号后,重新创建新的
access.log继续写入。
这中间最关键的是第 3 步。如果光把文件改名但不通知 Nginx,Nginx 仍然会往旧的 inode 里写数据,改名后的文件内容继续增长,但新的access.log却一直不存在。
2.2 cronolog:小时级甚至分钟级切分的备选
如果你对切分粒度有更高要求——比如需要切到小时甚至分钟级,logrotate 默认配置下并不方便,因为它是靠 cron 定期执行的,最小周期受限于 cron 的调度。这时候 cronolog 就派上用场了。
cronolog 的工作方式是在 Nginx 配置里把日志输出到一个管道:
access_log /usr/local/nginx/logs/access.%Y-%m-%d_%H.log cronolog;cronolog 会持续读取管道中的数据,按时间模板自动创建新文件。这种方式的好处是切分时机精确,缺点是系统里多了一个常驻管道进程,如果 cronolog 崩溃,日志会直接丢失且 Nginx 端不一定知道。
在生产环境里我更推荐用 logrotate 做按天切分,除非业务对小时级日志有硬性要求,否则没必要引入 cronolog 这种额外复杂度。
2.3 自定义脚本:灵活但要自己兜底
还有些团队会选择写 shell 脚本或 Python 脚本来做切分。简单版本的逻辑就是:
#!/bin/bash # 切分昨天的日志 YESTERDAY=$(date -d "yesterday" +%Y-%m-%d) LOG_DIR=/usr/local/nginx/logs mv ${LOG_DIR}/access.log ${LOG_DIR}/access_${YESTERDAY}.log kill -USR1 $(cat ${LOG_DIR}/nginx.pid) # 删除 30 天之前的日志 find ${LOG_DIR} -name "access_*.log" -mtime +30 -delete gzip ${LOG_DIR}/access_${YESTERDAY}.log脚本本身不难,难在边界处理:如果mv成功了但kill失败怎么办?如果昨天刚好没有产生日志,文件不存在怎么办?如果脚本被 cron 重复执行,会不会重复切分?每一条都需要自己兜底。相比之下,logrotate 这些坑基本都替你踩过了,所以我个人建议:没有特殊需求就老老实实用 logrotate。
3. logrotate 实战配置:一条条拆开讲清楚
3.1 一个可直接落地的完整配置
我用过的 production 配置大概是这样的。先创建日志切分规则文件:
/etc/logrotate.d/nginx内容如下:
/usr/local/nginx/logs/*.log { daily rotate 30 compress delaycompress missingok notifempty create 0644 nginx nginx sharedscripts postrotate if [ -f /usr/local/nginx/logs/nginx.pid ]; then kill -USR1 $(cat /usr/local/nginx/logs/nginx.pid) fi endscript }这里面的每一项都值得仔细讲一下。
daily:按天切分,也就是每天执行一次。如果你需要每周切一次,就改成weekly;如果你想每小时切一次,logrotate 的标准配置做不到,需要结合 crontab 调整执行频率和dateext的格式。实际生产中最常用的是daily,因为日志按天归档最符合日常排查习惯。rotate 30:保留最近 30 个切分出来的日志文件,超过 30 个后自动删除最老的。对于按天切分,30 意味着日志保留一个月。如果你的机器磁盘很紧张,可以调到 7 甚至 5。compress:对切分出来的旧日志进行 gzip 压缩,生成.gz文件。日志文本的压缩率通常在 80% 以上,也就是说 1GB 的日志压缩完可能只有 100~150MB,效果非常明显。delaycompress:这个选项要和compress配合使用。它表示"这次切分出来的第一个旧文件先不压缩,等下次切分时再一起压缩"。为什么要这样设计?因为 Nginx 在收到 USR1 信号后,可能还有一些请求正在写入旧的日志缓冲,立即压缩可能导致日志内容丢失。延迟一天压缩就安全得多。missingok:如果日志文件不存在,不要报错。实际场景中error.log可以被关掉或者改过名字,这个选项能让 logrotate 保持安静。notifempty:如果日志文件是空的,就不执行切分。避免产生大量 0 字节的压缩包。create 0644 nginx nginx:切分后创建新的日志文件,并设置权限为 0644,属主和属组为 nginx。如果不加这个选项,新的access.log会以当前执行用户(通常是 root)创建,容易出现权限错乱。sharedscripts:当/usr/local/nginx/logs/*.log匹配到多个文件时,postrotate脚本只执行一次。如果不加,每个匹配到的文件都会执行一次kill -USR1,Nginx 会收到一堆重复信号,虽然一般不会出乱子,但没必要。
注意:
create的用户和组一定要和 Nginx worker 进程的运行用户保持一致。如果 Nginx 以 nobody 运行而你创建成了 root 属主,后续 Nginx 写日志时会遇到 permission denied,导致 access.log 里没有任何新记录,排查起来特别容易懵。
3.2 postrotate 里为什么是 USR1 而不是其他信号
kill -USR1是 Nginx 官方文档和所有主流实践都推荐的方式。USR1是 Nginx 定义的一个自定义信号,收到后主进程会重开所有日志文件(包括 access.log 和 error.log)。关键在于这个过程是平滑的,不会中断正在处理的请求。
你也可以使用USR2做平滑升级可执行文件,用WINCH做优雅关闭 worker 进程,这几个信号各有分工。在 logrotate 场景里只用USR1。
很多新手会问:"我直接nginx -s reopen行不行?"答案是可以,但要注意nginx -s reopen是向主进程发送了同样的 USR1 信号。不过它在执行时需要读取 nginx.conf 配置文件,如果你在postrotate里用于nginx命令时恰好 Nginx 配置有问题,可能导致 reopen 失败。而直接用kill -USR1 $(cat nginx.pid)绕过了配置加载,更稳定一些。两种方式在实际中都有大量使用者,我个人的偏好是用后者。
3.3 手动验证切分是否生效
配置写完后不能就这么干等着明天看结果。我习惯立刻手动执行一次,确认配置没问题:
logrotate -vf /etc/logrotate.d/nginx-v是 verbose,打印每一步操作;-f是 force,强制切分,哪怕还没到周期。
执行完以后,检查三件事:
ls /usr/local/nginx/logs/里应该能看到access.log.1或者带日期的旧文件;cat /usr/local/nginx/logs/access.log | wc -l能看到新的 access.log 已经创建,并且有新请求写入;tail -f /usr/local/nginx/logs/access.log确认 Nginx 还在正常写入新文件。
如果你的旧日志没有立即变成.gz,是因为delaycompress在起作用,这很正常,别误以为配置失效了。
4. 进阶玩法:小时级切分、压缩策略、JSON 日志格式
4.1 如何做到按小时切分
logrotate 默认由 cron 每天调度,如果你需要按小时切分,需要让 cron 每小时执行一次 logrotate,并且让 logrotate 对同一个日志文件每小时做一次重命名。
第一步,把 logrotate 配置里的daily换成一个自定义的hourly选项是行不通的,logrotate 没有 hourly 这个指令。但你可以让 cron 在每小时的第 5 分钟手动运行一次 logrotate:
# crontab -e 5 * * * * /usr/sbin/logrotate -v /etc/logrotate.d/nginx_hourly第二步,在配置里用dateext加上dateformat,让切分出来的文件名带上小时信息:
/usr/local/nginx/logs/access.log { dateext dateformat -%Y-%m-%d_%H rotate 24 compress notifempty missingok sharedscripts postrotate if [ -f /usr/local/nginx/logs/nginx.pid ]; then kill -USR1 $(cat /usr/local/nginx/logs/nginx.pid) fi endscript }这样切分出来的文件就是access.log-2024-06-01_14.gz这种格式。rotate 24表示保留最近 24 个小时的日志,也就是一天的量。按小时切分后,日志从"一天一个文件"变成"一小时一个文件",虽然文件数量变多,但单个文件体积和检索范围都大幅缩小,适合流量特别大的站点。
4.2 切分后的压缩与保留周期调优
压缩不是越激进越好。虽然gzip之后日志体积能缩小很多,但压缩和解压都会消耗 CPU。对于 Nginx 所在的高并发生产服务器来说,如果每天日志量达到几十 GB,压缩过程可能会导致 CPU 短暂飙高。
我的建议是配合好机房带宽和磁盘成本来判断:
- 如果日志量一天小于 1GB,直接开启压缩,完全不用担心 CPU;
- 如果一天有 10GB 以上,建议用
delaycompress+compress,因为延迟压缩能把最热的那部分日志保留 24 小时,让监控系统可以直接读取; - 如果真的很在意性能,可以考虑给 Nginx 加一层 JSON 结构化日志输出,压缩交给下游的日志采集系统(ELK/Loki)去做,Nginx 本身只负责切分不负责压缩。
保留周期的调整也是一样道理。rotate 30不是一成不变的参数,如果你们有安全合规要求,日志至少要保留 180 天,那就设成rotate 180,并且要提前估算磁盘空间。
4.3 切分与 JSON 日志格式怎么配合
现在很多团队会把 Nginx access 日志改成 JSON 格式,方便采集到 ELK 或者 Loki 里,避免复杂的log_format解析。
一个典型的 JSON 日志格式是这样写的:
log_format json_combined escape=json '{' '"time_local":"$time_local",' '"remote_addr":"$remote_addr",' '"request_method":"$request_method",' '"request_uri":"$request_uri",' '"status":$status,' '"body_bytes_sent":$body_bytes_sent,' '"request_time":$request_time,' '"http_user_agent":"$http_user_agent",' '"http_referer":"$http_referer"' '}';然后在 server 块里指定:
access_log /usr/local/nginx/logs/access.log json_combined;切分逻辑对 JSON 日志完全适用,logrotate 不关心文件内容格式,它只负责移动和重命名文件。关键点在于 JSON 日志每条记录都自动带了time_local字段,切分后做时间范围检索时,你既可以利用文件名缩小范围,也可以根据 JSON 字段精确过滤。
用 JSON 格式后,文件体积会比普通格式大 20% 左右,因为多了大括号、引号和字段名。但压缩收益也更明显。所以如果你开了 JSON 日志,建议一定要开启compress,否则磁盘消耗会比较难看。
5. Docker 环境里的 Nginx 日志切分:思路要变一下
5.1 Docker 部署 Nginx 时日志落在哪
今年用 Docker 部署 Nginx 的人太多了。很多新手在容器里折腾日志切分,走了不少弯路,我在这里帮你直说:不要在容器内部直接搞 logrotate。
原因是容器通常只有一个 Nginx 主进程,看似是完整系统,但容器里默认没有 cron、没有 logrotate,而且容器内的 PID 1 通常被 Nginx 占用,信号处理和文件重命名都变得不自然。官方nginx镜像里确实内置了 logrotate 配置,但那个配置是针对默认的日志路径,并且没有 cron daemon 帮你定时触发。
所以实际生产里一般有两套做法:
- 把日志文件挂载到宿主机,由宿主机上的 logrotate 来处理。这是最主流的方式。Docker 启动时加上:
docker run -d -p 80:80 \ -v /var/log/nginx:/var/log/nginx \ nginx:1.21.5宿主机上的/var/log/nginx映射进容器相同的路径。然后在宿主机配置 logrotate:
/var/log/nginx/*.log { daily rotate 30 compress delaycompress sharedscripts postrotate # 进入容器向 Nginx 主进程发送 USR1 信号 docker exec <container_name> kill -USR1 1 endscript }这里有个细节:容器内 PID 1 是 Nginx 主进程,所以kill -USR1 1就是向 Nginx 主进程发信号。有些镜像的入口不是直接 Nginx,而是 shell 脚本,这样 PID 1 就不是 Nginx 了,需要先从ps或cat /var/run/nginx.pid拿到实际 PID,稳妥的话在 postrotate 里执行:
docker exec <container_name> /bin/sh -c 'kill -USR1 $(cat /var/run/nginx.pid)'- 使用 Docker 的 json-file 日志驱动。如果你没有刻意把 Nginx 日志写到文件,而是让 Nginx 把日志输出到 stdout/stderr,那 Docker 会把日志收集到
/var/lib/docker/containers/<container_id>/<container_id>-json.log。对这个文件的切分,可以用宿主机 logrotate,也可以用 Docker 自带的 log rotation 参数:
docker run -d --log-opt max-size=100m --log-opt max-file=10 nginx:1.21.5max-size=100m表示单个日志超过 100MB 就切分,max-file=10表示最多保留 10 个文件。Docker 会自动重命名并开启新的 json 日志文件,不涉及 Nginx 信号,对无状态容器来说很省心。
5.2 容器内 Nginx 日志路径的映射细节
在 Docker 部署 Nginx 时,日志路径的坑主要体现在权限上。宿主机挂载目录默认属主是 root,容器里的 Nginx worker 进程通常以nginx用户运行。如果挂载的目录权限是 755,且目录属主是 root,Nginx 会尝试在该目录里创建新的日志文件,这时候就可能遇到 permission denied。
解决方案一般有三种:
- 宿主机目录属主改成 nginx 对应的 UID,官方镜像里 nginx 用户的 UID 通常是 101。你可以:
chown -R 101:101 /var/log/nginx- 挂载单文件而不是目录:
docker run -d -v /var/log/nginx/access.log:/var/log/nginx/access.log nginx这样 Nginx 是在一个已经存在的文件上追加写入,不涉及创建新文件,权限问题会少一些。但 Nginx 重开日志时需要创建新文件,所以宿主机目录最好还是给够写权限。
- 用
--user指定容器内 Nginx 进程的运行 UID,让它和宿主机挂载目录的属主一致,不过这个会改动 Nginx master 的权限,不太推荐,容易产生意想不到的问题。
5.3 挂载模式下最隐蔽的坑:日志被宿主机直接删除
很多人知道要在宿主机上用 logrotate 切分容器日志,但实际操作时发现:logrotate 执行了 rename,可容器里的 Nginx 完全没有感知,日志继续往旧的 inode 写入。这是因为postrotate里没有接上信号通知这一环。
如果你用的是docker exec <container> kill -USR1 1,注意容器内不一定有kill命令。官方 nginx 镜像基于 Debian,自带/bin/kill或 shell 内建 kill;但如果你用的是 alpine 版本的镜像,做法也要确认一下busybox的 kill 是支持的。我遇到过一次用 alpine 镜像时kill -USR1 1报错 "kill: invalid signal specification: 'USR1'",实际上是 busybox 的 kill 需要用数字表示,这个时候可以写:
docker exec <container> /bin/kill -USR1 1或者直接在宿主机上用kill -USR1 <容器内 Nginx 对应的宿主 PID>,但这个查找过程比较绕,不推荐。
6. 踩坑实录:我遇到过的三类日志切分故障
6.1 切分之后 access.log 变成 0 字节,新日志没有写入
这是一个非常典型的问题。现象是 logrotate 执行完成后,新的access.log出现了,但它是 0 字节,之后再也没有新的请求日志写入。
排查链路如下:
- 先确认 Nginx 进程是否正常运行:
ps -ef | grep nginx如果 master 和 worker 都在,排除了 Nginx 挂掉的可能。
- 看 error.log 里有没有权限相关报错。我遇到的情况是 error.log 里刷了类似这样的内容:
open() "/usr/local/nginx/logs/access.log" failed (13: Permission denied)原因就是create创建的新文件属主是 root,而 Nginx worker 运行在 nobody 用户下,没有写权限。解决办法就是给create指定正确的属主:
create 0644 nobody nobody- 还有一个隐蔽原因:logrotate 的
copytruncate选项。如果你的配置里用了copytruncate而不是create + USR1,它把当前日志先拷贝一份再清空原文件。这样 Nginx 不需要重开日志文件,但copytruncate在文件写入量非常大的时候,拷贝和清空之间存在时间窗口,可能会丢一部分日志,而且对 Nginx 这种每毫秒都有响应的服务,短时间的文件切换还是有一点点影响。能不用copytruncate就不用。
6.2 Nginx 迟迟没有重开文件句柄,旧文件还在涨
这个问题通常出在 postrotate 脚本没有生效。排查分两步:
- 手动执行 logrotate 的 verbose 模式,看输出里是否真的执行了 postrotate:
logrotate -vf /etc/logrotate.d/nginx- 如果输出显示 postrotate 被跳过了,检查你的配置里是不是漏了
sharedscripts。在 glob 匹配多个日志文件的情况下,没有sharedscripts的话,脚本会对每个文件执行一次;但某些旧版本 logrotate 在missingok生效时可能会直接跳过postrotate。加了sharedscripts后,所有日志文件处理完,统一执行一次信号通知,这是最稳妥的组合方式。
还有一个常见的坑:nginx.pid路径不对。很多人写死/usr/local/nginx/logs/nginx.pid,但实际 Nginx 的 pid 路径在编译时可能改过,或者配置文件里用pid /run/nginx.pid;指定了别的路径。信号发错了对象,Nginx 自然不理会。稳妥的做法是先用命令确认:
nginx -t 2>&1 | grep pid或者直接在 nginx.conf 里找pid指令。
6.3 logrotate 执行时间到了,但日志文件完全没有变化
这种问题基本可以断定 logrotate 规则没加载,或者 cron 任务没执行。排查步骤:
- 确认规则文件权限和命名,logrotate 只加载
/etc/logrotate.d/下的文件,文件名无所谓,但是内容格式必须正确。检查语法:
logrotate -d /etc/logrotate.d/nginx-d是 debug 模式,不会真正执行,只打印它会做的事。如果这里报错,说明配置语法有问题。
- 确认系统 cron 服务在运行:
systemctl status cron很多精简安装的系统默认没开 cron,logrotate 就永远不可能自动执行。
- 手动跑一次看看:
logrotate -f /etc/logrotate.d/nginx如果手动有效但自动没反应,问题几乎一定出在 cron 服务这条链路上。注意看系统日志/var/log/syslog或/var/log/cron里有没有 logrotate 的调用记录。
我自己的习惯是:每次改完 logrotate 配置后,先手动-d调试一次,再-vf强制跑一次,最后再用tail确认日志真的在写。这一套流程走完,80% 的切分故障都能提前发现。
7. 关于日志切分,我再补几点个人体会
日志切分听起来是个小配置,但它属于"平时没人关心、出事全是事"的典型场景。踩过几次坑之后,我总结了几条经验,分享给你。
第一,日志切分方案一定要在部署 Nginx 当天就落实,不要觉得磁盘还够就拖着。等 access.log 涨到几十 GB 再回头切分,不仅切分过程耗时,还容易把磁盘 IO 打满,影响线上业务。
第二,切分和采集要一起设计。如果你们有 ELK 或 Loki,切分后的文件名时间戳、压缩格式最好和采集配置对齐,否则日志采集器可能读不到.gz文件,或者因为文件名格式不匹配跳过某些时间段。我建议日志文件名里统一带上日期,例如access.log-2024-06-01.gz,方便采集器按正则匹配。
第三,不要忽视 error.log。很多人只切 access.log,error.log 就扔在一边。其实 error.log 的排查价值更高,而且在高并发下也可能变得非常大。建议在 logrotate 规则里用*.log一把梭覆盖所有日志文件。
第四,在大流量场景下,切分瞬间会有短暂的性能抖动。rename 和 USR1 信号本身开销不大,但如果磁盘 IO 已经很紧张,rename 大文件时也会引起瞬时 IO 波动。如果你的服务对延迟极度敏感,可以把 logrotate 的执行时间设定在业务低谷期,比如凌晨 4 点,尽量避开晚高峰。
最后再分享一个小技巧:切分后的日志可以先不急着永久保存。我遇到过业务方说"要查三个月前的日志",结果发现 logrotate 只保留了 30 天。后来我们就在 logrotate 之外加了一层异地冷备脚本:每天把前一天切分出的压缩日志同步到对象存储或备份服务器。这样既不影响本地磁盘占用,又能保证需要的时候有历史数据可查。这个扩展方向对日志审计严格、或者希望控制存储成本的场景都很有用。