:從核心語法到日志清理與自動化運維)
Shell腳本編程這個概念很多剛入行的朋友一聽就覺得“老”“現(xiàn)在不都Python了嗎還有必要學Shell”但你在真實服務器上跑一次任務就會發(fā)現(xiàn)凡是跟Linux/Unix打交道的地方Shell就是繞不過去的那一層。我見過有人用Python寫幾百行實現(xiàn)批量文件處理最后被同事一個十幾行的Shell腳本輕松搞定效率還更高。這篇文章我不整那些虛的直接用做實操的口吻把Shell腳本編程從底層概念、核心語法、真實案例到排查技巧整個過一遍。前端、后端、運維、測試哪怕你只是偶爾碰一下服務器看完都能立刻上手寫自己的第一個腳本。1. 先搞清楚Shell腳本到底是“誰”它為什么無處不在1.1 從“跟操作系統(tǒng)說話”聊起Shell的本質(zhì)是命令解釋器很多人對Shell有兩個極端誤解一種覺得它特別神秘是某種底層語言另一種覺得它就是“命令行里敲命令”根本談不上編程。其實Shell就是一個命令解釋器它是用戶和操作系統(tǒng)內(nèi)核之間的那層“翻譯官”。你在終端里敲的每條命令都是先交給Shell由它解析、展開、調(diào)用系統(tǒng)接口再把結果顯示給你。Linux系統(tǒng)里常見的Shell有bash、sh、zsh、fish等。其中bashBourne Again Shell是絕大多數(shù)發(fā)行版默認的Shell也是腳本里最通用的解釋器。所謂“Shell腳本編程”本質(zhì)上就是把一串本來要手動敲的命令提前寫進一個文本文件里讓Shell按順序、按條件去執(zhí)行。它不像C語言或Python那樣需要編譯器也不需要裝一大堆依賴只要你有一臺Linux系統(tǒng)天然就能跑這正是它“無處不在”的根本原因。1.2 腳本和“在終端敲命令”的區(qū)別以及它能幫你解決的三類問題在終端一行一行敲命令適合臨時操作但一旦遇到下面這幾類情況就必須上腳本重復性批量任務比如每天要清理日志、每周要備份數(shù)據(jù)庫、每月要匯總報表。手工敲不但效率低還容易漏步驟、敲錯參數(shù)。帶條件和循環(huán)的復雜邏輯比如“磁盤使用率超過80%就發(fā)告警郵件”或“循環(huán)處理目錄下所有文件”。這類帶判斷、循環(huán)、分支的事情手敲根本忙不過來。需要定時或無人值守的任務配合cron定時任務腳本能在凌晨兩點自動執(zhí)行第二天早上你看到結果就行。所以Shell腳本解決的核心問題就是把“手動、零散、易錯”的命令操作變成“自動化、可復用、可排錯”的流程。它適合兩類人一類是系統(tǒng)管理員、運維工程師日常運維自動化離不開它另一類是開發(fā)、測試、數(shù)據(jù)分析師需要批量處理文件、打包發(fā)布、跑數(shù)據(jù)處理流程。就算你主要寫Java或Go部署、構建、日志分析這些環(huán)節(jié)也大概率要用到Shell。2. 動手寫腳本前這幾個底層概念必須吃透2.1 一切皆文件與命令查找路徑PATH為什么“命令找不到”最常發(fā)生Unix/Linux的設計哲學里有句經(jīng)典名言一切皆文件。目錄是文件、磁盤設備是文件、甚至進程間的通信管道也是文件。基于這點Shell腳本里的大部分操作最終都能落到“對文件的讀寫、創(chuàng)建、刪除、查找”上。你理解了這一點再看腳本邏輯就會特別清晰無非是先找到文件再處理內(nèi)容最后輸出結果。那“命令”本身又是什么其實也是一些可執(zhí)行文件它們被放在特定目錄下比如/bin、/usr/bin、/usr/local/bin。當你敲一個命令時Shell會按照一個叫PATH的環(huán)境變量里記錄的路徑順序逐個目錄去查找有沒有這個名字的可執(zhí)行文件。如果沒找到就報“command not found”。提示很多新手寫好腳本一執(zhí)行就提示“xxx: command not found”不是命令真的不存在而是腳本執(zhí)行時的PATH環(huán)境不完整。比如用cron調(diào)度腳本時默認PATH往往只有/bin和/usr/bin你平時能用的/usr/local/bin下的命令就找不到了。解決辦法是在腳本開頭顯式聲明PATH或者直接寫命令的完整路徑。2.2 環(huán)境變量、退出碼與管道腳本的“輸入輸出神經(jīng)”腳本不是孤立運行的它需要讀取系統(tǒng)信息、告訴上層它是成功還是失敗還要能把多個命令串起來。這三件事分別對應三個核心機制環(huán)境變量腳本運行環(huán)境里的“全局參數(shù)”。常見的如HOME當前用戶家目錄、USER當前用戶名、SHELL默認Shell路徑。在腳本里用$變量名引用比如echo $HOME。退出碼Exit Code每個命令執(zhí)行完都會返回一個數(shù)字0代表成功非0代表失敗不同數(shù)字常代表不同錯誤類型。腳本里用$?可以拿到上一條命令的退出碼。這很關鍵因為自動化任務必須靠它判斷“這步到底成沒成功”。管道|把前一個命令的標準輸出作為后一個命令的標準輸入。比如cat access.log | grep ERROR就是把日志內(nèi)容“喂”給grep去篩選。這三者加在一起腳本就有了最基本的“感知能力”能讀環(huán)境、能表狀態(tài)、能串聯(lián)命令。后面寫判斷邏輯時if后面的條件本質(zhì)上就是在檢查命令的退出碼。2.3 shebang與執(zhí)行權限讓腳本“能跑”的第一步經(jīng)??吹絊hell腳本的第一行是#!/bin/bash或#!/bin/sh這叫shebang井號感嘆號。它的作用是指定“用哪個解釋器來執(zhí)行這個腳本”。當你直接執(zhí)行腳本文件時系統(tǒng)會讀這一行然后把腳本文件當作參數(shù)傳給對應的解釋器。要讓腳本可以執(zhí)行還需要有執(zhí)行權限。最簡單的方式是chmod x script.sh。很多新手在這兒卡住腳本明明寫對了執(zhí)行卻提示“Permission denied”就是因為沒有執(zhí)行權限。執(zhí)行Shell腳本有幾種常見方式區(qū)別要清楚bash script.sh顯式用bash解釋執(zhí)行不需要執(zhí)行權限當前Shell的自定義函數(shù)和變量不會帶入子Shell。./script.sh直接執(zhí)行需要執(zhí)行權限并且腳本第一行shebang決定解釋器。source script.sh或. script.sh在當前Shell里執(zhí)行不新起子Shell。常用于加載配置、讓變量影響當前環(huán)境。注意我用bash script.sh來調(diào)試時有個習慣先看腳本里是否用了相對路徑。因為腳本的工作目錄取決于“你在哪里執(zhí)行它”而不是“腳本文件放在哪里”。這是個高頻坑我后面在案例里還會再提。3. 核心語法拆解變量、判斷、循環(huán)、文本處理一次講透3.1 變量與參數(shù)傳遞等號兩邊不能有空格引號有大講究Shell變量的定義非常寬容但有兩個細節(jié)幾乎所有人都會踩一次細節(jié)一等號兩邊不能有空格。寫name 張三會報錯因為Shell會把name當命令、把當參數(shù)。正確寫法是name張三。細節(jié)二引用變量時建議養(yǎng)成加雙引號的習慣。比如echo $name。如果變量里含空格不加引號會被拆成多個參數(shù)。比如filemy report.txt然后cat $file會變成cat my report.txt相當于打開兩個文件肯定報錯。但寫成cat $file就安全。腳本接收外部參數(shù)也常用到一組內(nèi)置變量$0腳本本身的名字$1、$2第一個、第二個位置參數(shù)$#參數(shù)個數(shù)$或$*所有參數(shù)字符串處理是腳本里最高頻的操作。這里分享幾個我常用的寫法strhello.world.txt echo ${str%.txt} # 去掉后綴輸出 hello.world echo ${str#*.} # 去掉左邊直到第一個點輸出 world.txt echo ${str//./_} # 把所有點替換成下劃線輸出 hello_world_txt這種${變量#模式}、${變量%模式}的寫法比起sed處理變量內(nèi)容效率更高是純Shell內(nèi)建能力不用額外起進程。語法稍微有點繞但用熟了之后處理文件名特別順手。3.2 條件判斷、循環(huán)與函數(shù)封裝寫法細節(jié)決定成敗條件判斷if的語法是if [ -f /etc/hosts ]; then echo file exists elif [ -d /tmp ]; then echo dir exists else echo not found fi這里要注意兩個坑第一[后面、]前面必須有空格[ -f /etc/hosts ]如果寫成[-f /etc/hosts]會直接語法報錯。第二then前面要用分號或換行分隔。更現(xiàn)代一點的寫法是用[[ ]]它在bash里更安全支持正則匹配和更復雜的邏輯組合我推薦直接用if [[ $name a* ]]; then echo 以a開頭 fi常用文件判斷參數(shù)-f是否存在且為文件、-d是否目錄、-x是否可執(zhí)行、-s文件是否非空。數(shù)值比較用-eq、-ne、-gt、-lt字符串比較用或。循環(huán)主要有for和while# for 遍歷列表 for ip in 192.168.1.1 192.168.1.2; do ping -c 1 $ip /dev/null echo $ip is up done # while 逐行讀取文件 while read -r line; do echo 處理: $line done ./data.txtwhile read這個寫法是處理文件按行遍歷的標準姿勢。-r參數(shù)可以防止反斜杠被轉(zhuǎn)義解釋處理路徑類內(nèi)容時務必加上。函數(shù)封裝則能把重復代碼收攏log_info() { echo [INFO] $(date %Y-%m-%d %H:%M:%S) $1 } log_info 腳本開始執(zhí)行Shell函數(shù)的兩個特點要牢記一是函數(shù)里的變量默認是全局的跟腳本共用如果你想要局部變量得在定義時用local關鍵字。二是函數(shù)要“先定義后調(diào)用”不像其他語言有提升機制所以函數(shù)定義一般放腳本靠前。3.3 文本處理三劍客grep、sed、awk在腳本里的正確用法Shell腳本里最值錢的能力不是語法花哨而是能用一行命令把“日志文件、配置文件”里的信息抽出來。我平時用得最多的三個工具grep過濾行。# 查日志里的ERROR并帶行號 grep -n ERROR app.log # 忽略大小寫、顯示前后兩行上下文 grep -i -n -C 2 error app.log # 只輸出匹配部分而不是整行 grep -o [0-9]\\.[0-9]\\.[0-9]\\.[0-9]\ app.logsed流編輯替換和刪除。# 把舊的IP替換成新的注意macOS和Linux的 -i 參數(shù)寫法不同 sed -i s/192.168.1.1/192.168.2.1/g config.properties # 只刪除第3到第5行 sed -i 3,5d data.txtawk按列抽取和統(tǒng)計。# 打印第1列和第3列冒號分隔 awk -F: {print $1, $3} /etc/passwd # 按條件統(tǒng)計日志里狀態(tài)碼為500的次數(shù) awk $9 500 {count} END {print count} access.log我的經(jīng)驗是能把一個文本處理需求拆成“grep先圈定范圍、sed做修改、awk做統(tǒng)計”這個流程腳本就已經(jīng)能解決80%的數(shù)據(jù)處理問題。后面案例里我會組合起來用。4. 實操案例從零手寫一個日志清理與備份腳本4.1 需求分析與方案設計先想清楚再動手直接講語法容易讓人原地轉(zhuǎn)圈我拿一個真實項目里的需求來串一遍。某臺業(yè)務服務器上有一個應用會持續(xù)寫日志日志文件按天滾動存在/data/app/logs/下形如app-2024-01-15.log?,F(xiàn)在的痛點是日志越積越多磁盤空間告警頻繁。需要做兩件事找出15天前、7天前的日志分別做壓縮和刪除。保留最近7天的日志不處理7到30天之間的日志壓縮歸檔超過30天的日志直接刪除。方案里用到兩個核心命令find按時間、名字、類型查找文件。tar或gzip壓縮歸檔。4.2 編寫腳本一步一步把邏輯落成代碼我先寫一個基礎版本邏輯是定義日志根目錄、保留天數(shù)、告警閾值。用find找需要處理的老文件。超過30天的直接刪除超過7天的先壓縮。每次操作寫一條帶時間戳的日志記錄方便事后追溯。腳本內(nèi)容如下#!/bin/bash # 配置區(qū) LOG_DIR/data/app/logs ARCHIVE_DAYS7 DELETE_DAYS30 SCRIPT_LOG/var/log/cleanup_script.log # 前置檢查 if [ ! -d $LOG_DIR ]; then echo $(date %Y-%m-%d %H:%M:%S) [ERROR] 日志目錄不存在: $LOG_DIR $SCRIPT_LOG exit 1 fi echo $(date %Y-%m-%d %H:%M:%S) [INFO] 開始清理日志目錄 $LOG_DIR $SCRIPT_LOG # 壓縮 7天前的日志 find $LOG_DIR -name *.log -type f -mtime $ARCHIVE_DAYS -mtime -$DELETE_DAYS | while read -r file; do if gzip -9 $file; then echo $(date %Y-%m-%d %H:%M:%S) [INFO] 壓縮成功: $file.gz $SCRIPT_LOG else echo $(date %Y-%m-%d %H:%M:%S) [ERROR] 壓縮失敗: $file $SCRIPT_LOG fi done # 刪除 30天前的日志 find $LOG_DIR -name *.log* -type f -mtime $DELETE_DAYS -delete echo $(date %Y-%m-%d %H:%M:%S) [INFO] 日志清理完成 $SCRIPT_LOG這段代碼里有幾個細節(jié)值得解釋-mtime 7表示“7天以前修改過的文件”-mtime -30表示“30天之內(nèi)修改過的文件”。兩條組合正好圈出7到30天之間這個區(qū)間。壓縮用gzip -9表示最高壓縮比日志文件文本重復度高壓縮效果明顯。壓縮成功后再刪除原文件避免壓縮一半出問題導致數(shù)據(jù)丟失。-delete是find直接刪除比-exec rm {} \;更安全高效注意它必須放在find命令的最后。4.3 部署調(diào)度用cron讓腳本“自己跑起來”腳本寫好后先手動執(zhí)行一遍驗證效果。確認沒問題后再配置cron定時任務# 每天凌晨2點30分執(zhí)行 30 2 * * * /usr/local/bin/cleanup_log.sh /dev/null 21這里有個非常關鍵的細節(jié)cron環(huán)境下PATH環(huán)境變量極簡腳本里如果用到/usr/local/bin下的命令必須寫絕對路徑或者在腳本開頭顯式設置PATH。我一般在腳本配置區(qū)加一行export PATH/usr/local/bin:/usr/bin:/bin不要小看這一行。很多人的腳本手動執(zhí)行正常但一進cron就“行為異?!笔邪司啪褪荘ATH的問題。另一個部署心得是上線前先在腳本里打開調(diào)試模式跑一次看輸出確認了再關掉。后面第五部分我會細說調(diào)試方法。4.4 這個案例還能怎么擴展真實環(huán)境里清理任務往往沒這么簡單我習慣給它再加幾個能力磁盤空間告警腳本里加一個判斷用df -h取當前使用率超過閾值就調(diào)用一個告警函數(shù)比如寫釘釘/企業(yè)微信機器人接口。異常退出碼檢查每次操作后檢查$?一旦失敗就發(fā)錯誤日志而不是悄然繼續(xù)?;コ怄i防止cron調(diào)度重疊執(zhí)行。實現(xiàn)方法是在腳本開頭創(chuàng)建一個鎖文件比如/var/run/cleanup_log.lock用mkdir的原子性來確保只有一個實例在跑?;コ怄i加鎖的標準寫法是這樣LOCK_DIR/var/run/cleanup_log.lock if ! mkdir $LOCK_DIR 2/dev/null; then echo $(date %Y-%m-%d %H:%M:%S) [WARN] 已有清理任務在執(zhí)行退出 $SCRIPT_LOG exit 0 fi trap rmdir $LOCK_DIR EXITtrap很關鍵它保證腳本不管是正常結束還是中途出錯都會去刪掉鎖目錄避免死鎖。5. 調(diào)試技巧與常見問題排查實錄5.1 最實用的調(diào)試手段bash -x、-n、set -euxo pipefail寫Shell腳本最痛苦的是“報錯看不懂、不報錯結果也不對”。我調(diào)試時基本靠三板斧第一板斧語法檢查不需要執(zhí)行。bash -n script.sh它只檢查語法錯誤不執(zhí)行腳本。有語法問題會立刻提示“l(fā)ine xx: syntax error”改完再跑。第二板斧跟蹤執(zhí)行過程。bash -x script.sh它會打印每一行命令展開后的實際內(nèi)容在每行前面用號標出來。這對排查“變量為什么是空的、命令參數(shù)有沒有傳對”特別有效。第三板斧腳本內(nèi)嵌嚴格模式。我寫稍微復雜一點的腳本時開頭一般是這樣的#!/bin/bash set -euo pipefail這一行的四個參數(shù)分別解決不同問題-e任何一條命令退出碼非0就立刻退出。避免“最后一行成功前面其實已經(jīng)失敗”的假象。-u使用未定義的變量直接報錯而不是當成空字符串。能抓出一堆拼寫錯誤。-o pipefail管道中只要有任何一個命令失敗整條管道就算失敗。否則cmd1 | cmd2的退出碼只取決于cmd2cmd1掛了你也發(fā)現(xiàn)不了。-x把trace開關放開方便看執(zhí)行流程。這個可以單獨在調(diào)試時開。但這類嚴格模式也不是無腦加。比如你的腳本里本來就希望某條命令失敗后繼續(xù)執(zhí)行就需要在命令末尾加|| true或者臨時關閉set -e再用set e恢復。我自己的習慣是清洗數(shù)據(jù)類腳本、有明確重試邏輯的腳本慎用全局-e日常任務腳本該加就加。5.2 線上常碰到的報錯與處理思路把實際運維里反復出現(xiàn)的幾個問題整理成速查表方便對癥下藥報錯現(xiàn)象可能原因解決辦法command not foundPATH不完整或命令沒安裝寫全路徑或腳本開頭export PATHPermission denied腳本沒有執(zhí)行權限chmod x script.sh$\r: command not found腳本在Windows下編輯帶CRLF換行sed -i s/\r$// script.sh或改用LF換行[: missing ]if判斷方括號里空格沒寫對檢查[后、]前是否都有空格變量值為空變量名拼錯或等號兩邊有空格set -u開啟未定義變量檢查用bash -x查看展開結果本地正常cron里不正常PATH不同、環(huán)境變量缺失、執(zhí)行用戶不同cron里顯式用絕對路徑腳本內(nèi)固定PATH日志時間不對或亂碼未設置時區(qū)或LANG環(huán)境變量腳本開頭export TZAsia/Shanghai、export LANGC.UTF-8排查時我通常遵循一個順序先看退出碼再用bash -x看執(zhí)行軌跡接著檢查變量值最后看文件權限和路徑。80%的問題都能在這幾步里定位。6. 腳本風格、可維護性與我個人踩過的那些坑6.1 寫腳本也要講“工程化”命名、注釋、模塊化很多人覺得Shell腳本是“臨時東西”不必講究規(guī)范。但真實情況是腳本進了cron以后可能要在生產(chǎn)環(huán)境跑兩三年。你三個月后再回頭看自己寫的腳本如果沒有清晰注釋真的會忘了當初為什么這么寫。我個人的腳本規(guī)范是這幾條腳本頭部寫明用途、作者、創(chuàng)建時間、最后修改時間、用法示例。配置項全部集中放在腳本頂部用大寫變量名比如LOG_DIR。函數(shù)按功能拆分函數(shù)名統(tǒng)一用動詞名詞如backup_db()、send_alert()。關鍵命令執(zhí)行前echo一句提示信息關鍵步驟后用退出碼判斷并記錄日志。全文統(tǒng)一用bash語法腳本中盡量使用bash而不是sh的兼容子集。6.2 我實際使用中印象最深的幾個教訓寫Shell這么多年來有幾個教訓是我每次分享都要念叨一遍的第一個不要把“等價命令”擅自替換。我早期寫腳本時為了圖省事把find刪除改成了rm -rf $(find ...)。結果某次路徑變量為空命令最終變成了rm -rf /的前半段幸好及時發(fā)現(xiàn)。后來我定了死規(guī)矩凡是刪除操作一律用find -delete并且在腳本里對目錄變量做非空校驗。這一條值得所有人刻進腦子里。第二個處理文件名時永遠記得加引號。有些文件名會帶空格甚至帶換行符。不加引號時循環(huán)遍歷會把一個名字拆成多個。用while read -r file; do配合$file是處理“文件名帶特殊字符”的標準解法。第三個別用sleep硬扛等待任務。最早我寫一個等待后端啟動的腳本用的是sleep 30然后檢查進程。后來同事提醒我應該用循環(huán)探測端口while ! nc -z 127.0.0.1 8080; do sleep 2; done。這樣容器啟動快時不用白等啟動慢時也不用擔心時間不夠。這種“探測式等待”比死等穩(wěn)健得多。第四個腳本里要處理“半途失敗”的善后。比如壓縮日志時壓縮到一半磁盤滿了原文件還完好但.gz文件是殘的。這時如果腳本繼續(xù)執(zhí)行刪除會發(fā)現(xiàn)gzip返回非0直接用連接就不會執(zhí)行刪除。我習慣是if gzip $file; then rm $file; fi確保壓縮成功才刪原文件。6.3 后續(xù)可以繼續(xù)擴展的幾個方向Shell腳本學到這兒基本日常自動化已經(jīng)夠用了。再往上走有幾個方向你可以根據(jù)自己的場景繼續(xù)深入和監(jiān)控系統(tǒng)結合讓腳本輸出標準格式的指標數(shù)據(jù)接入已有的監(jiān)控告警平臺。用Shell做CI/CD流水線腳本很多發(fā)布流程里的構建、測試、部署步驟底層都是Shell腳本在驅(qū)動。學習配套工具鏈比如jq處理JSON、yq處理YAML、parallel做并行處理。這些工具配合Shell能覆蓋更復雜的數(shù)據(jù)加工場景。理解其他腳本語言Shell適合“膠水層”和任務編排但復雜的數(shù)據(jù)結構、算法邏輯還是交給Python、Go這些更合適。真實項目里的最佳實踐往往是“ShellPython”混合Shell負責調(diào)度、調(diào)用、處理文件Python負責重邏輯。我自己現(xiàn)在寫自動化任務時思路已經(jīng)固定了能一條awk解決的絕不上循環(huán)能用find解決的絕不手寫遍歷能用函數(shù)封裝的絕不復制粘貼。腳本寫得越短越清晰出問題的概率就越低。這種“偷懶”其實是最認真的工程態(tài)度——把時間省下來去處理真正需要人處理的事情。