Nginx 日志切割完全指南:从原理到生产实战
一、为什么需要日志切割?
Nginx 作为最流行的 Web 服务器/反向代理之一,每天都在产生大量的access.log和error.log。如果不做切割,一个单文件日志可能会膨胀到几十 GB 甚至上百 GB,带来以下问题:
| 问题 | 影响 |
|---|---|
| 磁盘空间耗尽 | 日志文件过大导致服务器磁盘写满,服务不可用 |
| 查询效率低下 | 在几 GB 的日志中grep一条记录,耗时可能超过分钟级 |
| 日志分析困难 | 无法按天/周做访问趋势分析和报表 |
| 文件句柄风险 | 某些工具处理超大文件时可能触发内存溢出 |
最佳实践:按天切割日志,配合压缩保留 30~90 天的历史记录。
二、核心原理
Nginx 本身不支持按时间自动切分日志。它的日志写入流程是:
Nginx Master 进程启动 ↓ 打开日志文件(获取文件描述符 fd) ↓ 所有 Worker 进程通过 fd 写入日志 ↓ 日志文件持续增长...如果直接用mv重命名日志文件,Nginx 仍然持有旧的文件描述符,会继续往重命名后的文件写入。所以关键一步是通知 Nginx 重新打开日志文件:
# 方法一:发送 USR1 信号(最常用) kill -USR1 $(cat /usr/local/nginx/logs/nginx.pid) # 方法二:使用 nginx 命令 nginx -s reopenUSR1信号会让 Nginx Master 进程通知所有 Worker 进程重新打开日志文件。完整流程:
mv access.log access_20260416.log # 重命名旧日志 kill -USR1 <pid> # 通知 Nginx 重新打开 # Nginx 自动创建新的 access.log,继续写入⚠️千万不要直接删除日志文件!必须先
mv再发信号。直接rm会导致 Nginx 丢失文件描述符,磁盘空间不会被释放(直到进程重启)。
三、三种切割方案对比
| 方案 | 推荐度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| logrotate | ⭐⭐⭐⭐⭐ | 系统自带、配置简单、功能完善 | 灵活性稍弱 | 生产环境首选 |
| cron + Shell 脚本 | ⭐⭐⭐⭐ | 完全可控、可定制复杂逻辑 | 需自己维护脚本 | 有特殊需求时 |
| Nginx 内置变量 | ⭐⭐ | 无需外部工具 | 高并发下性能差 | 仅学习/测试用 |
下面逐一详解。
四、方案一:logrotate(推荐)
4.1 什么是 logrotate?
logrotate是 Linux 系统自带的日志轮转工具,由 cron 每天定时调用。大多数发行版安装 Nginx 后会自动生成配置文件。
4.2 检查现有配置
# 查看 Nginx 的 logrotate 配置 cat /etc/logrotate.d/nginx # 查看 logrotate 是否有 cron 调度 ls -l /etc/cron.daily/logrotate cat /etc/cron.daily/logrotate4.3 完整配置示例
/usr/local/nginx/logs/*.log { daily # 每天轮转一次 missingok # 日志文件不存在时不报错 rotate 30 # 保留最近 30 个轮转文件 dateext # 使用日期作为后缀(如 access.log-20260416) dateformat -%Y%m%d # 自定义日期格式 compress # 压缩旧日志(gzip) delaycompress # 延迟压缩:保留最近一个未压缩,方便排查 notifempty # 日志为空时不轮转 create 0640 nginx adm # 轮转后创建新文件,权限 0640,属主 nginx,属组 adm sharedscripts # 所有日志只执行一次 postrotate 脚本 postrotate # 读取 pid 文件并向 Nginx 发送 USR1 信号 [ -f /usr/local/nginx/logs/nginx.pid ] && kill -USR1 `cat /usr/local/nginx/logs/nginx.pid` endscript }4.4 参数详解
| 参数 | 说明 |
|---|---|
daily | 每天轮转,也支持weekly、monthly、yearly |
rotate N | 保留 N 份旧日志,超出的自动删除 |
dateext | 用日期作为后缀替代.1、.2递增编号 |
compress/delaycompress | 压缩旧日志 / 延迟压缩(最近一份不压缩) |
notifempty | 空文件不轮转 |
missingok | 日志文件缺失时静默跳过 |
create MODE OWNER GROUP | 轮转后立即创建新日志文件 |
sharedscripts | 多个日志匹配时,只运行一次脚本 |
size N | 文件超过 N(如100M、1G)时也触发轮转 |
4.5 手动测试
# 调试模式:只打印将要执行的操作,不实际执行 sudo logrotate -d /etc/logrotate.d/nginx # 强制执行一次轮转(即使今天已经执行过) sudo logrotate -vf /etc/logrotate.d/nginx4.6 验证结果
ls -lh /usr/local/nginx/logs/输出示例:
-rw-r----- 1 nginx adm 12M Apr 17 00:00 access.log -rw-r----- 1 nginx adm 156M Apr 16 23:59 access.log-20260416 -rw-r----- 1 nginx adm 12M Apr 15 00:00 access.log-20260415.gz -rw-r----- 1 nginx adm 890K Apr 17 00:00 error.log -rw-r----- 1 nginx adm 2.1M Apr 16 23:59 error.log-202604164.7 排查 logrotate 未生效
# 查看 logrotate 执行记录 cat /var/lib/logrotate/status | grep nginx # 手动执行并查看详细输出 sudo logrotate -vf /etc/logrotate.d/nginx 2>&1 # 常见原因 # 1. cron 服务未启动:systemctl status crond # 2. 配置文件语法错误:logrotate -d 检查 # 3. 日志文件名不匹配通配符 # 4. 状态文件中标记为 "already rotated this year"五、方案二:cron + Shell 脚本(完全可控)
5.1 基础版脚本
#!/bin/bash # ============================================ # Nginx 日志按天切割脚本(基础版) # 适用于:单 Nginx 实例,标准日志路径 # ============================================ LOGS_PATH="/usr/local/nginx/logs" PID_FILE="/usr/local/nginx/logs/nginx.pid" KEEP_DAYS=30 # 获取昨天的日期(兼容 macOS 和 Linux) if [[ "$OSTYPE" == "darwin"* ]]; then YESTERDAY=$(date -v-1d +%Y%m%d) else YESTERDAY=$(date -d "yesterday" +%Y%m%d) fi # 1. 移动旧日志 mv ${LOGS_PATH}/access.log ${LOGS_PATH}/access_${YESTERDAY}.log mv ${LOGS_PATH}/error.log ${LOGS_PATH}/error_${YESTERDAY}.log # 2. 通知 Nginx 重新打开日志 if [ -f "$PID_FILE" ]; then kill -USR1 $(cat "$PID_FILE") echo "[$(date)] 已发送 USR1 信号给 Nginx (PID: $(cat $PID_FILE))" else echo "[$(date)] 错误:找不到 PID 文件 $PID_FILE" exit 1 fi # 3. 压缩并清理过期日志 find ${LOGS_PATH} -name "access_*.log" -mtime +${KEEP_DAYS} -exec gzip {} \; find ${LOGS_PATH} -name "error_*.log" -mtime +${KEEP_DAYS} -exec gzip {} \; find ${LOGS_PATH} -name "*.gz" -mtime +$((KEEP_DAYS * 2)) -delete echo "[$(date)] 日志切割完成"5.2 增强版脚本(生产可用)
以下脚本增加了日志大小检测、多路径支持、告警通知等企业级特性:
#!/bin/bash # ============================================ # Nginx 日志切割脚本(增强版) # 功能:多日志路径、大小检测、自动压缩、 # 钉钉告警、执行日志记录 # ============================================ set -euo pipefail # ============ 配置区 ============ NGINX_PID_FILE="/usr/local/nginx/logs/nginx.pid" LOG_DIRS=( "/usr/local/nginx/logs" "/data/nginx_logs/api.example.com" "/data/nginx_logs/web.example.com" ) KEEP_DAYS=30 COMPRESS_AFTER_DAYS=3 # 超过 N 天的日志自动压缩 MIN_LOG_SIZE="10M" # 小于此大小的日志不切割(防止空转) LOG_DIR="/var/log/nginx-rotate" # 脚本自身执行日志 DINGTALK_WEBHOOK="" # 钉钉告警 Webhook(留空则不告警) # ================================ mkdir -p "$LOG_DIR" LOG_FILE="${LOG_DIR}/rotate_$(date +%Y%m%d).log" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" } alert() { local msg="$1" log "[ALERT] $msg" if [[ -n "$DINGTALK_WEBHOOK" ]]; then curl -s -X POST "$DINGTALK_WEBHOOK" \ -H 'Content-Type: application/json' \ -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"⚠️ Nginx日志切割告警: ${msg}\"}}" \ > /dev/null 2>&1 fi } # 检查 Nginx 是否运行 check_nginx() { if [[ ! -f "$NGINX_PID_FILE" ]]; then alert "Nginx PID 文件不存在: $NGINX_PID_FILE" return 1 fi local pid pid=$(cat "$NGINX_PID_FILE") if ! kill -0 "$pid" 2>/dev/null; then alert "Nginx 进程 (PID: $pid) 未运行" return 1 fi log "Nginx 运行正常 (PID: $pid)" } # 获取昨天日期 get_yesterday() { if [[ "$OSTYPE" == "darwin"* ]]; then date -v-1d +%Y%m%d else date -d "yesterday" +%Y%m%d fi } # 获取文件大小(人类可读转字节) size_to_bytes() { local size_str="$1" local num="${size_str%[KkMmGg]*}" local unit="${size_str##*[0-9]}" case "${unit^^}" in K) echo $((num * 1024)) ;; M) echo $((num * 1024 * 1024)) ;; G) echo $((num * 1024 * 1024 * 1024)) ;; *) echo "$num" ;; esac } # 切割单个日志目录 rotate_logs() { local log_dir="$1" local yesterday=$(get_yesterday) local min_bytes=$(size_to_bytes "$MIN_LOG_SIZE") local rotated=0 for log_file in "${log_dir}"/*.log; do [[ -f "$log_file" ]] || continue local file_size=$(stat -f%z "$log_file" 2>/dev/null || stat -c%s "$log_file" 2>/dev/null) if [[ "$file_size" -lt "$min_bytes" ]]; then log "跳过 ${log_file}(大小 ${file_size} 字节,小于阈值 ${MIN_LOG_SIZE})" continue fi local basename=$(basename "$log_file" .log) local target="${log_dir}/${basename}_${yesterday}.log" mv "$log_file" "$target" log "已切割: ${log_file} -> ${target} ($(numfmt --to=iec $file_size 2>/dev/null || echo ${file_size}B))" rotated=$((rotated + 1)) done echo "$rotated" } # 压缩过期日志 compress_old_logs() { local log_dir="$1" local count=0 for log_file in "${log_dir}"/*_[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].log; do [[ -f "$log_file" ]] || continue local file_age if [[ "$OSTYPE" == "darwin"* ]]; then file_age=$(( ($(date +%s) - $(stat -f%m "$log_file")) / 86400 )) else file_age=$(( ($(date +%s) - $(stat -c%Y "$log_file")) / 86400 )) fi if [[ "$file_age" -ge "$COMPRESS_AFTER_DAYS" ]]; then gzip -f "$log_file" count=$((count + 1)) fi done # 删除超过保留期的压缩文件 local deleted=0 for gz_file in "${log_dir}"/*.log.gz; do [[ -f "$gz_file" ]] || continue local file_age if [[ "$OSTYPE" == "darwin"* ]]; then file_age=$(( ($(date +%s) - $(stat -f%m "$gz_file")) / 86400 )) else file_age=$(( ($(date +%s) - $(stat -c%Y "$gz_file")) / 86400 )) fi if [[ "$file_age" -gt "$KEEP_DAYS" ]]; then rm -f "$gz_file" deleted=$((deleted + 1)) fi done log "目录 ${log_dir}: 压缩 ${count} 个文件, 删除 ${deleted} 个过期文件" } # ============ 主流程 ============ log "====== 开始日志切割 ======" check_nginx || exit 1 total_rotated=0 for dir in "${LOG_DIRS[@]}"; do if [[ ! -d "$dir" ]]; then log "目录不存在,跳过: $dir" continue fi local_count=$(rotate_logs "$dir") total_rotated=$((total_rotated + local_count)) compress_old_logs "$dir" done # 发送 USR1 信号 if [[ "$total_rotated" -gt 0 ]]; then kill -USR1 $(cat "$NGINX_PID_FILE") log "已发送 USR1 信号,共切割 ${total_rotated} 个日志文件" else log "没有日志需要切割,跳过信号发送" fi log "====== 日志切割完成 ======"5.3 配置 cron 定时任务
# 编辑 crontab crontab -e # 每天凌晨 00:05 执行日志切割 5 0 * * * /usr/local/bin/nginx-log-rotate.sh >> /var/log/nginx-rotate/cron.log 2>&15.4 带 logrotate 配置的 cron 方案
如果想在 cron 中实现更灵活的切割逻辑(如按大小切割),可以这样:
#!/bin/bash # 按大小+时间混合切割 # 当日志超过 500M 或距离上次切割超过 24 小时时执行 LOG_FILE="/usr/local/nginx/logs/access.log" PID_FILE="/usr/local/nginx/logs/nginx.pid" MAX_SIZE=524288000 # 500MB in bytes MIN_INTERVAL=86400 # 24 hours in seconds MARKER_FILE="/tmp/nginx-rotate-marker" rotate() { local ts=$(date +%Y%m%d_%H%M%S) mv "$LOG_FILE" "${LOG_FILE}.${ts}" gzip "${LOG_FILE}.${ts}" & kill -USR1 $(cat "$PID_FILE") date +%s > "$MARKER_FILE" echo "$(date) - 已切割日志 (${LOG_FILE} -> ${LOG_FILE}.${ts}.gz)" } # 检查文件大小 file_size=$(stat -f%z "$LOG_FILE" 2>/dev/null || stat -c%s "$LOG_FILE" 2>/dev/null) # 检查上次切割时间 last_rotate=0 [[ -f "$MARKER_FILE" ]] && last_rotate=$(cat "$MARKER_FILE") now=$(date +%s) elapsed=$((now - last_rotate)) if [[ "$file_size" -gt "$MAX_SIZE" ]] || [[ "$elapsed" -gt "$MIN_INTERVAL" ]]; then rotate else echo "$(date) - 日志无需切割 (大小: ${file_size}B, 上次切割: ${elapsed}s 前)" fi六、实战案例
📋 案例一:多域名 Vhost 日志切割
场景:一台服务器托管多个站点,每个站点有独立的 access/error 日志。
/usr/local/nginx/logs/ ├── nginx.pid ├── api.example.com_access.log ├── api.example.com_error.log ├── web.example.com_access.log ├── web.example.com_error.log ├── admin.example.com_access.log └── admin.example.com_error.loglogrotate 配置:
/usr/local/nginx/logs/*_access.log /usr/local/nginx/logs/*_error.log { daily missingok rotate 60 dateext dateformat -%Y%m%d compress delaycompress notifempty create 0640 nginx nginx sharedscripts postrotate [ -f /usr/local/nginx/logs/nginx.pid ] && kill -USR1 `cat /usr/local/nginx/logs/nginx.pid` endscript }切割后效果:
/usr/local/nginx/logs/ ├── api.example.com_access.log # 当天 ├── api.example.com_access.log-20260416 # 昨天(未压缩) ├── api.example.com_access.log-20260415.gz # 前天(已压缩) ├── api.example.com_error.log-20260416 ├── ...📋 案例二:日志按小时切割(高流量场景)
场景:日请求量过亿的 API 网关,日志增长极快,需要按小时切割。
#!/bin/bash # Nginx 日志按小时切割 # Cron: 0 * * * * (每小时执行一次) LOGS_PATH="/usr/local/nginx/logs" PID_FILE="${LOGS_PATH}/nginx.pid" KEEP_HOURS=168 # 保留 7 天 = 168 小时 HOUR=$(date -d '1 hour ago' '+%Y%m%d%H' 2>/dev/null || date -v-1H '+%Y%m%d%H') for log_file in ${LOGS_PATH}/*.log; do [[ -f "$log_file" ]] || continue base=$(basename "$log_file" .log) target="${LOGS_PATH}/${base}_${HOUR}.log" mv "$log_file" "$target" done # 通知 Nginx [ -f "$PID_FILE" ] && kill -USR1 $(cat "$PID_FILE") # 清理超过保留期的压缩日志 find "$LOGS_PATH" -name "*_[0-9]*.log.gz" -mmin +$((KEEP_HOURS * 60)) -delete # 压缩超过 1 小时的日志 find "$LOGS_PATH" -name "*_[0-9]*.log" -mmin +60 -exec gzip {} \;Cron 配置:
crontab -e # 每小时整点执行 0 * * * * /usr/local/bin/nginx-hourly-rotate.sh📋 案例三:Docker 环境下的日志切割
场景:Nginx 运行在 Docker 容器中,宿主机需要管理日志文件。
方案 A:宿主机 logrotate(推荐)
docker-compose.yml中挂载日志目录:
services: nginx: image: nginx:1.27 volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./logs:/var/log/nginx # 日志挂载到宿主机 ports: - "80:80"宿主机 logrotate 配置/etc/logrotate.d/docker-nginx:
/path/to/project/logs/*.log { daily missingok rotate 30 dateext compress delaycompress notifempty copytruncate # 关键!容器内无法接收宿主机的信号 create 0644 root root }注意:Docker 容器内无法直接接收宿主机的
kill -USR1,所以这里必须使用copytruncate。它的原理是先复制日志内容,再清空原文件。缺点是在高并发下可能丢失少量日志。
方案 B:进入容器发信号
#!/bin/bash # Docker 容器内日志切割 CONTAINER_NAME="nginx" LOG_DIR="/var/log/nginx" KEEP_DAYS=30 YESTERDAY=$(date -d "yesterday" +%Y%m%d) # 进入容器执行切割 docker exec $CONTAINER_NAME bash -c " cd $LOG_DIR && \ for f in *.log; do [ -f \"\$f\" ] && mv \"\$f\" \"\${f%.log}_${YESTERDAY}.log\" done && \ nginx -s reopen && \ find . -name '*_[0-9]*.log' -mtime +${KEEP_DAYS} -exec gzip {} \; && \ find . -name '*.gz' -mtime +$((KEEP_DAYS * 2)) -delete "📋 案例四:日志切割 + ELK 集成
场景:切割后的日志需要推送到 ELK(Elasticsearch + Logstash + Kibana)做集中分析。
#!/bin/bash # 日志切割 + 推送到 Logstash LOGS_PATH="/usr/local/nginx/logs" YESTERDAY=$(date -d "yesterday" +%Y%m%d) LOGSTASH_HOST="10.12.12.80:5044" # 1. 切割日志 for log_file in ${LOGS_PATH}/*_access.log; do [[ -f "$log_file" ]] || continue base=$(basename "$log_file" _access.log) target="${LOGS_PATH}/${base}_access_${YESTERDAY}.log" mv "$log_file" "$target" done kill -USR1 $(cat ${LOGS_PATH}/nginx.pid) # 2. 使用 nc 推送切割后的日志到 Logstash(Filebeat 更推荐) for rotated_log in ${LOGS_PATH}/*_access_${YESTERDAY}.log; do [[ -f "$rotated_log" ]] || continue log "推送日志: $rotated_log -> $LOGSTASH_HOST" # 生产环境建议使用 Filebeat 而非 nc # /usr/bin/filebeat -c /etc/filebeat/filebeat.yml -e & done # 3. 压缩旧日志 find ${LOGS_PATH} -name "*_access_*.log" -mtime +3 -exec gzip {} \;推荐使用Filebeat作为日志采集器,配置自动跟踪新文件:
# /etc/filebeat/filebeat.yml filebeat.inputs: - type: log enabled: true paths: - /usr/local/nginx/logs/*_access_*.log fields: log_type: nginx_access clean_inactive: 168h # 168 小时未更新的文件不再跟踪 ignore_older: 24h # 忽略超过 24 小时的旧文件(避免重复采集) output.logstash: hosts: ["10.12.12.80:5044"]七、方案三:Nginx 内置变量(不推荐生产使用)
通过$time_iso8601动态生成日志文件名:
http { map $time_iso8601 $logdate { ~^(?<ymd>\d{4}-\d{2}-\d{2}) $ymd; default 'nodate'; } # 每个请求都需要解析 map 变量并打开/写入对应文件 # 高并发时 I/O 压力巨大,不推荐! access_log /usr/local/nginx/logs/access-$logdate.log main; }为什么不推荐?
- 每个请求都要执行一次 map 正则匹配
- 每个 Worker 需要独立打开并维护当天日期的日志文件句柄
- 跨天时存在文件切换竞争条件
- 无法在不停机的情况下压缩旧日志
结论:仅限于个人博客、开发环境等低流量场景。
八、故障排查速查表
| 问题现象 | 可能原因 | 排查命令 |
|---|---|---|
| 切割后日志不再写入 | USR1 信号未发送成功 | kill -0 $(cat nginx.pid)检查进程 |
| 日志文件被清空 | 使用了>或truncate而非mv | 改用mv+kill -USR1 |
| 磁盘空间未释放 | 直接rm了日志文件 | lsof | grep deleted查找已删除但仍被占用的文件 |
| logrotate 不执行 | cron 服务未运行 | systemctl status crond |
| 新日志文件权限错误 | create参数配置不当 | 检查create MODE OWNER GROUP |
| Docker 容器日志未切割 | 容器内无法接收宿主机信号 | 使用copytruncate或进入容器执行 |
| 日期后缀不生效 | 缺少dateext参数 | 确认配置中有dateext |
快速恢复已删除但未释放的日志
# 查找被进程占用但已删除的文件 lsof | grep deleted | grep nginx # 临时恢复(通过 /proc 文件描述符) cat /proc/<pid>/fd/<fd> > /usr/local/nginx/logs/access.log.recovered九、自动化巡检脚本
在日志切割的基础上,可以加一个简单的日志巡检,每日检查切割是否正常:
#!/bin/bash # Nginx 日志切割巡检脚本 # 建议:每日 08:00 执行,检查昨天的切割结果 LOGS_PATH="/usr/local/nginx/logs" YESTERDAY=$(date -d "yesterday" +%Y%m%d) ALERT_THRESHOLD=0 # 昨天切割文件数为 0 则告警 echo "===== Nginx 日志切割巡检 =====" echo "检查日期: $(date)" echo "目标日期: ${YESTERDAY}" # 检查切割文件是否生成 count=$(find "$LOGS_PATH" -name "*_${YESTERDAY}*" -type f | wc -l) echo "切割文件数: ${count}" if [[ "$count" -eq "$ALERT_THRESHOLD" ]]; then echo "[WARNING] 昨天没有生成切割日志文件!" # 可接入钉钉/企业微信告警 exit 1 fi # 检查压缩情况 compressed=$(find "$LOGS_PATH" -name "*_${YESTERDAY}*.gz" -type f | wc -l) echo "已压缩文件数: ${compressed}" # 检查磁盘空间 disk_usage=$(df -h "$LOGS_PATH" | tail -1 | awk '{print $5}' | tr -d '%') echo "磁盘使用率: ${disk_usage}%" if [[ "$disk_usage" -gt 80 ]]; then echo "[WARNING] 磁盘使用率超过 80%!" fi # 统计昨天日志行数(检查是否有写入) total_lines=0 for f in $(find "$LOGS_PATH" -name "access_${YESTERDAY}*"); do lines=$(wc -l < "$f") total_lines=$((total_lines + lines)) done echo "昨日总访问量: ${total_lines}" echo "===== 巡检完成 ====="十、总结
Nginx 日志切割是运维工作中最基础也是最重要的任务之一。根据场景选择合适的方案:
| 场景 | 推荐方案 |
|---|---|
| 标准生产环境 | logrotate(零成本,系统自带) |
| 多域名 / 多路径 | cron + 增强版 Shell 脚本 |
| 高流量 API 网关 | 按小时切割 + Filebeat 采集 |
| Docker/K8s 容器化 | copytruncate 或 sidecar 方案 |
| 需要告警和监控 | 脚本 + 钉钉/企业微信 + 巡检 |
最后再强调一次:切割日志的正确姿势永远是先mv,再发USR1信号,切勿直接删除或清空日志文件。
本文涉及的脚本已在 Rocky Linux 9 和 Ubuntu 22.04 上测试通过,如有问题欢迎交流讨论。
