资讯动态

shell脚本实战:日志归档与定时任务的避坑指南

发布时间:2026/10/21 12:02:17 来源:尧图企业网站定制
1月7号晚上我总算把跨年这一批日志归档完顺手整理了一下这一周反复用到的shell脚本。说实话年年年底年初都是这类活最密集的时候旧的日志要压缩归档新日志要接上年度数据要做汇总还要把一批跑批任务从“手动临时执行”改成“定时自动执行”。这一整套流程里shell脚本是我最趁手的工具不需要专门搭环境一台Linux机器加一个编辑器就能干活尤其是配合grep、sed、awk、find这些老伙计处理几千个文件比我用图形界面快得多。这份笔记不是在罗列shell语法而是这段时间切切实实踩过坑、返过工之后沉淀下来的实操经验。适合正在写脚本处理日志、做数据统计、搞定时任务的运维、后端开发和分析同学哪怕你只是偶尔用命令行批量改文件里面也有一大半内容能直接用上。1. 新年第一周我被shell脚本救了几次1.1 年初批量任务的真实画像每年的1月初我手头的工作会突然出现一个很固定的模式数据侧要去年全量数据的汇总口径业务侧要跨年期间的异常日志线上系统要切换新一轮日志目录。这些事情单个拎出来都不复杂但叠加在一起就非常磨人——你不能真的让一个人坐在终端前面复制粘贴几百条命令。我今年的第一单任务是给去年12月31日到今年1月3日的错误日志做分类统计要求按小时输出错误码频次。刚开始我想的是导入某个报表工具里慢慢筛后来发现源文件有几百个、总量几个G干脆直接写脚本处理。真正动手之后效率和可靠性完全不一样——脚本跑完只需要不到两分钟还能把统计结果直接生成一个文本摘要发给同事。从那以后我给自己定了个规矩凡是重复两次以上的文件操作一律用shell脚本固化下来哪怕一开始只是临时脚本。1.2 这篇笔记的阅读方式我不打算按“变量、循环、函数”这种教科书顺序写更想把这几天真正用到的处理链路拆开从统计日志时的文本处理到归档清理时的文件操作再到定时任务里的环境陷阱最后是几个让我印象深刻的bug复盘。这样读起来更接近一个真实的排错过程。每一小节里我会先写“需求背景”再给“常用写法”最后补充“为什么要这样写而不那样写”。如果你是刚接触shell的小白建议拿一台Linux机器边看边敲如果已经写过一阵子脚本可以直接跳到第4、5、6节那些地方讲的坑最容易在线上翻车。2. 动笔之前先立规矩脚本骨架与安全开关2.1 三段式脚本结构配置区、函数区、主流程很多人写shell脚本是从第一行开始一路写到结束看起来像一篇流水账。小脚本无所谓但一旦超过几十行维护成本会骤增。我现在习惯把脚本分成三段顶部是配置区和环境声明中间是函数定义底部是主流程入口。这种结构让我在改脚本时非常安心不用从头读一遍。一个典型骨架大概长这样#!/usr/bin/env bash # 脚本用途跨年日志归档与统计 # 作者我修改日期2026-01-07 # 配置区 LOG_ROOT/data/logs ARCHIVE_ROOT/data/archive YEAR2025 WEBHOOK_URL # 函数区 log_info() { echo [$(date %Y-%m-%d %H:%M:%S)] $* } archive_files() { local dir$1 local target$2 tar -czf $target -C $dir . } # 主流程 main() { log_info 开始归档 archive_files $LOG_ROOT $ARCHIVE_ROOT/old-${YEAR}.tar.gz log_info 归档完成 } main $配置区单独拎出来的好处是换机器、换路径、换年份时根本不用读函数内部逻辑只改前面的变量就行。函数区把具体动作用有名字的函数封装起来主流程则像在读一份步骤清单。尤其到了1月初我需要同时处理去年和今年的数据只要把开始年份、目录这些参数化一个脚本就能复用而不必复制出七八个长得差不多的文件。2.2 set -euo pipefail这四个字母帮我拦住一半bug如果你现在只愿意记住一个shell技巧我强烈建议记住这个组合set -euo pipefail我个人几乎每个脚本开头都会写上这一行它由四个独立的开关组成作用完全不同set -e脚本中有一条命令返回非零状态时立刻退出避免“前面已经错了后面还在跑”的情况。比如归档失败后还要继续删源文件这就是灾难。set -u使用未定义变量时直接报错退出防止$dir拼错成$dri导致路径变成空字符串最后把根目录下的文件误删。set -o pipefail管道命令的返回值以“最后一个失败命令”为准而不是以“最后一个命令”为准。比如grep xxx.log | wc -l中grep没匹配到内容没有pipefail时管道还会返回成功后面的逻辑可能被蒙骗。上面三个一般写在脚本顶部调试时再临时加set -x输出每条命令的执行过程。不少人觉得加这些开关会让脚本“太敏感”动不动就中断。我反而觉得早期敏感比后期出事好。脚本跑起来本来就是处理大批文件一旦某个环节出问题后面的每一步都可能是在错误的前提下继续加工最后产出的结果你根本不敢信。宁可让它停在出错那一刻。2.3 调试三板斧语法检查、逐步跟踪、关键节点打桩写shell脚本时我最常用的调试手段有三个。第一个是语法检查bash -n script.sh只检查语法不执行。它能在运行前发现缺了fi、多了done一类低级问题。第二个是逐步跟踪bash -x script.sh。它会把每条展开后的命令输出到终端变量值一目了然。比如脚本里写了echo $file你会看到实际展开成echo /data/logs/app-2025-12-31.log如果路径不对当场就能发现。不过-x的输出很啰嗦通常是配合set -e临时开定位到问题后立刻关掉。第三个是我自己习惯的“打桩”法在关键节点加带前缀的日志输出比如echo [DEBUG] 即将处理目录: $SRC_DIR。为了统一格式我会定义一个log_info函数让调试信息、正常信息、错误信息都有固定格式最后还可以重定向到日志文件里。这一步看着笨但线上脚本出问题时你会发现它比任何调试器都靠谱——因为你能看的只有留痕。3. 日志统计手记grep、sed、awk的边界与易翻车点3.1 grep跨年筛选扩展正则与文件范围的拿捏今年最先遇到的场景是从一批日志里筛出跨年时段的数据。文件名形如app-2025-12-30.log、app-2025-12-31.log、app-2026-01-01.log取交集时我一开始写了grep 2025-12-31|2026-01-01 *.log结果什么也匹配不到。原因是基础正则表达式里|是普通字符不是“或”的意思。要么用-E开启扩展正则要么把括号转义grep -E 2025-12-31|2026-01-01 *.log # 或 grep 2025-12-31\|2026-01-01 *.log这是grep最容易翻车的地方每次换到不同系统我都下意识确认一下到底用的是grep还是grep -E。还有一个小技巧如果日志文件名里已经带了日期完全不需要用正则去匹配内容里的日期直接按文件名筛选更快ls /data/logs/ | grep -E 2025-12-3[01]|2026-01-0[1-3]再配合xargs处理比硬读内容快一个量级。筛选内容时我还建议加上上下文参数比如grep -E ERROR|FATAL -B2 -A5很多线上问题光看错误行根本不知道上下文只有前后几行日志拼接起来才能还原现场。3.2 sed改文件原地修改前先问自己备份了没有统计完日志接下来常要做的一件事是脱敏和清理。比如日志里偶发出现一堆敏感参数直接改源文件最方便的就是sedsed -i s/token[A-Za-z0-9_-]*/token***/g user-*.log-i表示原地修改看着很省事但风险也在这里。如果替换表达式写错文件内容被改坏了原文件已经没了你只能翻备份。所以我现在凡是带-i的sed都会写成-i.bak让sed在修改前先留一份.bak备份sed -i.bak s/token[A-Za-z0-9_-]*/token***/g user-*.log处理完确认无误再把.bak统一删掉。另外要提醒一个系统差异Linux的GNU sed和macOS自带的BSD sed对-i参数要求不同BSD版本强制要求后缀直接写sed -i s/.../会报错或者行为不同。如果脚本需要跨平台跑我一般不用-i而是先输出到临时文件再mv覆盖这样整个过程可控sed s/token[A-Za-z0-9_-]*/token***/g user-*.log tmp.clean.log mv tmp.clean.log user-$.log3.3 awk做聚合统计分隔符、空行、列位置的三个陷阱日志按小时统计错误码我用awk居多一个小例子awk {cnt[$5]} END {for (k in cnt) print k, cnt[k]} error.log这个写法本身没什么问题但实际运行时我连续踩了三个坑。第一个是分隔符。默认情况下awk用空格和Tab分割字段但日志格式往往不是单纯空格。比如[2026-01-07 10:23:45] [ERROR] code500方括号和混在一起$5取到的根本不是我要的错误码。这时候要么用-F指定多个分隔符awk -F[][ ] {print $5} error.log要么先用sed把日志规整成“列格式”再喂给awk。第二个坑是空行。日志文件里难免有换行符错乱造成的空行awk会把它也当成一条记录统计里凭空多出“空错误码”。给awk加个过滤条件NF 0只处理至少有一个字段的行。第三个坑是文件里如果有\r换行符会把最后一个字段都带个\r统计结果看起来有两个“500”和“500\r”。这种情况我在Windows传过来的日志里遇到过好几回处理方式是把\r提前过滤掉tr -d \r error.log | awk -F[][] {...}4. 归档与清理findxargs的正确使用姿势4.1 按时间筛选mtime、mmin和“0”的特殊语义归档去年日志最核心的一步是按时间把没用的旧文件挑出来。find算是我最常用的筛选器find /data/logs -name *.log -mtime 30 -type f这里的30意思是“修改时间大于30天”注意它是按24小时为一天来算的。-mtime 0表示“24小时以内修改过的文件”这个“0”的语义很容易被忽略。如果你需要精确到分钟用-mmin 30表示修改时间在30分钟以上。还有一个很实用的参数是-newer可以拿一个基准文件做参照找“比这个文件新”或“比这个文件旧”的所有文件touch /tmp/baseline.date find . -type f -newer /tmp/baseline.date年初归档我通常这样定位“去年”的文件先造一个1月1日0点的基准文件再用! -newer筛选出更旧的文件比按天数推断稳得多不用担心月份天数差异。4.2 处理带空格的文件名-print0与-0是一对搭档每一届shell新手几乎都会被“文件名带空格”坑一次。假设有个文件叫我的 笔记.log中间带空格下面这条命令就会翻车find . -name *.log | xargs rm因为xargs默认把空格当分隔符传给rm的就变成了两个文件“我的”和“笔记.log”轻则删错文件重则误删。正确做法是让find用\0分隔文件名xargs也用\0去读find . -name *.log -print0 | xargs -0 rm同理如果要在shell里遍历文件强烈建议用while IFS read -r而不是for file in $(find ...)后者同样会被空格切断。我个人现在处理文件列表的底线是只要文件名可能包含空格或通配符就统一用find ... -print0 | xargs -0这个组合不给自己留侥幸空间。4.3 一个完整的归档闭环打包、校验、删除、留痕年初归档不能只执行“打包”这一步后面的校验、清理、留痕同样重要。我整理了一个比较完整的脚本片段思路是大文件先校验列表再打包然后对比校验值最后才允许删除源文件#!/usr/bin/env bash set -euo pipefail SRC_DIR/data/logs/2025 TAR_FILE/data/archive/2025-all.tar.gz gen_checksum() { find $SRC_DIR -type f -print0 | sort -z | xargs -0 sha256sum } touch $TAR_FILE tar -czf $TAR_FILE -C $(dirname $SRC_DIR) $(basename $SRC_DIR) if gen_checksum | sed s|$SRC_DIR|$TAR_FILE_contents| /tmp/before.sha; then echo checksum generated fi find $SRC_DIR -type f -delete这里的核心是“先校验后删除”打包完成后立刻对源文件和压缩包做一次校验确认两者内容一致再清空原目录。如果压缩包本身坏了脚本会因set -e停在删除之前源文件还在损失可逆。校验指令这段我把它设计成幂等的——重复执行时只会在确认成功后才继续不会把“还没归档完”误判为“可以清理”。在真实生产环境中这类脚本后面还会接一个“留痕”动作把归档时间、文件数量、压缩包路径写进一个archive.log将来要溯源时不用翻聊天记录。5. cron下的shell陷阱为什么定时跑和手动跑不一样5.1 环境变量被清空PATH是最常见的第一个坑脚本在终端里手动执行一切正常塞进cron之后却频繁报“command not found”这是定时任务最经典的翻车现场。原因很简单cron在执行任务时只提供一个最小的环境经常连/usr/local/bin都不在PATH里。你在终端里能用python3、docker、rsync不代表cron环境也能找到这些命令。解决办法不是靠运气而是在脚本顶部主动声明环境export PATH/usr/local/bin:/usr/bin:/bin有些脚本还会依赖JAVA_HOME、PYTHONPATH等变量我会把这些也写进配置区或者cron任务行里。如果非要“加载用户某个配置文件里的完整环境”更稳的方式是在cron命令前显式source对应文件并且把输出重定向到日志比如0 3 * * * /bin/bash /opt/scripts/archive.sh /var/log/archive.cron.log 21把21加上是另一个实战教训不然脚本出错时cron会默认给系统用户发邮件等你回头查日志什么都没有排查难度直接翻倍。5.2 日志轮转的幂等与并发flock锁的简单用法定时任务里最难缠的一类问题是重复执行带来的副作用。比如按小时轮转日志的任务如果前一小时的任务还没跑完新一小时的任务又启动了两个进程同时操作同一个目录很容易出现重复压缩、日志丢失。处理并发最轻量的方案是用flock加文件锁。exec 9/var/lock/archive.lock flock -n 9 || { echo 另一个归档任务还在跑; exit 1; }第一行打开一个锁文件并绑定到文件描述符9第二行尝试获取锁获取失败就直接退出。-n表示非阻塞不加-n的话会等待其他任务释放锁。把这两句放在脚本靠前的位置就能保证同一时间只有一个归档实例在运行。这个思路不只适用于定时任务手动误跑两次同一脚本时也很管用。幂等性方面我还会注意不要让脚本在重复执行时产生完全一样的同名文件。比如日志压缩包带上时间戳源文件在归档成功后才删除这样即使重复执行也只是“空跑”一遍不会破坏已有数据。5.3 失败通知的轻量方式让脚本自己喊救命定时任务是典型的“白天不响半夜搞事”。跑挂了如果没人知道等于白跑。我一般会在脚本最后加一个失败通知逻辑最简单的是发送一条HTTP请求到某个群里内置的机器人接口。这种通用的webhook通知方式不用依赖额外软件几个命令就能实现if [ $? -ne 0 ]; then curl -fsS -H Content-Type: application/json \ -d {msg:archive.sh 执行失败请检查日志} \ $WEBHOOK_URL fi如果团队没有现成的机器人接口也可以退一步只做“cron输出异常检测”周期任务先统一把输出写到固定目录再配一个检查脚本发现文件里有关键错误词就报警。无论哪种方案核心思路都是同一件事——脚本不能只把错误闷在肚子里必须让它主动发声。手写shell脚本的同学往往容易把注意力全放在“跑通”上忽略“失敗可感知”这一环这会在上线后付出更大代价。6. 复盘几个让我印象深刻的shell bug6.1 变量不加引号文件路径被“切开”有一次我写了个清理脚本循环删除一批历史日志。代码写得很简单for f in $(find /data/logs -name *.log -mtime 90); do rm $f done看起来没问题实际执行时却把日志目录里另一个子目录下的文件全删了。原因就是rm $f这行没加引号系统拿到$f后会重新做词法拆分和通配符展开。路径里面有任何空格或特殊字符rm实际收到的就是被拆开的多个参数。改成rm $f之后这个隐患立刻消失。我现在写shell的铁律就是变量一出现该加引号的地方必须加引号不确定就加双引号代价最小。6.2 管道产生的子shell吞掉了我的计数器另一个印象深刻的bug是我统计日志文件行数时想顺手把总数和一个初始值加到一起total0 find /data/logs -name *.log | while read -r f; do total$((total $(wc -l $f))) done echo $total循环内部明明算得对循环一结束total却还是0。原因是管道符右侧的while跑在子shell里它的变量修改不会回传到父shell。解决办法有很多种我最常用的是把管道改成进程替换让循环留在当前shelltotal0 while read -r f; do total$((total $(wc -l $f))) done (find /data/logs -name *.log)如果这个场景发生在bash里进程替换比临时文件更干净。还要提醒一下$((...))里嵌套命令替换时可以写成total$((total lines))先把wc -l结果放进变量可读性更好。6.3 通配符没有匹配时把通配符本身传给了目标命令最后一个bug也是在年初归档时遇到的。脚本里有一条判断是否存在旧日志的命令if ls /data/logs/*.log.bak /dev/null 21; then echo 有备份 fi正常情况下没问题但目录里一个.bak文件都没有时ls会把一个字面量/data/logs/*.log.bak作为参数传进去然后报错“No such file or directory”。由于后面加了21这个错误被吞掉了判断结果还算正确。但如果有人把逻辑反过来写比如删掉匹配不到的通配符就可能真的去删除一个名字带星号的文件非常危险。更稳妥的做法是显式开启nullglob或先判断再处理shopt -s nullglob backup_files(/data/logs/*.log.bak) if [ ${#backup_files[]} -gt 0 ]; then echo 有 ${#backup_files[]} 个备份 finullglob的作用是让没有匹配到的通配符展开成空数组而不是字面量本身。这类问题平时很少触发往往只在“目录正好是空的”那个时刻爆发而且一爆发就是误删或误判。我后来把所有涉及通配符判断的位置都换成了数组加nullglob写法再没出过这类问题。如果只能从这一整篇笔记里留一条经验我会选这句话写shell脚本时始终默认“文件名可能带空格、变量可能为空、命令可能失败、目录可能不存在”然后让脚本在这些坏条件下也能安全停止或明确报错。这比任何花哨的语法技巧都让人安心。下一轮再处理2026年中的日志时我准备把这类归档脚本再提一层尝试用更工程化的方式管理公共函数库把已经验过的安全模式固化下来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑