現(xiàn)日志按天切割的完整方案與避坑指南)
一位朋友在做幾個(gè)小系統(tǒng)的日志整理時(shí)被單個(gè)日志文件動(dòng)輒幾個(gè)GB的問題搞得焦頭爛額查起問題來非常吃力。他把需求丟給我核心就一句話怎么用Shell把日志按天切到不同文件去。這問題看著簡單但真正落地時(shí)涉及時(shí)間格式、切割策略、歷史清理、容錯(cuò)處理一堆細(xì)節(jié)。這篇文章就把我實(shí)際的寫法和踩過的坑完整分享出來。1. 為什么日志按天切割是最務(wù)實(shí)的選擇做日志管理的同學(xué)應(yīng)該都有體會(huì)日志文件不切的話三個(gè)月甚至半年前的歷史記錄和今天的日志混在一個(gè)文件里體積直奔幾個(gè)GB甚至十幾GB。用view或者tail去翻日志加載起來卡頓嚴(yán)重用grep搜索某個(gè)報(bào)錯(cuò)關(guān)鍵字要在海量數(shù)據(jù)里掃一遍慢到懷疑人生。更麻煩的是某天系統(tǒng)出問題你想定位到具體某一天的某幾個(gè)小時(shí)在沒有時(shí)間切分的情況下只能自己估算偏移量然后用sed -n去硬跳效率極低。按天切割最直接的好處有三點(diǎn)。第一單個(gè)文件體積可控一天的量最多幾百M(fèi)Bgrep和tail都很流暢。第二排查問題可以非常精準(zhǔn)地定位到日期比如grep ERROR /var/log/app/app-2025-03-18.log一分鐘之內(nèi)就能圈定異常范圍。第三歸檔和清理非常自然刪除或者壓縮三個(gè)月前的舊日志只需要對(duì)文件名做日期比較一條find命令搞定不用寫復(fù)雜的腳本去計(jì)算偏移量。那切割方案怎么選業(yè)界常見的思路大致有三種用系統(tǒng)自帶的logrotate、用第三方工具比如cronolog、以及自己寫Shell腳本配合crontab調(diào)度。之前很多項(xiàng)目用logrotate它確實(shí)很成熟支持按大小、按時(shí)間輪轉(zhuǎn)配置也不復(fù)雜。但在一些自定義場景下比如日志文件名需要嵌入特定業(yè)務(wù)標(biāo)識(shí)、切割后需要馬上壓縮、或者某些服務(wù)啟動(dòng)方式比較特殊導(dǎo)致信號(hào)通知麻煩logrotate的靈活度就不太夠。所以說自己寫一套Shell腳本配合系統(tǒng)的cron調(diào)度反而是最可控、最容易定制、也最好移植的辦法——這也是我推薦大多數(shù)人嘗試的路線。自研方案還有一個(gè)容易被忽視的好處不依賴額外軟件包。很多內(nèi)網(wǎng)環(huán)境和容器基礎(chǔ)鏡像都是精簡過的連cronolog和logrotate都沒裝。而Shell腳本只要有bash、date、mv、gzip這些基礎(chǔ)工具就能跑幾乎零依賴。這在部署層面能省掉很多溝通成本。2. 切割方案的核心思路與流程設(shè)計(jì)在寫腳本之前先把整體流程在腦子里推演一遍。按天切割的本質(zhì)邏輯就是每天在固定時(shí)間點(diǎn)把當(dāng)前正在寫入的日志文件復(fù)制一份歸檔并記錄下歸檔日期然后清空原文件讓服務(wù)繼續(xù)往原文件里寫。注意這里用的是“復(fù)制后清空”而不是“直接改名”要說清楚為什么否則新手非常容易踩坑。很多服務(wù)進(jìn)程在啟動(dòng)時(shí)就持有日志文件的文件描述符寫入時(shí)會(huì)通過這個(gè)文件描述符往后追加內(nèi)容。如果直接用mv把原文件改名文件描述符不會(huì)跟著名字走它還是指向原來的inode。改名之后服務(wù)再寫日志內(nèi)容依然會(huì)落到那個(gè)已經(jīng)被移走的文件里而新創(chuàng)建的以原名命名的文件反而一直沒有內(nèi)容。這就是常見的“改名后日志不寫了”的坑。另外一種情況是如果你先刪除文件再創(chuàng)建新文件舊文件的inode在進(jìn)程沒有關(guān)閉它之前不會(huì)釋放文件內(nèi)容也不會(huì)消失但磁盤空間卻一直被占用你以為是清理掉了結(jié)果磁盤還是滿的。所以正確的做法是先cp一份當(dāng)前日志內(nèi)容到帶日期的歸檔文件然后用: file或者truncate -s 0 file把原文件變成空文件。這樣進(jìn)程持有的文件描述符不變寫入不會(huì)中斷同時(shí)歸檔文件已經(jīng)拿到了完整的歷史數(shù)據(jù)。這個(gè)方案唯一的小瑕疵是切割瞬間可能會(huì)有極少量的日志丟失進(jìn)程寫入正在發(fā)生而cp已經(jīng)執(zhí)行完的情況下但對(duì)于絕大多數(shù)普通應(yīng)用來說這種亞秒級(jí)的遺漏完全可以忽略。如果你做的是銀行交易系統(tǒng)、支付流水那種不容丟失的場景那就不該用Shell腳本而應(yīng)該引入專門的日志采集框架。切割的時(shí)間點(diǎn)怎么定我一般選在凌晨的00:00:01也就是0 0 * * *這個(gè)cron時(shí)間點(diǎn)。為什么選這個(gè)時(shí)刻而不是美其名曰“業(yè)務(wù)低峰期”夜里相對(duì)空閑切割動(dòng)作雖然很輕量但萬一當(dāng)天日志量特別大cp會(huì)占用一部分IO這個(gè)點(diǎn)用戶基本不受影響。同時(shí)日志天然按“自然日”來分凌晨0點(diǎn)整切割文件名中的日期就是昨天邏輯一目了然。如果你跑的業(yè)務(wù)是跨夜的結(jié)算任務(wù)0點(diǎn)前后都會(huì)有一波高峰也可以把切割時(shí)間往后挪半小時(shí)比如30 0 * * *根據(jù)自己的業(yè)務(wù)節(jié)奏靈活調(diào)整就好。還有一個(gè)容易忽略的點(diǎn)處理cron環(huán)境變量。cron運(yùn)行時(shí)的PATH往往很精簡可能只有/usr/bin:/bin但某些腳本里用到的命令比如gzip如果裝在/usr/local/bin會(huì)找不到導(dǎo)致腳本靜默失敗。穩(wěn)妥的做法是在腳本開頭強(qiáng)制定義PATH或者直接用絕對(duì)路徑調(diào)用關(guān)鍵命令。我習(xí)慣在腳本里顯式寫上export PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin一勞永逸。3. 完整實(shí)現(xiàn)從基礎(chǔ)版到進(jìn)階版先寫一個(gè)最核心的切割腳本不搞花活邏輯直白適合快速上手。#!/bin/bash # 日志按天切割腳本 - 基礎(chǔ)版 # 適用場景應(yīng)用程序持續(xù)寫入單一日志文件需要每日切割歸檔 # 關(guān)鍵變量定義 LOG_DIR/var/log/myapp LOG_NAMEapp.log STAMP$(date %F) # 當(dāng)前日期格式 2025-03-18 ARCHIVE_FILE${LOG_DIR}/${LOG_NAME%.log}-${STAMP}.log # 第一步復(fù)制當(dāng)前日志到歸檔文件 cp -p ${LOG_DIR}/${LOG_NAME} ${ARCHIVE_FILE} # 第二步清空原日志文件注意不是刪除 : ${LOG_DIR}/${LOG_NAME} # 可選壓縮歸檔文件節(jié)省空間 gzip ${ARCHIVE_FILE}這段腳本的每一行都有講究。date %F是date %Y-%m-%d的簡寫輸出格式穩(wěn)定不會(huì)因?yàn)檎Z言環(huán)境不同而出現(xiàn)月份英文縮寫。${LOG_NAME%.log}是Shell參數(shù)擴(kuò)展把a(bǔ)pp.log中的.log后綴去掉得到app這樣歸檔文件名就是app-2025-03-18.log語義清晰。如果日志文件名是access.log那歸檔就是access-2025-03-18.log一眼能看出對(duì)應(yīng)關(guān)系。cp -p這個(gè)-p參數(shù)很多人會(huì)忽略但非常重要。它表示保留原始文件的屬性時(shí)間戳、所有者等歸檔文件的時(shí)間戳?xí)辉O(shè)置為日志文件當(dāng)前的修改時(shí)間這樣你看到歸檔文件名里的日期和文件實(shí)際修改時(shí)間是一致的查問題時(shí)會(huì)少很多困惑。然后的: 這里是Shell內(nèi)置的空命令:后面跟一個(gè)重定向作用是打開文件并以空內(nèi)容覆蓋它。對(duì)進(jìn)程來說這個(gè)操作只是把原文件截?cái)嚅L度變?yōu)?文件描述符和inode都沒變所以后續(xù)寫入會(huì)依然正常追加。強(qiáng)調(diào)一遍絕對(duì)不要在這里用rm否則就會(huì)出現(xiàn)上一節(jié)說的服務(wù)繼續(xù)往已刪除文件里寫數(shù)據(jù)的問題。基礎(chǔ)版完成后我會(huì)建議在此基礎(chǔ)上加兩個(gè)能力一個(gè)是不壓縮的按天保留策略一個(gè)是手動(dòng)執(zhí)行時(shí)的時(shí)間校驗(yàn)。因?yàn)榛A(chǔ)版在真實(shí)環(huán)境中只能算“能跑”距離“好維護(hù)”還有一段距離。#!/bin/bash # 日志按天切割腳本 - 進(jìn)階版 # 新增能力壓縮自動(dòng)清理N天前的舊日志手動(dòng)執(zhí)行時(shí)的時(shí)間校驗(yàn) LOG_DIR/var/log/myapp LOG_NAMEapp.log RETENTION_DAYS90 # 手動(dòng)執(zhí)行時(shí)可以通過環(huán)境變量傳入日期比如 STAMP2025-03-17 ./cut_log.sh if [ -z $STAMP ]; then STAMP$(date %F) fi ARCHIVE_FILE${LOG_DIR}/${LOG_NAME%.log}-${STAMP}.log # 保證目標(biāo)目錄存在 [ -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天前的歸檔日志只清理本應(yīng)用前綴的文件 find ${LOG_DIR} -name ${LOG_NAME%.log}-*.log.gz -mtime ${RETENTION_DAYS} -delete進(jìn)階版里加入了保留天數(shù)的清理邏輯。find里先用-name ${LOG_NAME%.log}-*.log.gz限定文件名模式確保只清理這個(gè)應(yīng)用自己的歷史日志不會(huì)誤傷同目錄下其他文件。-mtime 90的意思是從當(dāng)前時(shí)間往前推90天以前修改過的文件這里用號(hào)注意它是“大于90天”而不是“第90天”邊界值差了24小時(shí)放在腳本里影響不大追求精確可以省略號(hào)直接用-mtime 90。關(guān)于gzip這一步壓縮時(shí)機(jī)放在切割動(dòng)作完成之后。如果當(dāng)天日志量特別大會(huì)有一點(diǎn)耗時(shí)但gzip的壓縮速度很快一天幾個(gè)GB的文本壓下來通常也就十幾秒放在凌晨完全無感。壓縮后歸檔文件從幾個(gè)GB變成幾百M(fèi)B磁盤空間壓力驟減。配合上90天的保留策略磁盤占用就能維持在一個(gè)相對(duì)穩(wěn)定的水位。4. 調(diào)度配置與真實(shí)場景落地腳本寫好了剩下的關(guān)鍵一步就是讓系統(tǒng)按計(jì)劃執(zhí)行它。這里用crontab來完成調(diào)度。# 編輯當(dāng)前用戶的crontab crontab -e # 加入以下一行 0 0 * * * /usr/local/bin/cut_log.sh /var/log/cut_log.log 21這行cron表達(dá)式從左到右依次是分鐘、小時(shí)、日、月、周。0 0 * * *表示每天0點(diǎn)0分執(zhí)行一次。執(zhí)行路徑有兩個(gè)重點(diǎn)第一腳本要放在固定位置比如/usr/local/bin/不要隨手丟在家目錄里。因?yàn)閏ron執(zhí)行時(shí)的工作目錄和登錄Shell不一樣如果你在腳本里用了相對(duì)路徑大概率找不到文件。寫腳本時(shí)也建議把所有路徑都寫成絕對(duì)路徑。第二執(zhí)行日志要重定向到獨(dú)立文件 /var/log/cut_log.log 21。很多人寫cron任務(wù)時(shí)會(huì)忽略輸出結(jié)果腳本運(yùn)行出錯(cuò)時(shí)完全無跡可尋。加上這行之后腳本的stdout和stderr都會(huì)被記錄到日志文件里排查時(shí)能省很多力氣。腳本里我還習(xí)慣性地加上關(guān)鍵操作的回顯比如echo [$(date %F %T)] 切割完成: ${ARCHIVE_FILE}.gz這樣看著執(zhí)行日志就能確認(rèn)切割動(dòng)作是否如期發(fā)生。說完配置再來聊幾個(gè)真實(shí)場景里的細(xì)節(jié)。場景一應(yīng)用自己會(huì)重開日志文件。有些Java應(yīng)用默認(rèn)使用log4j2之類的日志框架配置了滾動(dòng)策略日志文件名甚至?xí)蛱?hào)。這時(shí)候你再去cp一個(gè)固定文件名的日志就不一定對(duì)了。遇到這種情況先把應(yīng)用的日志策略改成“單文件持續(xù)寫入”或者“只按大小滾動(dòng)”然后在Shell層做統(tǒng)一切割如果應(yīng)用本身已經(jīng)按天滾動(dòng)那你根本不需要再寫Shell腳本直接讓日志框架側(cè)完成歸檔Shell腳本只負(fù)責(zé)壓縮和清理就好。盲目疊加兩層切割邏輯會(huì)出現(xiàn)重復(fù)歸檔、文件名混亂影響排查。場景二多個(gè)服務(wù)共享一個(gè)日志目錄。假設(shè)/var/log/myapp/下跑了好幾個(gè)子服務(wù)每個(gè)服務(wù)一個(gè)日志文件。這種情況下我會(huì)建議把切割參數(shù)提取出來做成“一個(gè)腳本傳參復(fù)用”。比如腳本寫成cut_log.sh 服務(wù)名 日志名通過$1、$2接收參數(shù)這樣cron里寫多行每行調(diào)一次腳本傳不同參數(shù)即可不需要把同一個(gè)邏輯復(fù)制粘貼好幾份。場景三容器環(huán)境。容器里通常沒有cron守護(hù)進(jìn)程你也不應(yīng)該為了跑cron特意在容器里裝額外軟件。換成在宿主機(jī)上配置cron通過掛載的日志目錄卷去執(zhí)行切割腳本或者用Kubernetes的CronJob在Pod里執(zhí)行同樣的腳本。核心腳本本身不需要任何改動(dòng)因?yàn)樗僮鞯闹皇俏募湍夸洸魂P(guān)心容器還是虛擬機(jī)。5. 常見問題與排查技巧實(shí)錄按天切割這個(gè)需求本身原理不復(fù)雜但在真實(shí)操作中幾乎每個(gè)來找我?guī)兔Φ呐笥讯疾冗^不同的坑。我把最常見的幾個(gè)問題和排查思路整理成了速查表直接對(duì)照著查就行。現(xiàn)象可能原因排查與解決切割后原日志文件不再增長腳本用了mv或rm而不是截?cái)嗲蹇諜z查腳本中是否用了: 或truncate確認(rèn)應(yīng)用進(jìn)程是否仍指向舊inode必要時(shí)重啟應(yīng)用讓日志句柄復(fù)位歸檔文件日期比實(shí)際晚一天cron執(zhí)行時(shí)間設(shè)在0點(diǎn)前或時(shí)區(qū)設(shè)置不對(duì)用date命令確認(rèn)服務(wù)器時(shí)區(qū)把切割時(shí)間改到0點(diǎn)后如5 0 * * *手動(dòng)執(zhí)行腳本時(shí)用STAMP環(huán)境變量覆寫日期cron任務(wù)沒執(zhí)行但腳本手動(dòng)執(zhí)行正常環(huán)境變量或PATH問題腳本依賴的gzip不在cron的PATH中在腳本開頭顯式聲明PATHcron任務(wù)輸出重定向到日志文件查看是否有“command not found”報(bào)錯(cuò)歸檔文件重復(fù)生成或內(nèi)容為空腳本被重復(fù)調(diào)度或者日志文件本來就為空檢查crontab是否有重復(fù)行檢查應(yīng)用是否真的產(chǎn)生了日志為空時(shí)cp后原文件為空歸檔自然也空建議在腳本里加一個(gè)非空判斷find清理誤刪了非日志文件-name匹配范圍過寬先加ls或find的-print參數(shù)預(yù)覽確認(rèn)文件列表無誤后再換成-delete磁盤空間依然緩慢增長應(yīng)用不斷打開多個(gè)文件句柄未釋放用lsof或ls -l /proc/pid/fd查看進(jìn)程打開的日志文件確認(rèn)是否有刪除但未釋放句柄的情況除了上面的速查表還有兩個(gè)我踩過坑之后總結(jié)的心得值得單獨(dú)強(qiáng)調(diào)。第一個(gè)是空日志文件問題。有時(shí)候某個(gè)服務(wù)當(dāng)天確實(shí)沒有產(chǎn)生任何日志文件為空切割時(shí)cp一個(gè)空文件過去再gzip就會(huì)留下一個(gè)幾十字節(jié)的空壓縮包??粗坏K事但積累多了有點(diǎn)煩人。我現(xiàn)在的腳本里會(huì)加一個(gè)判斷if [ -s ${LOG_DIR}/${LOG_NAME} ]; then-s表示文件存在且非空再做切割歸檔否則直接跳過這樣就不會(huì)把空文件也留檔。第二個(gè)是腳本冪等性問題。我曾經(jīng)遇到過cron因?yàn)橄到y(tǒng)重啟剛好錯(cuò)過執(zhí)行時(shí)間于是手動(dòng)補(bǔ)跑了一次結(jié)果因?yàn)槊麤_突出現(xiàn)了兩個(gè)同名歸檔文件腳本沒做覆蓋控制。后來我在切割前加了一個(gè)判斷如果歸檔文件已存在就追加時(shí)間戳后綴如app-2025-03-18-150001.log.gz確保每次手動(dòng)補(bǔ)跑不會(huì)把之前的歸檔覆蓋掉。# 冪等性處理如果歸檔文件已存在追加時(shí)分秒后綴 if [ -e ${ARCHIVE_FILE}.gz ] || [ -e ${ARCHIVE_FILE} ]; then ARCHIVE_FILE${ARCHIVE_FILE%.log}-$(date %H%M%S).log fi這一小段邏輯不復(fù)雜但讓我少掉了很多頭發(fā)。6. 優(yōu)化擴(kuò)展與個(gè)人經(jīng)驗(yàn)總結(jié)基礎(chǔ)腳本跑通之后還可以根據(jù)自己的業(yè)務(wù)情況做一些優(yōu)化讓整套機(jī)制更順手。如果你不想讓腳本總是從cp整個(gè)大文件開始可以改用tail -c結(jié)合日志文件的字節(jié)數(shù)做增量復(fù)制比如tail -c 1 ${LOG_DIR}/${LOG_NAME} ${ARCHIVE_FILE}這樣在大文件場景下能減少IO壓力。不過這個(gè)做法在極端并發(fā)下可能會(huì)丟失少量數(shù)據(jù)普通場景下用起來沒問題。個(gè)人建議先用cp方案跑一陣確實(shí)有性能瓶頸再優(yōu)化不必一開始就上復(fù)雜方案。另外如果你的日志寫入量特別大凌晨瞬間切割時(shí)cp整個(gè)文件可能耗時(shí)較長。可以考慮在接近0點(diǎn)時(shí)先做一次“軟切割”把0點(diǎn)前5分鐘作為分界點(diǎn)先復(fù)制前一天的尾部日志再清空原文件。但這里涉及跨天的邊界判斷腳本復(fù)雜度會(huì)上一層。大多數(shù)情況下凌晨的負(fù)載很低直接cp一天的量也不會(huì)造成明顯影響沒必要過度設(shè)計(jì)。還有一個(gè)小技巧值得分享把歸檔文件的權(quán)限和屬主設(shè)置對(duì)避免后續(xù)其他賬號(hào)讀不了。腳本里可以在cp之后加一句chown appuser:appgroup ${ARCHIVE_FILE}或者直接在cp的-p之后追加chmod 640之類的權(quán)限控制。否則有些服務(wù)以root運(yùn)行會(huì)產(chǎn)生root屬主的日志導(dǎo)致普通運(yùn)維賬號(hào)無法查看歷史日志。最后說一下我對(duì)這套方案的整體感受。用純Shell做日志切割很多人第一反應(yīng)是“有現(xiàn)成工具為什么要再造輪子”但真正深入之后會(huì)發(fā)現(xiàn)輪子固然好卻未必合自己的腳。自研腳本能讓你完全掌控文件名規(guī)則、壓縮時(shí)機(jī)、保留策略和清理邊界而且代碼總共不過二十多行沒有學(xué)習(xí)成本出問題也知道從何查起。我在后續(xù)好幾個(gè)項(xiàng)目里幾乎都是從這套腳本基礎(chǔ)上改的換機(jī)器部署基本就是復(fù)制粘貼改一下變量名就上線。如果你手頭正好有日志越滾越大的困擾我建議別急著引入重型日志系統(tǒng)先用這個(gè)簡單方案跑兩周試試。日志一天天一清磁盤空間穩(wěn)住了查問題的效率也會(huì)有明顯提升。按天切割只是日志治理的第一步等這個(gè)機(jī)制穩(wěn)定了你再去考慮日志采集、全文檢索、告警分析那些更高階的玩法就有了一個(gè)干凈整潔的數(shù)據(jù)底座。