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
不同服务的「重开日志」信号不一样:
| 服务 | 信号 | 说明 |
|---|---|---|
| nginx | USR1 | 重开日志 |
| rsyslog | HUP | 重载配置并重开 |
| 自定义 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. 总结
日志轮转与归档的可靠做法可以压缩成五条:
- 模型选对:能改应用就 rename + reopen,不能就 copytruncate 并接受窗口。
- 压缩延后:
delaycompress不是优化项,是正确性要求。 - 保留双保险:
rotate管份数,find -mtime管时间,两者都要有。 - 归档先传后删:上传校验通过才允许
rm本地文件。 - 保留策略下沉:能交给对象存储生命周期就不在脚本里写删除逻辑。
把这五条落实之后,日志这条链路就从「迟早出事」变成「可预期地增长与消退」。日志落地之后的分析工作(错误率、异常聚合、告警阈值)见 日志解析与聚合 ;如果日志目录本身也需要被实时监控,inotify 文件监听 提供了比定时轮询更及时的触发方式。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。