日志轮转与归档

系统讲解 logrotate 的策略指令与 postrotate 钩子、压缩延迟与保留期计算、归档到对象存储的生命周期与校验,以及 rename 加 reopen 与 copytruncate 两种模型下的写入安全、竞态规避、容器场景差异与常见踩坑速查。

1. 轮转要解决的三件事

一句话总结: 日志轮转不是「定期删旧文件」,而是同时解决磁盘上限、检索效率与合规保留三个问题——只做删除的轮转策略迟早会丢数据或撑爆磁盘。

1.1 三个约束

① 磁盘上限:任何单个日志目录都不应该无限增长
② 检索效率:正在写的文件越小,grep/tail 越快,出问题时越好查
③ 合规保留:某些日志必须留 N 天/N 年,删早了是事故

这三个约束互相冲突:保留越久占盘越多。轮转策略的本质是给每条日志线分配一个「保留预算」。

1.2 两种模型

模型做法优点风险
rename + reopen重命名旧文件,通知进程重开零丢日志需要进程支持信号
copytruncate拷贝一份再把原文件截断进程无需配合拷贝与截断之间有窗口丢数据
rename + reopen:
  mv app.log app.log.1        ← 旧 inode 被重命名
  kill -USR1 <pid>            ← 进程重新 open("app.log") 得到新 inode
  写入继续进入新文件,旧 inode 上不再有新数据

copytruncate:
  cp app.log app.log.1        ← 拷贝期间进程仍在写
  : > app.log                 ← 截断,拷贝窗口内的写入丢失

一句话总结: 能改应用就用 rename + reopen,不能改就用 copytruncate 并接受「极端情况下丢几行」;永远不要在同一份日志上混用两种模型。

2. logrotate 策略与钩子

一句话总结: logrotate 的配置是「全局默认 + 每份日志覆盖」的叠加结构,理解哪些指令可以叠加、哪些互斥,是避免策略写错的前提。

2.1 配置结构

/etc/logrotate.conf          # 全局默认
/etc/logrotate.d/*           # 每个服务一个片段,按文件名字典序加载
# /etc/logrotate.conf 里的常见全局项
weekly                # 默认每周轮转
rotate 4              # 默认保留 4 份
create                # 轮转后创建新文件
dateext               # 用日期而非序号命名
compress              # 轮转后压缩
include /etc/logrotate.d
# /etc/logrotate.d/myapp
/var/log/myapp/*.log {
    daily
    rotate 14
    missingok
    notifempty
    compress
    delaycompress
    dateext
    dateformat -%Y%m%d
    create 0640 myapp myapp
    sharedscripts
    postrotate
        /bin/kill -USR1 $(cat /run/myapp.pid 2>/dev/null) 2>/dev/null || true
    endscript
}

2.2 指令速查

指令作用常见坑
daily/weekly/monthly轮转周期只决定「最早何时」,不保证准点
size 100M按大小轮转与周期指令同时写是「或」关系
rotate N保留份数按份数不按天数,与周期联动
missingok文件不存在不报错忘了它会让 cron 每天发告警邮件
notifempty空文件不轮转会推迟轮转时间
copytruncate拷贝后截断有丢数据窗口
delaycompress延后一轮再压缩必须配合 compress
sharedscripts多文件只跑一次钩子不写则每个文件跑一次
dateext用日期命名同一天多次轮转需要 dateformat 加时分

size 与 daily 同时出现时,logrotate 的行为是「满足任一条件即轮转」,很多人误以为是与关系。

2.3 postrotate 与信号

# 关键点:kill 的目标 PID 必须能可靠取到
postrotate
    if [ -f /run/nginx.pid ]; then
        /bin/kill -USR1 "$(cat /run/nginx.pid)"
    fi
endscript

不同服务的「重开日志」信号不一样:

服务信号说明
nginxUSR1重开日志
rsyslogHUP重载配置并重开
自定义 Go 程序通常 USR1 或 HUP看实现
systemd 服务systemctl reload走单元定义

endscript 之前的每一行都会交给 /bin/sh 执行,所以里面写的是 POSIX 语法,不能用 bash 数组之类的东西。

2.4 调试 logrotate

# 干跑:只打印将要做什么,不真的执行
logrotate -d /etc/logrotate.d/myapp

# 强制轮转一次(即使未到周期)
logrotate -f /etc/logrotate.d/myapp

# 指定状态文件(排查「为什么不轮转」时最有用)
logrotate -v -s /var/lib/logrotate/status /etc/logrotate.d/myapp

# 查看上次轮转时间
grep myapp /var/lib/logrotate/status

一句话总结: 「为什么不轮转」的答案八成在状态文件里——logrotate 用状态文件记录每份日志的上次轮转时间,手动删掉日志文件但没删状态记录,会导致轮转时间计算错乱。

2.5 用 cron 还是 systemd timer

logrotate 传统上由 /etc/cron.daily/logrotate 触发,现代发行版多改为 systemd timer。两者的差异在于「错过执行怎么办」:

# /etc/systemd/system/logrotate.timer
[Timer]
OnCalendar=daily
Persistent=true          # 关机错过的执行会在开机后补跑

Persistent=true 是 cron 不具备的能力,服务器停机重启后不会跳过当天的轮转。定时任务的更完整对比见 cron 定时任务 与 systemd 单元与定时器 。

3. 压缩与保留期

一句话总结: delaycompress 是必须的——正在写的日志文件被压缩后,进程的 fd 会指向一个内容已经变了的文件,导致日志错乱。

3.1 为什么必须 delaycompress

没有 delaycompress:
  mv app.log app.log.1
  gzip app.log.1            ← 立刻压缩
  进程若尚未重开 fd,仍指向 app.log.1 的 inode
  → 后续写入进入一个「正在被 gzip 读取」的文件,内容损坏

有 delaycompress:
  mv app.log app.log.1
  (本轮不压缩)
  下轮轮转时才 gzip app.log.1 → app.log.2.gz
  → 压缩时该文件已确认无写入

3.2 保留期计算

保留天数 ≈ 轮转周期 × rotate 份数
daily + rotate 14  → 约 14 天
weekly + rotate 8  → 约 8 周

这个估算在 size 触发轮转时不成立:如果一天内因体积触发多次轮转,rotate 14 可能只覆盖几天。

# 精确计算某目录下日志的实际覆盖时间跨度
ls -1t /var/log/myapp/*.gz | tail -1 | xargs -r stat -c '%y %n'

3.3 日期命名与多次轮转

# 每天一次:默认 dateformat 就够
dateext
dateformat -%Y%m%d          # app.log-20261007

# 一天可能轮转多次:必须加时分秒,否则命名冲突
dateformat -%Y%m%d-%H%M%S   # app.log-20261007-143022

命名冲突时 logrotate 的行为是直接报错并跳过,不会覆盖——所以看到「destination already exists」时先检查 dateformat 的粒度。

3.4 磁盘配额与保护

# 轮转前先检查磁盘余量,余量不足时先删最旧的归档
check_space() {
    local dir=$1 need_mb=$2
    local avail_mb
    avail_mb=$(df -Pm "$dir" | awk 'NR==2 {print $4}')
    if [ "$avail_mb" -lt "$need_mb" ]; then
        echo "空间不足: 剩余 ${avail_mb}MB < 需要 ${need_mb}MB" >&2
        return 1
    fi
}

# 兜底清理:按时间删除超过 N 天的归档(独立于 logrotate)
find /var/log/myapp -name '*.gz' -mtime +30 -delete

一句话总结: rotate N 只按份数保留,一旦轮转频率因体积触发而变快,实际保留时间会大幅缩短——真正的保留期保障要靠独立的 find -mtime 清理与监控。

4. 归档到对象存储

一句话总结: 本地轮转解决磁盘问题,对象存储解决保留问题——归档脚本的成败关键在于「上传成功才删本地」这个顺序不能反。

4.1 上传与校验

#!/usr/bin/env bash
set -euo pipefail

BUCKET=s3://logs-archive/myapp
SRC=/var/log/myapp
RETENTION_DAYS=7

archive_one() {
    local f=$1
    local key="myapp/$(date -d "@$(stat -c %Y "$f")" +%Y/%m/%d)/$(basename "$f")"

    # 上传并记录 ETag,用于后续校验
    local etag
    etag=$(aws s3 cp "$f" "$BUCKET/$key" --only-show-errors \
            --metadata "sha256=$(sha256sum "$f" | cut -d' ' -f1)" \
            --query 'ETag' --output text) || return 1

    [ -n "$etag" ] || { echo "上传未返回 ETag: $f" >&2; return 1; }
    echo "已归档 $f → $key"
}

# 只归档超过保留期的压缩日志,避免动到正在写的文件
while IFS= read -r -d '' f; do
    archive_one "$f" && rm -f "$f"
done < <(find "$SRC" -name '*.gz' -mtime +"$RETENTION_DAYS" -print0)

三个安全点:-print0 + read -d '' 处理含空格的文件名、archive_one 成功才 rm、用 -mtime +N 排除正在写的文件。

4.2 生命周期策略

上传到对象存储后,保留策略交给存储侧,避免在脚本里维护复杂的删除逻辑:

{
  "Rules": [
    {
      "ID": "logs-tiering",
      "Filter": { "Prefix": "myapp/" },
      "Status": "Enabled",
      "Transitions": [
        { "Days": 30,  "StorageClass": "STANDARD_IA" },
        { "Days": 90,  "StorageClass": "GLACIER" }
      ],
      "Expiration": { "Days": 730 }
    }
  ]
}
# 应用生命周期配置
aws s3api put-bucket-lifecycle-configuration \
    --bucket logs-archive \
    --lifecycle-configuration file://lifecycle.json

把「多久转冷、多久删除」下沉到存储层的好处是:策略变更不需要改脚本、不需要重新部署,且存储侧的执行是强保证的。

4.3 断点续传与大文件

# 大文件用分段上传,网络中断可续传
aws s3 cp big.log.gz "$BUCKET/$key" \
    --storage-class STANDARD_IA \
    --no-progress

# 配置自动分段阈值(默认 8MB)
aws configure set s3.multipart_threshold 64MB
aws configure set s3.multipart_chunksize 16MB

云端 CLI 的通用封装技巧(凭据、区域、重试、超时)见 云 CLI 自动化 。

4.4 归档后校验

# 抽样验证远端对象可读且哈希一致
verify_archive() {
    local key=$1 expect=$2
    local tmp; tmp=$(mktemp)
    aws s3 cp "$BUCKET/$key" "$tmp" --only-show-errors
    local actual; actual=$(sha256sum "$tmp" | cut -d' ' -f1)
    rm -f "$tmp"
    [ "$actual" = "$expect" ] || { echo "校验失败: $key" >&2; return 1; }
}

归档链路最怕的是「上传成功但内容损坏」,因此抽查校验应该作为定期任务保留,而不是只在迁移时跑一次。归档与备份的整体策略(增量、异地、恢复演练)可参考 备份与 rsync 策略 。

5. 轮转期间的写入安全

一句话总结: 轮转的核心风险是「写入者与轮转者对同一个 inode 的认知不一致」——要么让写入者主动重开,要么接受截断窗口,没有第三条路。

5.1 rename + reopen 的正确顺序

1. 应用停止向旧 inode 写入(或至少不再打开新文件)
2. mv app.log app.log.1        ← 旧 inode 改名,应用 fd 仍指向它
3. kill -USR1 <pid>            ← 应用 close 旧 fd、open 新 app.log
4. 此时 app.log 是全新 inode,app.log.1 上无新写入
5. 压缩 app.log.1(delaycompress 下延后一轮)

顺序不能颠倒:先发信号再 rename,应用重开时打开的还是旧文件,轮转等于没做。

5.2 copytruncate 的竞态

cp app.log app.log.1     ← T1 开始拷贝,此刻文件内容为 A
(应用写入 B)            ← T2 应用写入,进入 app.log
: > app.log              ← T3 截断,B 永久丢失

丢失窗口的大小等于「拷贝耗时」,日志量大时可能达到数秒。缓解手段:

# 1. 用 copytruncate 时保持单文件体积较小(配合 size 轮转)
size 50M
rotate 10
copytruncate

# 2. 写入侧尽量用 O_APPEND 且减少 flush 间隔
# 3. 关键日志改用 rename + reopen 模型

5.3 容器与 systemd 场景

容器里通常没有 logrotate,日志应该直接写到 stdout 交给运行时收集:

# 反例:应用写文件到容器内,日志随容器销毁而丢失
CMD ["./app", "--log-file", "/var/log/app.log"]

# 正例:写 stdout,由运行时接管轮转
CMD ["./app", "--log-stdout"]
# docker-compose 里配置 json-file 驱动的轮转
services:
  app:
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "5"

如果确实需要在容器内轮转(比如 sidecar 模式),参考 容器入口脚本 里的信号转发与 PID 1 处理。

5.4 无 logrotate 的自研轮转

#!/usr/bin/env bash
set -euo pipefail

# 简单可靠的按大小轮转,适合嵌入式与精简镜像
rotate_by_size() {
    local file=$1 max_bytes=$2 keep=$3

    [ -f "$file" ] || return 0
    local size; size=$(stat -c %s "$file")
    [ "$size" -lt "$max_bytes" ] && return 0

    local stamp; stamp=$(date +%Y%m%d-%H%M%S)
    cp "$file" "${file}.${stamp}"
    : > "$file"                      # 截断

    gzip -9 "${file}.${stamp}"

    # 按份数清理
    ls -1t "${file}".*.gz 2>/dev/null | tail -n +"$(( keep + 1 ))" | xargs -r rm -f
}

# 由应用在每次写日志前调用,或由 inotify 触发的独立进程调用

配合 inotify 文件监听 ,可以在文件增长到阈值时立即触发轮转,而不必等定时任务。

6. 踩坑速查

症状原因处理
轮转后日志仍在旧文件未发重开信号加 postrotate + kill -USR1
压缩后日志内容错乱缺 delaycompress补上
每天收到 cron 报错邮件日志不存在且未声明加 missingok
命名冲突报错一天内多次轮转dateformat 加时分秒
轮转频率异常状态文件与实际不符清理 /var/lib/logrotate/status
归档后本地仍有残留rm 在上传前执行调整为先传后删
归档文件损坏上传被截断记录并校验 sha256
磁盘仍被吃满rotate 只按份数加 find -mtime 兜底清理

7. 总结

日志轮转与归档的可靠做法可以压缩成五条:

  1. 模型选对:能改应用就 rename + reopen,不能就 copytruncate 并接受窗口。
  2. 压缩延后:delaycompress 不是优化项,是正确性要求。
  3. 保留双保险:rotate 管份数,find -mtime 管时间,两者都要有。
  4. 归档先传后删:上传校验通过才允许 rm 本地文件。
  5. 保留策略下沉:能交给对象存储生命周期就不在脚本里写删除逻辑。

把这五条落实之后,日志这条链路就从「迟早出事」变成「可预期地增长与消退」。日志落地之后的分析工作(错误率、异常聚合、告警阈值)见 日志解析与聚合 ;如果日志目录本身也需要被实时监控,inotify 文件监听 提供了比定时轮询更及时的触发方式。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 监控采集与告警脚本
  2. Shell 处理二进制数据
  3. Shell 脚本的 POSIX 可移植性