磁盘告警到了 92%,/var/log 下面躺着 400GB 文件。最刺眼的是一个 sidecar 的日志,从上线那天起就没配 rotation,单文件 120GB。
临时 rm 掉它,df 却没有任何变化。原因是进程还持有这个文件的句柄,inode 没释放。这件事之后,我把清理做成了一个每分钟可以放心跑的脚本。
先定策略,再写命令#
按文件年龄分三段处理,不要用文件名里的日期——文件名是应用写的,不可信;mtime 才是文件系统的事实。三段的比例可以按业务调,但顺序不能反:先压缩再删除,能保证删掉的每一份都还有一份压缩副本可供回溯。
| 文件年龄 | 动作 | 说明 |
|---|---|---|
| 3 天以内 | 不动 | 可能正在写,也可能还要排查 |
| 3–30 天 | gzip -9 |
压缩比通常在 10:1 以上 |
| 30 天以上 | 删除 | 包括已压缩的 .gz |
文件名含 audit |
永不删除 | 合规日志单独走归档 |
find -mtime +N 的边界要心里有数:它按 24 小时取整,+3 实际匹配 4 天以上的文件。合规场景别依赖这个精度,用 -newermt 或专门的归档任务。
压缩工具用 gzip,因为它几乎每台机器都有。如果发行版自带 zstd,同样的文本日志压缩率相当、速度能快好几倍,把脚本里的 gzip -9 换成 zstd -10 --rm 即可;代价是排查时要多记一个解压命令。压缩级别也别迷信 -9:对已经接近熵上限的日志,-9 相比 -6 只多省几个百分点,CPU 却要多花一倍。
脚本主体#
#!/usr/bin/env bash
set -euo pipefail
LOG_DIR="${LOG_DIR:-/var/log/app}"
KEEP_DAYS="${KEEP_DAYS:-3}" # 超过几天开始压缩
DELETE_DAYS="${DELETE_DAYS:-30}" # 超过几天直接删除
PATTERN="${PATTERN:-*.log}"
DRY_RUN="${DRY_RUN:-0}"
LOCK_FILE="${LOCK_FILE:-/tmp/log-cleanup.lock}"
[ -d "$LOG_DIR" ] || { echo "目录不存在: $LOG_DIR" >&2; exit 2; }
[[ "$KEEP_DAYS" =~ ^[0-9]+$ && "$DELETE_DAYS" =~ ^[0-9]+$ ]] \
|| { echo "KEEP_DAYS / DELETE_DAYS 必须是整数" >&2; exit 2; }
(( KEEP_DAYS < DELETE_DAYS )) \
|| { echo "KEEP_DAYS 必须小于 DELETE_DAYS" >&2; exit 2; }
# 同一时间只跑一个实例;没有 flock 的环境退化为目录锁
if command -v flock >/dev/null 2>&1; then
exec 9>"$LOCK_FILE"
flock -n 9 || { echo "已有实例在运行" >&2; exit 0; }
else
LOCK_DIR="$LOCK_FILE.d"
mkdir "$LOCK_DIR" 2>/dev/null || { echo "已有实例在运行" >&2; exit 0; }
trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT
fi
run() { if [ "$DRY_RUN" = "1" ]; then echo "[dry-run] $*"; else "$@"; fi; }
锁这一段不是装饰。清理任务被 cron 每分钟拉起一次,压缩一个 2GB 文件可能要几十秒,没有锁就会同时出现多个 gzip 抢同一个文件,轻则报错,重则留下 xxx.log.gz.gz。set -e 在这里也有副作用:gzip 失败会让整个脚本退出,后面的删除一个都不做——这是对的,宁可少清理,也不要带着未知状态继续删。
压缩与删除#
before=$(du -sm "$LOG_DIR" | cut -f1)
# 1) 压缩:够老但还没到删除线,且跳过已压缩文件
while IFS= read -r -d '' f; do
run nice -n 10 gzip -9 -- "$f"
done < <(find "$LOG_DIR" -xdev -type f -name "$PATTERN" \
-mtime +"$KEEP_DAYS" -mtime -"$DELETE_DAYS" \
! -name '*.gz' ! -name 'audit*' -print0)
# 2) 删除:超过删除线,含 .gz
while IFS= read -r -d '' f; do
run rm -f -- "$f"
done < <(find "$LOG_DIR" -xdev -type f \
\( -name "$PATTERN" -o -name "$PATTERN.gz" \) \
! -name 'audit*' -mtime +"$DELETE_DAYS" -print0)
after=$(du -sm "$LOG_DIR" | cut -f1)
echo "清理完成: ${before}MB -> ${after}MB"
三处细节值得单独说:
-print0+read -d ''。日志文件名里带空格、中文、甚至换行都不罕见。用$(find ...)接for会在空格处断词,直接删错文件。-xdev。不跨越挂载点。/var/log上如果挂了独立分区或网络盘,少了这个参数可能一路走进别的文件系统。nice -n 10。压缩是 CPU 密集操作,降优先级避免和业务抢核。IO 压力大时还可以套ionice -c2 -n7。
-- 也不能省。文件名以 - 开头时,gzip -9 -- -foo.log 才不会把文件名当成选项。
先在 dry-run 里看一遍#
脚本里所有写操作都走 run(),DRY_RUN=1 时只打印命令。上线前拿一个临时目录跑一遍:
mkdir -p /tmp/logtest && cd /tmp/logtest
echo keep > current.log
echo old > old.log
echo space > "with space.log"
echo gone > ancient.log
touch -d '5 days ago' old.log "with space.log"
touch -d '40 days ago' ancient.log
LOG_DIR=/tmp/logtest LOCK_FILE=/tmp/logtest.lock DRY_RUN=1 ./cleanup.sh
预期输出只有两条 gzip 和一条 rm:old.log、with space.log 被压缩,ancient.log 被删除,current.log 不动。确认无误后把 DRY_RUN 换成 0 再跑一次,检查目录里是不是多了 .gz、少了 .log。
这一步值得自动化。它同时验证了空值保护、空格文件名、年龄判断三条路径,比在生产上「小心一点」可靠得多。
rm 之后空间不释放,怎么办#
这是清理日志最常见的认知错误:删掉文件不等于释放磁盘。只要还有进程持有该文件的 fd,inode 和 block 就一直在。
三种处理方式:
- 清空而不是删除:
: > /var/log/app/current.log。文件还在,fd 继续有效,空间立即释放。适合正在写的单个大文件。 - 让进程重开文件:
kill -HUP(前提是应用实现了 reopen),配合 logrotate 的create模式。 - 删完再检查:
lsof +L1能列出所有「已删除但仍被占用」的文件。发现大块空间挂在某个进程上,先重启它,或者按第 1 条清空。
具体排查时,lsof +L1 | sort -k7 -n | tail -20 按文件大小排序,能直接看到哪个进程占着几个 GB 的「幽灵文件」。df -h 和 du -sh 对不上账时,先跑这一条,基本都能定位到。
所以脚本只处理「够老」的文件:-mtime +3 天然排除了正在写的日志。真要清理当天的超大日志,应该用清空,而不是删除。
跑在什么节奏上#
- 频率:每 5–10 分钟一次足够,
*/5 * * * *。不要@daily攒到凌晨一次干完,那会在业务低峰制造一个 IO 尖峰。 - 限速:单次处理文件数设上限(
head -z -n 200),剩下的下一轮继续。 - 观测:脚本自身要输出处理了多少个文件、释放了多少 MB。没有输出的清理脚本,出问题时你无法判断它到底跑没跑。
- 权限:别用 root 跑。给专用用户对日志目录的写权限,
set -u兜住空变量,避免rm -rf "$LOG_DIR"变成rm -rf /。
对应的 crontab 大致是这样,注意用绝对路径,cron 的 PATH 和你登录时不一样:
*/5 * * * * LOG_DIR=/var/log/app /opt/ops/log-cleanup.sh >> /var/log/app/.cleanup.log 2>&1
把脚本自己的输出也写进日志目录,等于给自己埋了一个自监控点:清理任务失败时,.cleanup.log 里会留下 set -e 触发的那条错误。
- 压缩后抽检:
gzip -t能验证压缩包完整性。对大文件做一次抽检,比事后发现 30 天前的日志解不开要强。
目录锁在没有 flock 的系统上不是强一致的:进程被 kill -9 时 trap 不会执行,残留的锁目录会让后续任务永久跳过。生产环境优先用 flock,或者加一个「锁超过 30 分钟则强制清理」的兜底。
结论#
- 用
mtime分段(压缩线 / 删除线),别信文件名里的日期。 - 文件名用
-print0传递,--结束选项,-xdev限制范围。 - 正在写的日志用清空(
: > file),不要rm,否则空间不释放。 - 加锁、加
dry-run、加数值校验,让脚本可以被安全地重复执行。 - 清理频率高于一次清空,避免制造 IO 尖峰,同时保留可观测的输出。
- 上线前先用
DRY_RUN=1在临时目录走一遍,别在生产上做第一次试运行。
完