代码实战 · 2026 年 11 月 11 日

用 Shell 写一个生产可用的日志清理脚本

磁盘被打满的往往不是日志本身,而是没人清理的日志。这个脚本按天压缩、按期删除,带锁、带 dry-run、带空格文件名防护,并解释 rm 之后空间为什么不释放。

磁盘告警到了 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 却要多花一倍。

脚本主体#

bash
#!/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 失败会让整个脚本退出,后面的删除一个都不做——这是对的,宁可少清理,也不要带着未知状态继续删。

压缩与删除#

bash
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 时只打印命令。上线前拿一个临时目录跑一遍:

bash
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 就一直在。

三种处理方式:

  1. 清空而不是删除:: > /var/log/app/current.log。文件还在,fd 继续有效,空间立即释放。适合正在写的单个大文件。
  2. 让进程重开文件:kill -HUP(前提是应用实现了 reopen),配合 logrotate 的 create 模式。
  3. 删完再检查: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 和你登录时不一样:

bash
*/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 在临时目录走一遍,别在生产上做第一次试运行。

完

#Shell#运维#日志
回到顶部 ↑