☰
Shell脚本实现日志按天切割的完整方案与避坑指南
2026/10/16 4:25:47 网站建设 项目流程

一位朋友在做几个小系统的日志整理时,被单个日志文件动辄几个GB的问题搞得焦头烂额,查起问题来非常吃力。他把需求丢给我,核心就一句话:怎么用Shell把日志按天切到不同文件去。这问题看着简单,但真正落地时涉及时间格式、切割策略、历史清理、容错处理一堆细节。这篇文章就把我实际的写法和踩过的坑完整分享出来。

1. 为什么日志按天切割是最务实的选择

做日志管理的同学应该都有体会,日志文件不切的话,三个月甚至半年前的历史记录和今天的日志混在一个文件里,体积直奔几个GB甚至十几GB。用view或者tail去翻日志,加载起来卡顿严重;用grep搜索某个报错关键字,要在海量数据里扫一遍,慢到怀疑人生。更麻烦的是,某天系统出问题,你想定位到具体某一天的某几个小时,在没有时间切分的情况下,只能自己估算偏移量,然后用sed -n去硬跳,效率极低。

按天切割最直接的好处有三点。第一,单个文件体积可控,一天的量最多几百MB,grep和tail都很流畅。第二,排查问题可以非常精准地定位到日期,比如grep "ERROR" /var/log/app/app-2025-03-18.log,一分钟之内就能圈定异常范围。第三,归档和清理非常自然,删除或者压缩三个月前的旧日志,只需要对文件名做日期比较,一条find命令搞定,不用写复杂的脚本去计算偏移量。

那切割方案怎么选?业界常见的思路大致有三种:用系统自带的logrotate、用第三方工具(比如cronolog)、以及自己写Shell脚本配合crontab调度。之前很多项目用logrotate,它确实很成熟,支持按大小、按时间轮转,配置也不复杂。但在一些自定义场景下,比如日志文件名需要嵌入特定业务标识、切割后需要马上压缩、或者某些服务启动方式比较特殊导致信号通知麻烦,logrotate的灵活度就不太够。所以说自己写一套Shell脚本,配合系统的cron调度,反而是最可控、最容易定制、也最好移植的办法——这也是我推荐大多数人尝试的路线。

自研方案还有一个容易被忽视的好处:不依赖额外软件包。很多内网环境和容器基础镜像都是精简过的,连cronolog和logrotate都没装。而Shell脚本只要有bash、date、mv、gzip这些基础工具就能跑,几乎零依赖。这在部署层面能省掉很多沟通成本。

2. 切割方案的核心思路与流程设计

在写脚本之前,先把整体流程在脑子里推演一遍。按天切割的本质逻辑,就是每天在固定时间点,把当前正在写入的日志文件复制一份归档,并记录下归档日期,然后清空原文件,让服务继续往原文件里写。注意这里用的是“复制后清空”而不是“直接改名”,要说清楚为什么,否则新手非常容易踩坑。

很多服务进程在启动时就持有日志文件的文件描述符,写入时会通过这个文件描述符往后追加内容。如果直接用mv把原文件改名,文件描述符不会跟着名字走,它还是指向原来的inode。改名之后,服务再写日志,内容依然会落到那个已经被移走的文件里,而新创建的以原名命名的文件反而一直没有内容。这就是常见的“改名后日志不写了”的坑。另外一种情况是,如果你先删除文件再创建新文件,旧文件的inode在进程没有关闭它之前不会释放,文件内容也不会消失,但磁盘空间却一直被占用,你以为是清理掉了,结果磁盘还是满的。

所以正确的做法是,先cp一份当前日志内容到带日期的归档文件,然后用: > file或者truncate -s 0 file把原文件变成空文件。这样进程持有的文件描述符不变,写入不会中断,同时归档文件已经拿到了完整的历史数据。这个方案唯一的小瑕疵是切割瞬间可能会有极少量的日志丢失(进程写入正在发生而cp已经执行完的情况下),但对于绝大多数普通应用来说,这种亚秒级的遗漏完全可以忽略。如果你做的是银行交易系统、支付流水那种不容丢失的场景,那就不该用Shell脚本,而应该引入专门的日志采集框架。

切割的时间点怎么定?我一般选在凌晨的00:00:01,也就是0 0 * * *这个cron时间点。为什么选这个时刻,而不是美其名曰“业务低峰期”?夜里相对空闲,切割动作虽然很轻量,但万一当天日志量特别大,cp会占用一部分IO,这个点用户基本不受影响。同时,日志天然按“自然日”来分,凌晨0点整切割,文件名中的日期就是昨天,逻辑一目了然。如果你跑的业务是跨夜的结算任务,0点前后都会有一波高峰,也可以把切割时间往后挪半小时,比如30 0 * * *,根据自己的业务节奏灵活调整就好。

还有一个容易忽略的点:处理cron环境变量。cron运行时的PATH往往很精简,可能只有/usr/bin:/bin,但某些脚本里用到的命令(比如gzip如果装在/usr/local/bin)会找不到,导致脚本静默失败。稳妥的做法是在脚本开头强制定义PATH,或者直接用绝对路径调用关键命令。我习惯在脚本里显式写上export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin,一劳永逸。

3. 完整实现:从基础版到进阶版

先写一个最核心的切割脚本,不搞花活,逻辑直白,适合快速上手。

#!/bin/bash # 日志按天切割脚本 - 基础版 # 适用场景:应用程序持续写入单一日志文件,需要每日切割归档 # 关键变量定义 LOG_DIR="/var/log/myapp" LOG_NAME="app.log" STAMP=$(date +%F) # 当前日期,格式 2025-03-18 ARCHIVE_FILE="${LOG_DIR}/${LOG_NAME%.log}-${STAMP}.log" # 第一步:复制当前日志到归档文件 cp -p "${LOG_DIR}/${LOG_NAME}" "${ARCHIVE_FILE}" # 第二步:清空原日志文件(注意不是删除) : > "${LOG_DIR}/${LOG_NAME}" # 可选:压缩归档文件,节省空间 gzip "${ARCHIVE_FILE}"

这段脚本的每一行都有讲究。date +%F是date +%Y-%m-%d的简写,输出格式稳定,不会因为语言环境不同而出现月份英文缩写。${LOG_NAME%.log}是Shell参数扩展,把app.log中的.log后缀去掉,得到app,这样归档文件名就是app-2025-03-18.log,语义清晰。如果日志文件名是access.log,那归档就是access-2025-03-18.log,一眼能看出对应关系。

cp -p这个-p参数很多人会忽略,但非常重要。它表示保留原始文件的属性(时间戳、所有者等),归档文件的时间戳会被设置为日志文件当前的修改时间,这样你看到归档文件名里的日期和文件实际修改时间是一致的,查问题时会少很多困惑。

然后的: >,这里是Shell内置的空命令:,后面跟一个重定向,作用是打开文件并以空内容覆盖它。对进程来说,这个操作只是把原文件截断长度变为0,文件描述符和inode都没变,所以后续写入会依然正常追加。强调一遍,绝对不要在这里用rm,否则就会出现上一节说的服务继续往已删除文件里写数据的问题。

基础版完成后,我会建议在此基础上加两个能力,一个是不压缩的按天保留策略,一个是手动执行时的时间校验。因为基础版在真实环境中只能算“能跑”,距离“好维护”还有一段距离。

#!/bin/bash # 日志按天切割脚本 - 进阶版 # 新增能力:压缩+自动清理N天前的旧日志+手动执行时的时间校验 LOG_DIR="/var/log/myapp" LOG_NAME="app.log" RETENTION_DAYS=90 # 手动执行时可以通过环境变量传入日期,比如 STAMP=2025-03-17 ./cut_log.sh if [ -z "$STAMP" ]; then STAMP=$(date +%F) fi ARCHIVE_FILE="${LOG_DIR}/${LOG_NAME%.log}-${STAMP}.log" # 保证目标目录存在 [ -d "$LOG_DIR" ] || { echo "ERROR: $LOG_DIR 不存在"; exit 1; } # 切割 cp -p "${LOG_DIR}/${LOG_NAME}" "${ARCHIVE_FILE}" : > "${LOG_DIR}/${LOG_NAME}" # 压缩归档 if [ -f "${ARCHIVE_FILE}" ]; then gzip "${ARCHIVE_FILE}" fi # 清理N天前的归档日志(只清理本应用前缀的文件) find "${LOG_DIR}" -name "${LOG_NAME%.log}-*.log.gz" -mtime +${RETENTION_DAYS} -delete

进阶版里加入了保留天数的清理逻辑。find里先用-name "${LOG_NAME%.log}-*.log.gz"限定文件名模式,确保只清理这个应用自己的历史日志,不会误伤同目录下其他文件。-mtime +90的意思是从当前时间往前推90天以前修改过的文件,这里用+号,注意它是“大于90天”而不是“第90天”,边界值差了24小时,放在脚本里影响不大,追求精确可以省略+号直接用-mtime 90。

关于gzip这一步,压缩时机放在切割动作完成之后。如果当天日志量特别大会有一点耗时,但gzip的压缩速度很快,一天几个GB的文本压下来通常也就十几秒,放在凌晨完全无感。压缩后归档文件从几个GB变成几百MB,磁盘空间压力骤减。配合上90天的保留策略,磁盘占用就能维持在一个相对稳定的水位。

4. 调度配置与真实场景落地

脚本写好了,剩下的关键一步就是让系统按计划执行它。这里用crontab来完成调度。

# 编辑当前用户的crontab crontab -e # 加入以下一行 0 0 * * * /usr/local/bin/cut_log.sh >> /var/log/cut_log.log 2>&1

这行cron表达式从左到右依次是:分钟、小时、日、月、周。0 0 * * *表示每天0点0分执行一次。执行路径有两个重点:

第一,脚本要放在固定位置,比如/usr/local/bin/,不要随手丢在家目录里。因为cron执行时的工作目录和登录Shell不一样,如果你在脚本里用了相对路径,大概率找不到文件。写脚本时也建议把所有路径都写成绝对路径。

第二,执行日志要重定向到独立文件,>> /var/log/cut_log.log 2>&1。很多人写cron任务时会忽略输出,结果脚本运行出错时完全无迹可寻。加上这行之后,脚本的stdout和stderr都会被记录到日志文件里,排查时能省很多力气。脚本里我还习惯性地加上关键操作的回显,比如echo "[$(date +'%F %T')] 切割完成: ${ARCHIVE_FILE}.gz",这样看着执行日志就能确认切割动作是否如期发生。

说完配置,再来聊几个真实场景里的细节。

场景一:应用自己会重开日志文件。有些Java应用默认使用log4j2之类的日志框架,配置了滚动策略,日志文件名甚至会带序号。这时候你再去cp一个固定文件名的日志就不一定对了。遇到这种情况,先把应用的日志策略改成“单文件持续写入”或者“只按大小滚动”,然后在Shell层做统一切割;如果应用本身已经按天滚动,那你根本不需要再写Shell脚本,直接让日志框架侧完成归档,Shell脚本只负责压缩和清理就好。盲目叠加两层切割逻辑,会出现重复归档、文件名混乱,影响排查。

场景二:多个服务共享一个日志目录。假设/var/log/myapp/下跑了好几个子服务,每个服务一个日志文件。这种情况下我会建议把切割参数提取出来,做成“一个脚本传参复用”。比如脚本写成cut_log.sh 服务名 日志名,通过$1、$2接收参数,这样cron里写多行,每行调一次脚本传不同参数即可,不需要把同一个逻辑复制粘贴好几份。

场景三:容器环境。容器里通常没有cron守护进程,你也不应该为了跑cron特意在容器里装额外软件。换成在宿主机上配置cron,通过挂载的日志目录卷去执行切割脚本;或者用Kubernetes的CronJob,在Pod里执行同样的脚本。核心脚本本身不需要任何改动,因为它操作的只是文件和目录,不关心容器还是虚拟机。

5. 常见问题与排查技巧实录

按天切割这个需求,本身原理不复杂,但在真实操作中,几乎每个来找我帮忙的朋友都踩过不同的坑。我把最常见的几个问题和排查思路整理成了速查表,直接对照着查就行。

现象可能原因排查与解决
切割后原日志文件不再增长脚本用了mv或rm而不是截断清空检查脚本中是否用了: >或truncate;确认应用进程是否仍指向旧inode,必要时重启应用让日志句柄复位
归档文件日期比实际晚一天cron执行时间设在0点前,或时区设置不对用date命令确认服务器时区;把切割时间改到0点后(如5 0 * * *);手动执行脚本时用STAMP环境变量覆写日期
cron任务没执行但脚本手动执行正常环境变量或PATH问题,脚本依赖的gzip不在cron的PATH中在脚本开头显式声明PATH;cron任务输出重定向到日志文件,查看是否有“command not found”报错
归档文件重复生成或内容为空脚本被重复调度,或者日志文件本来就为空检查crontab是否有重复行;检查应用是否真的产生了日志;为空时cp后原文件为空,归档自然也空,建议在脚本里加一个非空判断
find清理误删了非日志文件-name匹配范围过宽先加ls或find的-print参数预览,确认文件列表无误后再换成-delete
磁盘空间依然缓慢增长应用不断打开多个文件句柄未释放用lsof或ls -l /proc/<pid>/fd查看进程打开的日志文件,确认是否有删除但未释放句柄的情况

除了上面的速查表,还有两个我踩过坑之后总结的心得,值得单独强调。

第一个是空日志文件问题。有时候某个服务当天确实没有产生任何日志,文件为空,切割时cp一个空文件过去,再gzip,就会留下一个几十字节的空压缩包。看着不碍事,但积累多了有点烦人。我现在的脚本里会加一个判断:if [ -s "${LOG_DIR}/${LOG_NAME}" ]; then(-s表示文件存在且非空)再做切割归档,否则直接跳过,这样就不会把空文件也留档。

第二个是脚本幂等性问题。我曾经遇到过cron因为系统重启刚好错过执行时间,于是手动补跑了一次,结果因为命名冲突出现了两个同名归档文件(脚本没做覆盖控制)。后来我在切割前加了一个判断:如果归档文件已存在,就追加时间戳后缀,如app-2025-03-18-150001.log.gz,确保每次手动补跑不会把之前的归档覆盖掉。

# 幂等性处理:如果归档文件已存在,追加时分秒后缀 if [ -e "${ARCHIVE_FILE}.gz" ] || [ -e "${ARCHIVE_FILE}" ]; then ARCHIVE_FILE="${ARCHIVE_FILE%.log}-$(date +%H%M%S).log" fi

这一小段逻辑不复杂,但让我少掉了很多头发。

6. 优化扩展与个人经验总结

基础脚本跑通之后,还可以根据自己的业务情况做一些优化,让整套机制更顺手。

如果你不想让脚本总是从cp整个大文件开始,可以改用tail -c结合日志文件的字节数做增量复制,比如tail -c +1 "${LOG_DIR}/${LOG_NAME}" > "${ARCHIVE_FILE}",这样在大文件场景下能减少IO压力。不过这个做法在极端并发下可能会丢失少量数据,普通场景下用起来没问题。个人建议先用cp方案跑一阵,确实有性能瓶颈再优化,不必一开始就上复杂方案。

另外,如果你的日志写入量特别大,凌晨瞬间切割时cp整个文件可能耗时较长。可以考虑在接近0点时先做一次“软切割”:把0点前5分钟作为分界点,先复制前一天的尾部日志,再清空原文件。但这里涉及跨天的边界判断,脚本复杂度会上一层。大多数情况下,凌晨的负载很低,直接cp一天的量也不会造成明显影响,没必要过度设计。

还有一个小技巧值得分享:把归档文件的权限和属主设置对,避免后续其他账号读不了。脚本里可以在cp之后加一句chown appuser:appgroup "${ARCHIVE_FILE}",或者直接在cp的-p之后追加chmod 640之类的权限控制。否则有些服务以root运行会产生root属主的日志,导致普通运维账号无法查看历史日志。

最后说一下我对这套方案的整体感受。用纯Shell做日志切割,很多人第一反应是“有现成工具为什么要再造轮子”,但真正深入之后会发现,轮子固然好,却未必合自己的脚。自研脚本能让你完全掌控文件名规则、压缩时机、保留策略和清理边界,而且代码总共不过二十多行,没有学习成本,出问题也知道从何查起。我在后续好几个项目里,几乎都是从这套脚本基础上改的,换机器部署基本就是复制粘贴,改一下变量名就上线。

如果你手头正好有日志越滚越大的困扰,我建议别急着引入重型日志系统,先用这个简单方案跑两周试试。日志一天天一清,磁盘空间稳住了,查问题的效率也会有明显提升。按天切割只是日志治理的第一步,等这个机制稳定了,你再去考虑日志采集、全文检索、告警分析那些更高阶的玩法,就有了一个干净整洁的数据底座。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询