:從命令行到自動化運維的完整套路)
寫Shell腳本這事兒很多人都是被逼出來的。我自己就是先被一串又一串的命令搞得頭大然后發(fā)現(xiàn)“命令行是一條一條喂給電腦的腳本是一次性把規(guī)則告訴電腦”從那以后只要是需要反復(fù)執(zhí)行的活兒我第一反應(yīng)就是“能不能寫個腳本”。Shell腳本說白了就是把一串Linux命令按邏輯組合起來放進一個文件里賦予執(zhí)行權(quán)限后一次跑完。它能干的事非常多批量備份、日志清理、監(jiān)控告警、自動部署、數(shù)據(jù)處理……凡是我們在終端里手動敲過兩遍以上的命令都值得腳本化。這篇文章適合剛接觸Linux沒太久、想從“會敲命令”邁向“會寫腳本”的人也適合那些已經(jīng)寫了幾個腳本但總覺得自己在“硬湊”的人我會把日常積累的套路、坑、調(diào)試方法一起倒出來。1. 我為什么勸你認真學(xué)Shell腳本而不是只靠記憶命令行1.1 從“會敲命令”到“會寫腳本”的分水嶺剛接觸Linux的時候我特別崇拜那些能一口氣敲出一串復(fù)雜命令的人感覺那才是高手。后來發(fā)現(xiàn)真正的高手從來不靠手速他們靠的是把復(fù)雜邏輯固化下來。Shell腳本就是這個“固化”的手段??梢赃@么理解命令行是“手動擋”腳本是“自動駕駛”。手動擋當(dāng)然靈活每個路口你都能自己控制但如果是每天上班通勤的固定路線沒有人會愿意天天手動換擋。腳本存在的意義就是把那些重復(fù)的、固定的、容易忘記操作步驟的任務(wù)交給一個可復(fù)現(xiàn)的文件去執(zhí)行。從“會敲命令”到“會寫腳本”你還需要跨過幾個小臺階理解腳本就是一個“命令的集合”但集合里還有變量、循環(huán)、判斷。搞懂執(zhí)行機制腳本里的每一行本質(zhì)上還是在啟動一個子Shell去執(zhí)行命令。學(xué)會給腳本傳參數(shù)和接收輸出。學(xué)會處理異常命令失敗了是繼續(xù)跑還是停下來。很多人卡在第二個臺階上因為總想著把腳本寫出“程序”的樣子一上來就定義函數(shù)搞面向?qū)ο竽翘捉Y(jié)果把自己繞暈了。我的經(jīng)驗是先用最簡單的順序結(jié)構(gòu)解決眼下問題等腳本寫多了再回頭優(yōu)化結(jié)構(gòu)。1.2 從需求到腳本一個最樸素的分步思路我在寫腳本前后心態(tài)上的轉(zhuǎn)變其實就一句話“先想清楚要干什么再想怎么敲命令。”有一次我需要清理某個目錄下七天前的日志文件手動敲的流程是先看目錄里有哪些文件再篩選七天前的然后刪除最后確認一下磁盤空間有沒有釋放。第二周我又要做同樣的事第三周還是同樣的事我就知道該寫腳本了。這個從“手動操作”到“寫腳本”的轉(zhuǎn)化過程其實可以總結(jié)成四步把步驟寫成大白話找到目錄、篩選舊文件、刪除、報告結(jié)果。把大白話翻譯成命令find、rm或者再加上df。把命令按照運行順序?qū)戇M文件。加參數(shù)支持、加判斷邏輯、加輸出信息讓腳本更健壯。別看這四步簡單絕大多數(shù)腳本卡住不是因為不會寫命令而是卡在第二步和第三步的銜接上。你以為一條命令能搞定實際上命令在交互式終端里能用放到腳本里就各種問題——比如變量沒加引號、忘記處理空格路徑、rm刪錯了目錄。這些問題我下面會專門講到。2. 寫腳本前必須養(yǎng)成的幾個習(xí)慣2.1 Shabang行和權(quán)限兩個最常見的入門坑第一個坑是腳本第一行的#!也就是Shebang行。很多文章會說“必須寫”但沒解釋為什么。其實原理很直接當(dāng)你直接執(zhí)行./myscript.sh時操作系統(tǒng)需要知道用哪個解釋器來解析這個文件里的內(nèi)容。#!/bin/bash就是明確告訴系統(tǒng)“用/bin/bash去跑這個文件”。如果漏了這行系統(tǒng)會默認用當(dāng)前Shell去解釋不同環(huán)境下的行為可能完全不一樣非常容易踩坑。操作層面建議直接寫#!/bin/bash不要用#!/bin/sh。因為不少發(fā)行版里/bin/sh已經(jīng)指向dash或者別的解釋器語法兼容性會差一些我在某臺服務(wù)器上就吃過這個虧本地跑得好好的腳本一放到服務(wù)器上就各種語法報錯查來查去發(fā)現(xiàn)是解釋器不一致導(dǎo)致的。第二個坑是執(zhí)行權(quán)限。很多人寫完腳本一執(zhí)行報Permission denied第一反應(yīng)是“腳本寫錯了”其實只是少了個執(zhí)行權(quán)限。一開始我習(xí)慣用chmod x script.sh去處理后來發(fā)現(xiàn)更穩(wěn)妥的方式是chmod 755 script.sh直接明確所有者有讀寫執(zhí)行權(quán)限其他用戶只有讀和執(zhí)行權(quán)限。這部署到多用戶環(huán)境時能省掉很多不必要的權(quán)限問題。2.2 變量、引號、通配符看著簡單坑里都是過來人Shell腳本里的變量定義nameabc注意等號兩邊不能有空格。這個我想很多初學(xué)的人都遇到過習(xí)慣性寫成name abcShell會把name當(dāng)成一個命令去執(zhí)行然后報command not found。這不是你不夠細心而是Shell語法就是這么“不順手”不踩一下根本記不住。引號是另一個重災(zāi)區(qū)。雙引號里的變量會展開單引號里的內(nèi)容就是純文本。比如nameworld echo hello $name echo hello $name第一次輸出hello world第二次輸出hello $name。這個問題看起來簡單但一旦腳本里要拼接路徑、拼接命令參數(shù)時有沒有加引號導(dǎo)致的差別非常大。特別是文件路徑里帶空格的時候——如果變量不加雙引號Shell會把它拆成多個獨立的詞去處理命令直接就是錯的。通配符*、?、[]也得好小心。用rm *.log的時候Shell會先把*.log匹配到的文件列表展開再交給rm執(zhí)行。但如果目錄下的文件特別多命令行的長度有限制可能直接報錯。更安全的方式是用find去配合處理后面我會給案例。2.3 命名規(guī)范與注釋給三個月后的自己留條活路寫腳本的時候加注釋這件事我真的吃了很多虧才養(yǎng)成習(xí)慣。倒不是有多難就是當(dāng)時總覺得“這是我自己的腳本我肯定記得住”。事實是過三個月再看你很可能完全不記得當(dāng)初為什么用了一個那么奇怪的寫法。我的建議是至少做到這幾點腳本文件命名用小寫字母加下劃線比如backup_logs.sh不要用a.sh這種沒有信息的名字。全局變量用大寫加下劃線比如LOG_DIR函數(shù)內(nèi)的局部變量用小寫。每個函數(shù)上方用兩三行注釋說明它干什么、接收什么參數(shù)。關(guān)鍵命令前加注釋解釋“為什么這么做”特別是那些看起來很繞的寫法。比如我之前寫過一段判斷磁盤使用率的邏輯當(dāng)時覺得很簡單沒加注釋后來回看時一直在糾結(jié)“為什么這里要多一個倒數(shù)第二層的管道”。這種時候才意識到注釋是寫給未來的自己看的不是寫給現(xiàn)在的。3. 一個完整的實操案例日志歸檔腳本3.1 需求梳理先別急著寫代碼前面講了那么多理論這里我拿一個非常典型的場景來說日志歸檔。假設(shè)有這樣一個需求——某應(yīng)用每天會往/var/log/myapp/目錄下寫入日志文件日志按天命名例如app-2025-06-01.log。現(xiàn)在需要寫一個腳本把七天前的日志打包壓縮然后刪除原始文件釋放磁盤空間整個過程要有日志輸出方便事后追蹤。這個需求看起來很常規(guī)但動手之前還是先梳理清楚幾個問題目錄路徑是否固定如果腳本被別人拿去用路徑最好不要硬編碼?!捌咛烨啊痹趺唇缍ò次募锏娜掌谶€是按文件的mtime修改時間我建議按文件名來因為mtime可能因為復(fù)制、修改而變化。要不要保留最近七天的文件要。刪除原始文件前是不是必須先確認壓縮成功必須。腳本如果出錯了怎么讓人知道要有錯誤信息。想清楚這幾點再去寫腳本順序邏輯就非常清晰了。3.2 分步實現(xiàn)每一段代碼都說明白為什么這么寫下面是我后來實際用過的腳本簡化版我分段拆開來解釋。#!/bin/bash # # 日志歸檔腳本將七天前的日志壓縮并刪除原文件 # 用法./archive_logs.sh 日志目錄 LOG_DIR${1:-/var/log/myapp} RETENTION_DAYS7 TIMESTAMP$(date %Y%m%d_%H%M%S) ARCHIVE_DIR${LOG_DIR}/archive LOG_FILE${ARCHIVE_DIR}/archive_${TIMESTAMP}.log這里用${1:-/var/log/myapp}處理參數(shù)如果用戶傳了目錄就用用戶傳的如果沒傳就用默認值。這個寫法是我個人強烈推薦的能讓腳本更通用不用為每一個目錄單獨復(fù)制一份腳本。接下來創(chuàng)建歸檔目錄和日志文件目錄mkdir -p $ARCHIVE_DIR-p的意思是不只創(chuàng)建目錄如果上級目錄不存在也會一并創(chuàng)建而且如果目錄已經(jīng)存在不會報錯。這是寫腳本時很常用的一個選項。然后是核心的查找和壓縮邏輯cd $LOG_DIR || { echo 無法進入目錄 $LOG_DIR; exit 1; } find . -maxdepth 1 -type f -name app-*.log -mtime $RETENTION_DAYS -print0 | while IFS read -r -d file; do base$(basename $file) tar -czf ${ARCHIVE_DIR}/${base}.tar.gz $base if [ $? -eq 0 ]; then rm -f $base echo [OK] 歸檔并刪除: $base else echo [FAIL] 歸檔失敗已保留原文件: $base 2 fi done這里有幾個關(guān)鍵點我要專門講講。第一為什么用find -mtime 7而不是用日期字符串mtime是最近修改時間7表示超過七天了。雖然我前面說過用文件名更準(zhǔn)但實際情況下如果日志文件不是嚴(yán)格按天命名mtime也是能接受的選擇。更嚴(yán)格的做法你可以用! -newermt 7 days ago來處理但這個寫法不同find版本支持情況不同你本地測試通過了就好。第二為什么用-print0while read而不是直接find ... -exec因為日志文件名雖然是我們自己生成的但保不準(zhǔn)會帶空格或者其他特殊字符。-print0會讓find輸出文件名時用空字符而不是換行來分隔配合read -d 就能安全地處理任意文件名。這個習(xí)慣我強烈建議養(yǎng)成能讓你避開很多隱藏很深的坑。第三刪除前一定要確認壓縮成功。我用$?檢查上一條命令的返回值tar成功了是0失敗是非0只有成功了才執(zhí)行rm。這個習(xí)慣特別重要——寧可壓縮失敗留著舊文件也不要刪了原文件后才發(fā)現(xiàn)壓縮包是壞的。3.3 加上容錯和日常運維需要的輸出實戰(zhàn)中還有一個容易忽略的問題就是腳本執(zhí)行過程中如果中途出錯后面的命令還會不會繼續(xù)執(zhí)行默認情況下Shell腳本不會因為某條命令失敗就停下來。這就可能導(dǎo)致壓縮失敗了但刪除還是執(zhí)行了。一個比較穩(wěn)妥的思路是在腳本開頭加set -euo pipefailset -e一旦腳本中任何命令返回非零狀態(tài)立即退出腳本。set -u如果使用了未定義的變量直接報錯退出。set -o pipefail管道命令中如果任何一段失敗整個管道的返回值就是失敗的。這個組合我是在踩過幾次坑之后才真正接受并養(yǎng)成的習(xí)慣。剛開始覺得set -e太“激進”了可能會導(dǎo)致腳本在遇到一些“預(yù)期內(nèi)的小失敗”時直接退出。后來我調(diào)整了寫法明知某條命令可能會失敗、但希望腳本繼續(xù)往下走的命令就在末尾加|| true。這樣既能有安全網(wǎng)又能靈活處理預(yù)期中的失敗。日志輸出方面我的心得是“不要只把消息寫給終端還要落盤”。腳本如果是通過定時任務(wù)跑的執(zhí)行完之后你根本看不到終端輸出所以要把關(guān)鍵信息寫到日志文件里方便事后排查。腳本里我設(shè)置了LOG_FILE但為了簡化就沒在每一行加重定向?qū)嶋H你可以直接把需要落盤的輸出用 ${LOG_FILE} 21追加進去。4. 測試和調(diào)試別讓腳本裸奔上線4.1 用bash -x觀察腳本的每一步“內(nèi)心戲”最開始我寫腳本寫完直接執(zhí)行報錯就盯著錯誤信息猜。后來朋友給我推薦了bash -x直接讓我打開了新世界bash -x ./archive_logs.sh執(zhí)行的時候Shell會把每一行命令展開后的實際內(nèi)容打印到終端比如變量值替換成什么、命令拼接成什么樣都能看得清清楚楚。這功能解釋“腳本怎么回事”的時候太有用了。比如之前我寫了一個循環(huán)拼接字符串的腳本結(jié)果發(fā)現(xiàn)輸出的路徑老是少了一段怎么查都查不出來。后來用bash -x一跑清清楚楚看到變量在循環(huán)里被覆蓋掉了問題一下定位。現(xiàn)在我的排查習(xí)慣是腳本執(zhí)行前先bash -n檢查語法再bash -x看執(zhí)行過程配合起來非常好用。bash -n是只檢查語法不執(zhí)行適合在正式執(zhí)行前先用它過一遍防止低級錯誤。不過它只能查語法查不出運行時才會暴露的問題所以調(diào)試階段的bash -x不能省。4.2 常見報錯信息速查看到這些別再慌平時跑腳本的時候我積累了幾類高頻報錯這里整理成表格方便大家直接對照報錯信息常見原因排查方向command not found命令拼錯了或者環(huán)境變量PATH沒包含命令路徑檢查命令名必要時用絕對路徑Permission denied腳本沒有執(zhí)行權(quán)限chmod x script.shNo such file or directory打開文件時路徑不對或者Shabang行指定的解釋器路徑錯誤檢查#!/bin/bash是否拼對檢查文件路徑unexpected end of file經(jīng)常是少了fi、done、}檢查循環(huán)和判斷語句的結(jié)尾是否配對integer expression expected比較時用了字符串而不是數(shù)字檢查變量里存的是不是數(shù)字unbound variable用了set -u后變量沒定義給變量加默認值或提前定義遇到報錯我的處理順序是先看是哪一行再確定是語法層面還是運行層面然后用bash -x跑一遍看實際的執(zhí)行過程最后再搜索報錯的完整消息。而不是一上來就復(fù)制報錯去搜。4.3 自動化測試的思路讓舊腳本不再“哪天崩了都不知道”另外一個容易被忽略的問題是腳本改了一次之后怎么確定沒改壞尤其是那些放在定時任務(wù)里運行的腳本有時候一個月才出一次問題等發(fā)現(xiàn)問題的時候日志已經(jīng)堆了很久了。我自己用的方法是給腳本做一個簡易的“冒煙測試”用一個臨時目錄模擬真實環(huán)境放上測試文件。把腳本跑一遍檢查是否有錯誤輸出。檢查歸檔包是否生成、原文件是否被清理。這聽起來很樸素但能擋住大部分低級回歸。有條件的話還可以給腳本加一個--dry-run選項只打印即將執(zhí)行的命令而不真正執(zhí)行這對自己排查問題和給別人演示腳本都特別友好。5. 日常高頻場景腳本套路可以直接抄5.1 批量重命名和批量壓縮一個套路走天下日常用得最多的兩大類腳本就是批量重命名和批量壓縮。我這里分享兩個模板思路。批量重命名時核心是“循環(huán)每個文件”和“生成新文件名”#!/bin/bash # 批量將當(dāng)前目錄下的 .txt 文件重命名為 .md for file in *.txt; do base${file%.txt} mv $file ${base}.md done這里${file%.txt}是Shell的參數(shù)展開意思是把變量file末尾的.txt去掉。這種寫法比用sed再去提取文件名要直觀得多。批量壓縮時我遇到過一個問題直接把所有需要壓縮的文件打包成一個包和每個文件單獨打包需求不一樣。常規(guī)做法是# 將所有 .log 文件打包成一個包 tar -czf all_logs.tar.gz *.log # 分別打包每個日期目錄 for dir in 2025-*; do tar -czf ${dir}.tar.gz $dir done需要提醒的是如果目錄下有幾百上千個文件*.log展開可能會超過命令行長度限制。這種場景我一般會改用find配合-print0的方式再結(jié)合前面提到的while read安全性和穩(wěn)定性都會高很多。5.2 定時任務(wù)與腳本的配合別踩PATH和環(huán)境的坑腳本寫好之后最常見的部署方式就是放進cron定時任務(wù)里。但這里有個特別隱蔽的坑cron執(zhí)行腳本時環(huán)境變量和交互式終端里完全不一樣PATH可能非常精簡不包含/usr/local/bin這類路徑。我在這里真實踩過坑某次腳本里用了一個工具交互式終端里跑得好好的但放進cron之后一直報command not found原因就是cron的環(huán)境里沒有那個工具所在的目錄。后來我在腳本開頭加了一行顯式設(shè)置PATHexport PATH/usr/local/bin:/usr/bin:/bin:${PATH}從那以后我的所有腳本只要可能有部署到定時任務(wù)需求的都會在開頭處理一下PATH這是一個簡單的防御性寫法。另外cron里運行的腳本如果有輸出默認會通過郵件發(fā)給本機用戶但很多服務(wù)器根本沒配郵件服務(wù)導(dǎo)致錯誤信息被丟棄。我建議在cron配置里把標(biāo)準(zhǔn)輸出和標(biāo)準(zhǔn)錯誤重定向到日志文件例如30 2 * * * /home/user/scripts/archive_logs.sh /home/user/logs/archive.log 21這樣即使腳本出了問題至少日志里能看到痕跡。5.3 監(jiān)控腳本的寫法取指標(biāo)、做判斷、發(fā)通知很多人以為監(jiān)控腳本很復(fù)雜其實核心就是三件事獲取指標(biāo)、判斷閾值、通知。以磁盤使用率為例#!/bin/bash # 磁盤使用率監(jiān)控超過閾值則輸出告警 THRESHOLD80 CURRENT$(df -h / | awk NR2 {print $5} | sed s/%//) if [ $CURRENT -gt $THRESHOLD ]; then echo 磁盤使用率已超過閾值: ${CURRENT}% # 這里可以接入通知比如寫入告警文件、調(diào)用接口等 else echo 磁盤使用率正常: ${CURRENT}% fi這個腳本里有個小細節(jié)df -h | awk NR2 {print $5}取出的是根分區(qū)的使用率百分比帶一個百分號再用sed s/%//去掉百分號才能和THRESHOLD做數(shù)值比較。這里繞了一下原因就是Shell里的數(shù)字比較不支持百分號兩者之間不是一個數(shù)字類型。監(jiān)控腳本和前面幾個腳本不同它通常要跑很多年所以我覺得最值得花時間的地方不是一上來就寫得很復(fù)雜而是把“收集指標(biāo)”和“發(fā)出告警”這兩件事拆成兩個函數(shù)將來要增加告警渠道時只需要改一個函數(shù)就夠了。6. 我在實際使用中的一些體會說了這么多最后聊一點個人體會。寫腳本這件事說到底看的不是你能寫出多高深的語法而是你能不能在寫之前把流程理清楚。我見過不少人一上來就寫結(jié)果腳本里全是“補丁式”的邏輯這里加一句特殊情況判斷那里加一個臨時變量最后腳本又長又難讀。其實大多數(shù)腳本要解決的場景都是重復(fù)勞動只要把流程拆清楚腳本自然就好寫了。我自己的習(xí)慣是先手工跑一遍命令確認每一步都能得到預(yù)期結(jié)果再改寫成腳本。這個過程能篩掉很多邏輯問題比寫完一堆再調(diào)試要省太多時間。另外腳本里能寫得通用一點就通用一點哪怕只是把“固定路徑”改成“可通過參數(shù)傳入”都會讓腳本的壽命長很多。再分享一個最后的技巧腳本最好在一開始就做“冪等性”設(shè)計也就是同一個腳本多次運行不會因為重復(fù)執(zhí)行而產(chǎn)生錯誤結(jié)果。比如歸檔腳本跑第一次會把七天前的日志歸檔掉跑第二次再運行就不會重復(fù)歸檔同一天的日志因為已經(jīng)刪掉了。這個思路能讓腳本在cron里偶爾被誤觸多次時也安全。Shell腳本的價值不在技術(shù)多高深而在“解放雙手”這四個字。把第一次做覺得麻煩的事記錄下來變成每次都能自動完成的事這個積累過程才是它最有魅力的地方。