
一位朋友在做几个小系统的日志整理时被单个日志文件动辄几个GB的问题搞得焦头烂额查起问题来非常吃力。他把需求丢给我核心就一句话怎么用Shell把日志按天切到不同文件去。这问题看着简单但真正落地时涉及时间格式、切割策略、历史清理、容错处理一堆细节。这篇文章就把我实际的写法和踩过的坑完整分享出来。1. 为什么日志按天切割是最务实的选择做日志管理的同学应该都有体会日志文件不切的话三个月甚至半年前的历史记录和今天的日志混在一个文件里体积直奔几个GB甚至十几GB。用view或者tail去翻日志加载起来卡顿严重用grep搜索某个报错关键字要在海量数据里扫一遍慢到怀疑人生。更麻烦的是某天系统出问题你想定位到具体某一天的某几个小时在没有时间切分的情况下只能自己估算偏移量然后用sed -n去硬跳效率极低。按天切割最直接的好处有三点。第一单个文件体积可控一天的量最多几百MBgrep和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_NAMEapp.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_NAMEapp.log RETENTION_DAYS90 # 手动执行时可以通过环境变量传入日期比如 STAMP2025-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 21这行cron表达式从左到右依次是分钟、小时、日、月、周。0 0 * * *表示每天0点0分执行一次。执行路径有两个重点第一脚本要放在固定位置比如/usr/local/bin/不要随手丢在家目录里。因为cron执行时的工作目录和登录Shell不一样如果你在脚本里用了相对路径大概率找不到文件。写脚本时也建议把所有路径都写成绝对路径。第二执行日志要重定向到独立文件 /var/log/cut_log.log 21。很多人写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中在脚本开头显式声明PATHcron任务输出重定向到日志文件查看是否有“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做日志切割很多人第一反应是“有现成工具为什么要再造轮子”但真正深入之后会发现轮子固然好却未必合自己的脚。自研脚本能让你完全掌控文件名规则、压缩时机、保留策略和清理边界而且代码总共不过二十多行没有学习成本出问题也知道从何查起。我在后续好几个项目里几乎都是从这套脚本基础上改的换机器部署基本就是复制粘贴改一下变量名就上线。如果你手头正好有日志越滚越大的困扰我建议别急着引入重型日志系统先用这个简单方案跑两周试试。日志一天天一清磁盘空间稳住了查问题的效率也会有明显提升。按天切割只是日志治理的第一步等这个机制稳定了你再去考虑日志采集、全文检索、告警分析那些更高阶的玩法就有了一个干净整洁的数据底座。