磁盘告警那天我记得很清楚。凌晨三点被监控短信吵醒,登录一看/分区使用率已经到了 92%,再拖几个小时业务就要挂。df -h一查,/var/log下面躺着几十个 GB 的日志文件,messages和syslog单个就有 20 多 GB。那时候我手里没有现成的清理方案,只能先手动truncate几个大文件把水位降下来,第二天再熬夜写脚本。
那台机器上跑着一堆 Java 服务,catalina.out以每天 1GB 的速度膨胀,老旧的单体架构本身又没接中央日志,所有输出全写在本机磁盘上。手动清一次只能管两三天,再往外就是没完没了的重复劳动。所以"日志自动清理"这个需求,不是简单的"写个脚本定时删文件",而是要把日志的存放位置、增长速率、服务对日志文件句柄的依赖、定时调度、权限边界全部考虑进去。这篇就把我当时整理出来的完整思路和脚本逐段拆开讲,适合刚接触 Linux 运维、写过几行 shell 但不知道怎么在服务器上做实操的读者。代码可以直接抄,坑我也会一个一个指出来。
1. 日志吃满磁盘的常见路径:先搞清楚到底清什么
写脚本之前,最忌讳的就是抄一段find /var/log -type f -name "*.log" -exec rm -f {} \;就丢到 crontab 里。这种写法在干净的测试机上没问题,放到真实生产环境几乎必然出事。我先带你把最常见的日志存放位置和膨胀规律过一遍,后面脚本的设计逻辑才能对得上。
1.1 主流发行版日志基本都堆在哪里
/var/log/messages:CentOS / RHEL 系的主日志,内核和大部分服务的输出都往这里写,高负载机器一个月能冲到几 GB。/var/log/syslog:Debian / Ubuntu 系对应messages的位置,行为类似。/var/log/secure:登录认证日志,暴力破解频繁的机器会快速增长。/var/log/cron:计划任务执行日志,任务多且输出未重定向时会积累。/var/log/boot.log、/var/log/dmesg:启动过程和内核环形缓冲区的落盘,固定大小,膨胀一般不严重。- 应用自己的日志:
/opt/app/logs、/home/www/logs、Tomcat 的catalina.out、Nginx 的access.log和error.log。这类才是真正的"重灾区",因为应用不会像系统日志那样自带 rotate 策略,写多少就留多少。
| 日志路径 | 典型增长速率 | 清理风险 |
|---|---|---|
| /var/log/messages | 中等,高并发机器快 | 需要保证 syslog 服务可写 |
| /var/log/secure | 被扫描时极快 | 删除后 sshd 继续写入旧句柄 |
| 应用目录下的 *.log | 可达 GB/天 | 应用持有文件句柄,rm 后磁盘不释放 |
| Nginx access.log | 流量大时可 GB/天 | 直接删会导致正在打开的 fd 继续占空间 |
1.2 一个不能删的边界:服务仍然持有的文件句柄
日志清理里面最反直觉的一点是:rm删除日志文件后,磁盘空间不一定立刻释放。只要某个进程还握着这个文件的文件描述符,文件的 inode 就还活着,空间依然被占用。表现就是df一看还是满的,du却找不到那个文件。这个现象在处理catalina.out、messages、spring.log这类由长驻进程持续写入的日志时尤其常见。
我当时处理第一台机器时就是这样,直接rm -f /var/log/messages,磁盘使用率纹丝不动。后来查了才知道,rsyslog 一直打开着这个文件,删除操作只是把目录项抹掉了,真正占空间的 inode 还挂在进程上。解决方式不是删除文件,而是清空文件内容,也就是truncate或: > file。清空操作保留文件路径和 inode,进程继续往同一个位置写,磁盘空间立刻释放。
1.3 日志保留策略的取舍
"日志要留几天"这个问题没有标准答案,取决于你所在团队的需求:有没有审计要求、会不会有人回头查半年前的报错、监控平台能不能长期存储。我自己的实践是分三类处理:
- 系统日志(messages / syslog / secure):保留 7 天,因为排查问题大多只看最近一周。
- 应用业务日志:保留 15~30 天,业务侧经常需要对比更长周期的数据。
- 调试日志 / 临时
nohup.out:保留 3 天,这类日志不删就是磁盘杀手。
每类日志给一个独立的保留天数,用同一个脚本但不同配置来跑,比"一刀切删 7 天前"灵活得多。
2. 清理策略对比:为什么我不直接rm,也不完全交给logrotate
市面上现成的日志清理方案不少,系统自带的logrotate就能按天轮转、按大小轮转、压缩旧档。那为什么还要自己写脚本?因为很多场景下logrotate覆盖不到:你没有权限改系统配置、应用日志目录结构混乱、日志文件名不带统一的日期后缀、或者干脆是云服务器镜像里预装的精简系统把 logrotate 卸载了。自己写脚本的价值在于可定制、可审计、可扩展。
2.1rm的副作用不止是句柄问题
- 删除文件后,日志系统可能会创建一个新文件,但新文件的权限、属主、SELinux 上下文如果不对,会导致服务写日志失败。尤其 rsyslog 的
$FileCreateMode配置和你手动touch出来的默认权限不一致时,坑最明显。 rm是彻底删除,没有回滚余地。万一误删了还在使用的重要日志,后续需要回溯时就只能从备份或监控系统里找。- 删除操作瞬间释放大量 inode 和磁盘块,但同时触发大量元数据更新,在性能敏感的业务时段会带来微小的 IO 冲击。
2.2truncate+find的组合更稳
清理日志的正确姿势是找到需要处理的文件后,不是删掉它,而是把内容清空:
truncate -s 0 /var/log/messages对正在被写入的日志,这个操作是安全的。清空后进程继续在原有句柄上写,文件重新从 0 开始增长。对于已经完全不再写入的旧日志,比如上了日期的app.log.2025-05-01,则可以放心删除。所以脚本的核心逻辑是:
- 没有日期后缀、仍在活跃写入的日志:一律
truncate清空,不动文件本身。 - 带日期后缀、且时间超过保留期限的归档文件:直接删除。
用时间条件判断的find再加上类型限制,就能覆盖绝大多数场景:
find /var/log -type f -name "*.log" -mtime +7 -exec truncate -s 0 {} \;这里没有rm,因为/var/log下的日志基本都是活跃写入文件,清空是正确的处理方式。真正要删除的归档日志,我会单独用一个目录列表来匹配,避免误伤。
2.3 和logrotate的分工边界
我并不是要把logrotate说成"没用"。如果服务允许你自己定义轮转规则,logrotate当然是首选,它的copytruncate、compress、dateext都很成熟。我的建议是:
- 系统级日志:交给
logrotate默认配置,不要动。 - 应用日志:如果应用本身不做日志切割,就用我的清理脚本做兜底。
- 混合方案:
logrotate每天生成一个带日期的压缩文件,清理脚本负责删除超过保留期限的压缩文件。
这样的分工下,轮转是平滑的,历史归档是保留的,磁盘不会被掏空,也不会出现"删了一半正在写的文件"这种闹心事。
3. 清理脚本逐段拆解:参数化设计、防呆保护和执行逻辑
下面这个脚本是我实际在用的版本,去掉了公司内部路径和敏感信息,保留了完整骨架。它的设计目标很简单:一个脚本,多份配置,既能手动跑,也能自动跑,还要能防误删。
3.1 基础骨架与可配置区
#!/usr/bin/env bash # --------------------------------------------------------- # log-cleaner.sh # 适用:CentOS 7 / 8、Ubuntu 20.04+、Debian 派生发行版 # 功能:按目录清理过期日志、清空超大日志 # --------------------------------------------------------- set -u # 备份文件保留天数,按实际需求调整 DAYS_SYSTEM=7 DAYS_APP=15 DAYS_DEBUG=3 # 超过该大小的活跃日志将被直接清空,单位 MB MAX_SIZE_MB=1024 # 需要清理的根目录列表 SYSLOG_DIRS="/var/log" APPLOG_DIRS="/opt/app/logs /home/www/logs /data/logs" # 只删除归档文件,绝不 truncate 的路径 ARCHIVE_ROOTS="/var/log /opt/app/logs" # 需要保留的文件列表,使用通配模式 PROTECT_PATTERNS=( "/var/log/btmp" "/var/log/wtmp" "/var/log/lastlog" ) # 日志输出 LOGFILE="/var/log/log-cleaner.log" log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOGFILE" } clean_system_logs() { local dir="$1" local days="$2" # 只处理 .log 日志和系统常见日志名 find "$dir" -maxdepth 1 -type f \ \( -name "*.log" -o -name "messages*" -o -name "syslog*" \) \ -mtime "+${days}" -exec truncate -s 0 {} \; log "cleaned system logs under $dir (retention=${days}d)" } clean_app_logs() { local dir="$1" local days="$2" # 处理活跃日志,只清空 find "$dir" -maxdepth 1 -type f -name "*.log" -mtime "+${days}" -exec truncate -s 0 {} \; # 处理归档日志,只删除明确带时间戳且过期的文件 find "$dir" -maxdepth 1 -type f \ \( -name "*.log.*" -o -name "*.gz" -o -name "*.zip" \) \ -mtime "+${days}" -delete log "cleaned app logs under $dir (retention=${days}d)" } # 对超大活跃文件做即时清空 truncate_oversize() { local dir="$1" find "$dir" -maxdepth 1 -type f -name "*.log" -size "+${MAX_SIZE_MB}M" \ -exec truncate -s 0 {} \; log "truncated log files larger than ${MAX_SIZE_MB}MB under $dir" } # 保留保护列表中的文件,防止权限和账号数据损坏 protect_files() { local pattern for pattern in "${PROTECT_PATTERNS[@]}"; do chattr -a "$pattern" 2>/dev/null || true done } main() { # 无参数时输出帮助 if [ $# -lt 1 ]; then echo "Usage: $0 {system|app|oversize|all}" >&2 exit 1 fi case "${1:-all}" in system) for d in $SYSLOG_DIRS; do clean_system_logs "$d" "$DAYS_SYSTEM"; done ;; app) for d in $APPLOG_DIRS; do clean_app_logs "$d" "$DAYS_APP"; done ;; oversize) for d in $SYSLOG_DIRS $APPLOG_DIRS; do truncate_oversize "$d"; done ;; all) for d in $SYSLOG_DIRS; do clean_system_logs "$d" "$DAYS_SYSTEM"; done for d in $APPLOG_DIRS; do clean_app_logs "$d" "$DAYS_APP"; done for d in $SYSLOG_DIRS $APPLOG_DIRS; do truncate_oversize "$d"; done ;; *) echo "Unknown option: $1" >&2; exit 2 ;; esac } protect_files main "$@"这个脚本有几个用心设计的点,我逐个说。
3.2 关键参数设计的底层逻辑
-maxdepth 1限制深度:这是防止误删的核心之一。日志目录下通常只有一层文件,加上-maxdepth 1就不会递归到子目录的无关文件里去。如果应用目录下有按日期分子目录的组织方式,再单独写一个递归清理函数,不要混在一起。
-mtime "+${days}"的语义:find的-mtime +7表示"超过 7 * 24 小时之前修改过"。之所以用加号而不是裸数字,是因为裸数字匹配的是"恰好 7 天这个区间",不是我们理解的"7 天以上"。设成DAYS_SYSTEM=7后传参+7才是正确写法,写成7会出现"昨天改过的文件也被删掉"的诡异问题。
区分清空与删除:clean_app_logs里对*.log用truncate,对*.log.*、*.gz用-delete。原因前面提过:活跃文件不能删,归档文件留着没有价值。真实环境里,如果应用写日志时带着日期轮转,像app.log.2025-11-20,那这些就是归档文件,到期直接删。如果应用一直往app.log写,就只清空。
3.3 防误删保护机制
脚本里的protect_files函数很有意思。/var/log/btmp、/var/log/wtmp是登录失败的记录和登录成功的记录,lastlog记录每个用户最近登录时间。这些文件被find按名字匹配到的概率不高,但一旦清空了,last、who、lastlog这几条命令的排查能力就废了。chattr -a给它们加追加属性,我在清理前显式解除一次,是为了防止系统里之前设置过限制导致清理失败,也算双保险。
另外一个常见保护手段是在脚本顶部做磁盘使用率二次判断:
usage=$(df /var/log | awk 'NR==2 {print $5}' | tr -d '%') if [ "$usage" -lt 80 ]; then echo "disk usage ${usage}% is below 80%, skip cleanup" exit 0 fi这个"低于阈值不干活"的逻辑非常实用。日志增长没那么快的时候没必要天天清空文件,人为减少对服务的干扰。我把它放在main之前,配合 crontab 每天跑一次,实际效果是只有磁盘到 80% 以上才清理。既保住了磁盘水位,又不会每天都做无谓的 IO 操作。
4. 实测和排障:生产环境里我踩过的三个坑
脚本写完之后不能直接上生产,一定要先在测试环境空转,再拿到低峰时段实操。下面这三个坑,都是我在真实服务器上排过的,每一段都对应一条可以直接抄的"事故清单"。
4.1 坑一:find -mtime的 24 小时误差
观察:
我起初把清理脚本放到 crontab 每天凌晨 3 点跑,预期是"清掉 7 天前的日志"。但第二天检查时发现,昨天下午生成的日志也被清空了。排查后定位到原因:-mtime +7是严格按 Unix 时间戳减去 7 * 86400 秒整除后计算天数,而非自然天。比如文件修改时间是 2025-11-20 14:00,脚本在 2025-11-27 03:00 跑,时间差是 6 天 13 小时,在find看来已经是 7 个"整数天"了,于是被当作过期文件处理。
对策:
- 按自然天判断:给脚本传入
DAYS_SYSTEM后,不要直接在find命令里写-mtime,而是用stat先算出文件的修改日期再比较,或者直接用find -newermt指定绝对时间阈值。
我在生产脚本里用的是更稳妥的写法:
find "$dir" -maxdepth 1 -type f -name "*.log" \ ! -newermt "$(date -d "${days} days ago" '+%Y-%m-%d 00:00:00')" \ -exec truncate -s 0 {} \;用! -newermt表达"文件修改时间早于 N 天前零点"的逻辑,避开了-mtime的整除陷阱。这个细节如果不踩一次,很难意识到。
4.2 坑二:日志目录里混入了空格和通配符字符
观察:
脚本在某个应用服务器上报错,find输出的路径被 shell 拆分,结果一条truncate只截断了路径的前半段,剩下半段变成了多余参数。原因很朴素——应用日志目录里有带空格的子目录,比如/data/logs/my app/。find -exec truncate -s 0 {} \;本身对空格是安全的,因为{}会在内部展开成完整路径,但如果我在循环里把find输出接到了变量上,就埋下了分裂隐患。
对策:
- 优先用
find -exec,不要用for file in $(find ...)。 - 如果必须用 for 循环,改成
while IFS= read -r file:
find "$dir" -type f -name "*.log" -print0 | while IFS= read -r -d '' file; do truncate -s 0 "$file" done-print0配合-d ''是处理任意文件名的唯一正解。虽然我脚本里刻意用-maxdepth 1减少了递归,但真实环境下,应用目录下出现带空格文件名的概率并不低。
4.3 坑三:crontab 环境变量和权限不一致
观察:
脚本在终端里手动执行一切正常,放进 crontab 后却提示权限不足。排查发现 root 的 crontab 默认 PATH 只有/usr/bin:/bin,而脚本里用到了/usr/bin/truncate、/usr/bin/find,这些路径在精简系统上不一定在默认 PATH 里。
对策:
- 在脚本头部显式导入环境,不要依赖 shell 的默认 PATH:
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"- 同样重要的是,crontab 里最好确认脚本本身有可执行权限,并且用绝对路径调用:
0 3 * * * /bin/bash /opt/scripts/log-cleaner.sh all >> /var/log/log-cleaner-cron.log 2>&1还有个细节:脚本里truncate命令在部分旧系统上不存在,我后来改用: > file作为备选方案。truncate的优点是你可以只清空超大文件而不影响文件的权限属主,非常稳;如果系统没有truncate,就用:
: > "$file"冒号是 shell 内建的空操作,> file把文件截断为空,效果一样。但这种方式要求当前用户对文件有写权限,所以脚本里要确保以 root 或日志目录属主身份运行。
5. 让脚本真正“自动”——定时调度、防重入和告警联动
脚本写好只是第一步,让它按节奏自动跑才是"自动清理"的本体。我这里有一套完整的落地套路。
5.1 crontab 配置示例
推荐早上非业务高峰跑一次,凌晨 3 点到 4 点之间最合适:
# 日志清理示例 0 3 * * * root /bin/bash /opt/scripts/log-cleaner.sh all >> /var/log/log-cleaner-cron.log 2>&1如果设置了磁盘阈值判断(低于 80% 跳过),这个频率不会对系统造成压力。如果日志增长速度更快,可以在 14 点再加一次:
0 14 * * * root /bin/bash /opt/scripts/log-cleaner.sh app >> /var/log/log-cleaner-cron.log 2>&1应用日志单独跑一次app模式,只处理应用目录,不影响系统日志。
5.2 防止脚本重入的锁机制
crontab 的定时任务不保证不重叠。如果上一次清理因为磁盘 IO 慢没有结束,下一次任务又被触发,两个脚本同时truncate文件,不仅没必要,还可能导致日志短暂写乱。解决方案是用flock给脚本加锁:
#!/usr/bin/env bash exec 9<> /var/lock/log-cleaner.lock if ! flock -n 9; then echo "another cleaner instance is running, skip" exit 0 fi # 后续是真正的清理逻辑flock -n表示拿不到锁就立刻退出,不会阻塞等待。锁文件放在/var/lock,重启后自动清空,不用担心残留。
5.3 磁盘使用率告警联动
脚本本身清完日志后,最好加一段检查磁盘水位的逻辑,方便排查"为什么清完还是会报警"。
# 在脚本执行完清理动作后输出当前水位 check_disk_usage() { local partition="$1" local usage usage=$(df "$partition" | awk 'NR==2 {print $5}' | tr -d '%') if [ "$usage" -ge 90 ]; then echo "WARNING: $partition usage is ${usage}%, need manual intervention" else echo "OK: $partition usage is ${usage}%" fi }把这段输出接到log-cleaner-cron.log,再用你自己的监控系统扫这个日志文件里的 WARNING 关键字,就能实现"自动清理 + 清理失败自动告警"。我在实际部署时还加了一步:把每天的磁盘水位追加到本地一个csv文件,后续压测或排障时能快速看到日志清理的效果线。
6. 进阶:Python 改写、轮转前置和彻底告别手写脚本
shell 脚本简单直接,但维护到一定程度后会有几个痛点:find的参数组合越来越复杂、不同发行版之间行为差异大、没有结构化的输出结果。如果你需要在多台机器上统一管理日志清理,可以考虑下面几种进阶方案。
6.1 什么时候应该用 Python 或替代工具重写
“要不要用 Python 重写”的衡量标准很简单:你的清理策略里,规则是否已经复杂到 shell 里全是 if/else?如果只是保留天数不同、目录不同,shell 完全够用。但如果你要做正则匹配日志名称、按文件日期解析、将清理结果上报到监控平台,Python 的os.path.getmtime、pathlib.glob、re会让代码干净很多。
一个 Python 版本的简化骨架:
#!/usr/bin/env python3 import os import time RETENTION_DAYS = 7 LOG_DIRS = ["/var/log"] PROTECTED = {"/var/log/btmp", "/var/log/wtmp", "/var/log/lastlog"} def clean_dir(directory: str, days: int) -> list[str]: cleaned = [] now = time.time() for root, _, files in os.walk(directory): for name in files: path = os.path.join(root, name) if not name.endswith(".log") or path in PROTECTED: continue age_days = (now - os.path.getmtime(path)) / 86400 if age_days > days: open(path, "w").close() cleaned.append(path) return cleanedopen(path, "w").close()在这里等价于 shell 的: > file。注意遍历用os.walk,比 shell 的find更容易控制子目录深度和行为差异。写完之后用python3 log-cleaner.py直接跑,输出可以做成 JSON,配合监控接口非常方便。
但我也要提醒一句:能用 shell 解决的生产问题,不要轻易引入 Python 运行时。部分精简镜像上连 Python 都没有,为了一个清理脚本安装解释器,性价比太低。先评估环境再选型。
6.2 从“事后清理”走向“事前轮转”:集中日志平台与监控水位
日志清理的最终形态不是写一个更聪明的清理脚本,而是尽量让日志不在本地堆积。我和团队后来推动做的一个改造,就是把所有服务日志通过 filebeat / fluentbit 发送到集中的日志平台,本地只保留最近 1~2 天。这样就算脚本哪天挂了,磁盘也不会立刻爆掉。在这个方案落地前,我总结出一套行之有效的本地兜底策略:
- 给应用日志统一加
logrotate轮转,或让应用自己按天切分,不要让单一app.log无限增长。 - 接一个简单的磁盘监控,超过 80% 就提前告警,不要等到 95% 再处理。
- 清理脚本固定每天早上跑一次,保留策略按"7 天 / 15 天 / 30 天"分类,执行结果留日志。
- 每次上线清理脚本时,先在测试环境用
maxdepth=1跑find的-print模式预览命中列表,肉眼确认没问题再真正执行。
我见过太多"日志目录又满了"的工单,大部分其实不是空间不够,而是没有一套稳定的清理节奏。这套脚本和流程在几十台机器上跑下来,稳定运行了很长时间,磁盘水位一直维持在安全线以内。如果你在落地的过程中遇到其他发行版特有的日志路径差异,或者服务进程特殊导致句柄不释放的情况,直接按自己环境调整目录列表和保留天数就行。有一次我碰到一个服务不仅不释放旧句柄,还会在truncate -s 0之后一直写旧偏移量,导致文件空洞越来越大,最后是重启服务才彻底解决。这种非典型情况虽然少见,但真遇到了要记得检查lsof +L1看谁还在握着已删除的文件,处理手段不是死磕脚本,而是先处理服务本身的日志句柄问题——脚本能解决 90% 的常规膨胀,剩下的 10% 总是要具体问题具体分析的。