اسکریپت bash یک فایل متنی است که چند دستور لینوکس را پشت سر هم نگه میدارد تا بهجای تایپ دستی، با یک فرمان اجرا شوند. کافی است خط اول فایل #!/usr/bin/env bash باشد، دستورها را زیرش بنویسید، با chmod +x script.sh اجراییاش کنید و با ./script.sh اجرا کنید. هر کاری که بیش از دو سه بار با دست روی سرور انجام میدهید (پشتیبانگیری، پاککردن لاگهای قدیمی، بررسی بالا بودن سایتها، انتشار نسخهی جدید) نامزد خوبی برای یک اسکریپت است.
ولی بین «اسکریپتی که کار میکند» و «اسکریپتی که میشود به آن اعتماد کرد» فاصله هست. اسکریپت bash بهطور پیشفرض بعد از خطا ادامه میدهد، متغیر خالی را بیصدا قبول میکند و نام فایلِ فاصلهدار را دو تکه میکند. در این مقاله پایهها را مرور میکنیم، روی همین دامها تأکید میکنیم و در آخر یک اسکریپت کامل برای بررسی وضعیت HTTP چند سایت مینویسیم. فرض بر این است که با دستورهای پایهی لینوکس آشنایید.
shebang و اجرای اسکریپت
#!/usr/bin/env bash
echo "سلام از $(hostname)"
خط اول (shebang) به سیستم میگوید فایل را با چه مفسری اجرا کند. #!/usr/bin/env bash اولین bash موجود در PATH را پیدا میکند؛ #!/bin/bash هم روی Ubuntu کاملاً درست است. فقط #!/bin/sh ننویسید اگر از امکانات bash استفاده میکنید: روی Ubuntu، sh در واقع dash است و چیزهایی مثل [[ ]] و آرایه را نمیشناسد.
chmod +x hello.sh
./hello.sh
set -euo pipefail: سه خط بیمه
تقریباً هر اسکریپتی را با این خط شروع کنید:
set -euo pipefail
-e: اگر دستوری با خطا تمام شود، اسکریپت همانجا متوقف میشود. بدون آن، اگرcd /var/www/appشکست بخورد، دستور بعدی (مثلاًrm -rf storage/cache/*) در پوشهی اشتباه اجرا میشود.-u: استفاده از متغیر تعریفنشده خطا است. بدون آن، غلط تایپی در$BACKUP_DRIبیصدا به رشتهی خالی تبدیل میشود وrm -rf "$BACKUP_DRI/"*میشودrm -rf /*.-o pipefail: در یک pipe مثلmysqldump ... | gzip > db.sql.gz، کد خروج بهطور پیشفرض مال آخرین دستور (gzip) است. یعنی اگر dump شکست بخورد، اسکریپت فکر میکند همهچیز درست بوده. باpipefailشکست هر بخش، شکست کل pipe است. در مقالهی پشتیبانگیری دیدیم این یکی چقدر حیاتی است.
استثناهای -e که باید بدانید
-e در شرط if، در while، و در عبارتهای && و || عمل نمیکند؛ چون آنجا شکست بخشی از منطق است. یک دام معروف هم دارد:
set -e
count=0
((count++)) # اسکریپت همینجا بیصدا تمام میشود
مقدار عبارت count++ قبل از افزایش صفر است و (( )) برای صفر کد خروج ۱ برمیگرداند. به جایش بنویسید count=$((count + 1)).
اگر عمداً میخواهید خطای یک دستور نادیده گرفته شود، صریح بنویسید: grep -q pattern file || true.
متغیرها و کوتیشن
name="app" # بدون فاصله دور =
dir="/var/www/$name"
today=$(date +%F) # خروجی دستور در متغیر
echo "deploying to ${dir}/releases/${today}"
قانون طلایی: متغیر را همیشه داخل کوتیشن دوتایی بنویسید: "$file"، نه $file. بدون کوتیشن، bash مقدار را با فاصله تکه میکند و * را هم باز میکند:
file="my report.pdf"
rm $file # تلاش برای پاک کردن دو فایل: my و report.pdf
rm "$file" # درست
کوتیشن تکی ('...') هیچ چیز را باز نمیکند؛ '$HOME' همان متن $HOME است. برای مقدار پیشفرض از ${VAR:-default} استفاده کنید که با set -u هم سازگار است.
آرگومانها
| متغیر | معنی |
|---|---|
$0 |
نام اسکریپت |
$1، $2، … |
آرگومان اول، دوم، … |
$# |
تعداد آرگومانها |
"$@" |
همهی آرگومانها، هر کدام جدا |
$? |
کد خروج آخرین دستور |
#!/usr/bin/env bash
set -euo pipefail
if (( $# < 1 )); then
echo "usage: $0 <domain> [days]" >&2
exit 2
fi
domain="$1"
days="${2:-30}"
echo "checking $domain for $days days"
با set -u، اگر آرگومان اختیاری را مستقیم با $2 بخوانید و داده نشده باشد، خطا میگیرید؛ برای همین ${2:-30}.
شرطها: if و test
if [[ -f "$config" ]]; then
echo "config exists"
elif [[ -d "$config" ]]; then
echo "it is a directory" >&2
exit 1
else
echo "no config, using defaults"
fi
در bash از [[ ]] استفاده کنید؛ نسبت به [ ] (همان دستور test) خطای کمتری دارد و از &&، || و الگوی =~ پشتیبانی میکند. پرکاربردترین آزمونها:
| آزمون | درست است اگر |
|---|---|
-f path |
فایل معمولی باشد |
-d path |
پوشه باشد |
-r path / -w path |
قابل خواندن / نوشتن باشد |
-z "$s" / -n "$s" |
رشته خالی / غیرخالی باشد |
"$a" == "$b" |
دو رشته برابر باشند |
"$s" =~ ^[0-9]+$ |
با regex بخواند |
برای مقایسهی عددی از (( )) استفاده کنید: if (( size > 1000 )); then.
خود if در واقع کد خروج یک دستور را میسنجد، پس این هم درست است: if grep -q "ERROR" app.log; then.
حلقهها
# روی فهرست
for site in example.com example.org; do
echo "$site"
done
# روی فایلها (بدون ls؛ glob با فاصله در نام مشکلی ندارد)
for f in /var/log/app/*.log; do
[[ -e "$f" ]] || continue
gzip "$f"
done
# تا وقتی شرط برقرار است
attempt=1
until curl -sf http://localhost:8080/health > /dev/null; do
(( attempt >= 10 )) && { echo "service did not start" >&2; exit 1; }
attempt=$((attempt + 1))
sleep 3
done
[[ -e "$f" ]] || continue برای حالتی است که هیچ فایلی با الگو نخواند؛ آنوقت bash خود الگو را بهعنوان رشته در $f میگذارد.
خواندن فایل خطبهخط
while IFS= read -r line || [[ -n "$line" ]]; do
echo "line: $line"
done < servers.txt
IFS= فاصلههای ابتدا و انتهای خط را نگه میدارد، -r جلوی تفسیر \ را میگیرد و || [[ -n "$line" ]] خط آخر را هم میخواند اگر فایل با newline تمام نشده باشد. از for line in $(cat file) استفاده نکنید؛ روی فاصلهها میشکند، نه روی خطها.
توابع و کدهای خروج
backup_dir() {
local src="$1"
local dest="$2"
tar -czf "$dest" -C "$(dirname "$src")" "$(basename "$src")"
}
if backup_dir /var/www/app "/var/backups/app-$(date +%F).tar.gz"; then
echo "backup ok"
fi
- متغیرهای داخل تابع را با
localتعریف کنید تا متغیرهای بیرون را بازنویسی نکنند. - آرگومانهای تابع هم با
$1،$2خوانده میشوند. returnکد خروج تابع است (صفر یعنی موفق، ۱ تا ۲۵۵ یعنی خطا)، نه مقدار بازگشتی. برای برگرداندن داده، آن راechoکنید و با$(...)بگیرید.- اسکریپت هم با
exit 0یاexit 1تمام شود؛ cron، systemd و CI همین کد را میخوانند تا بفهمند کار موفق بوده یا نه.
برای پاکسازی در هر حالتی (موفق یا خطا)، trap بگذارید:
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
لاگگرفتن
اسکریپتی که با cron اجرا میشود کسی را ندارد که خروجیاش را ببیند. یک تابع کوچک کافی است:
log() {
printf '%s %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"
}
log "starting backup"
پیامهای خطا را به stderr بفرستید (echo "..." >&2) تا از خروجی عادی جدا باشند.
مثال کامل: بررسی وضعیت HTTP چند سایت
این اسکریپت فهرست آدرسها را از یک فایل میخواند، کد وضعیت HTTP هر کدام را میگیرد، نتیجه را لاگ میکند و اگر حتی یکی خراب بود با کد ۱ خارج میشود:
#!/usr/bin/env bash
# بررسی وضعیت HTTP چند سایت از روی یک فایل فهرست
set -euo pipefail
readonly SITES_FILE="${1:-/etc/check-sites/sites.txt}"
readonly LOG_FILE="${LOG_FILE:-/var/log/check-sites.log}"
readonly TIMEOUT=10
log() {
printf '%s %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"
}
check_site() {
local url="$1"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" "$url") || true
if [[ "$code" =~ ^(2|3)[0-9][0-9]$ ]]; then
log "OK $code $url"
return 0
else
log "FAIL $code $url"
return 1
fi
}
main() {
if [[ ! -r "$SITES_FILE" ]]; then
echo "فایل فهرست پیدا نشد: $SITES_FILE" >&2
exit 2
fi
local total=0 failed=0
while IFS= read -r url || [[ -n "$url" ]]; do
# خط خالی و توضیح را رد کن
if [[ -z "$url" || "$url" == \#* ]]; then
continue
fi
total=$((total + 1))
if ! check_site "$url"; then
failed=$((failed + 1))
fi
done < "$SITES_FILE"
log "done: $total checked, $failed failed"
if (( failed > 0 )); then
exit 1
fi
}
main "$@"
فایل فهرست، یک آدرس در هر خط:
# سایتهای اصلی
https://example.com
https://example.com/api/health
نصب و اجرا:
sudo install -m 755 check-sites /usr/local/bin/check-sites
sudo mkdir -p /etc/check-sites
check-sites /etc/check-sites/sites.txt
echo "exit code: $?"
چند نکته در همین اسکریپت:
curl -w '%{http_code}'فقط کد وضعیت را چاپ میکند و اگر اتصال برقرار نشود000میدهد. چون curl در آن حالت کد خروج غیرصفر دارد وset -eفعال است،|| trueجلوی توقف اسکریپت را میگیرد.- کل منطق در تابع
mainاست و آخر فایلmain "$@"صدا زده میشود؛ اینطوری ترتیب تعریف توابع اهمیتی ندارد. - کد خروج ۲ برای «ورودی اشتباه» و ۱ برای «سایتی خراب است» جدا شده تا در cron یا مانیتورینگ قابل تشخیص باشند.
برای اجرای خودکار هر پنج دقیقه، یک خط در crontab کافی است؛ جزئیات زمانبندی و دامهای محیط cron در مقالهی کرون جاب آمده است. اگر پیامی هم میخواهید، شاخهی FAIL جای مناسبی برای فرستادن هشدار است. برای مانیتورینگ جدیتر، مانیتورینگ سرور لینوکس را ببینید.
shellcheck: بازبین خودکار اسکریپت
shellcheck اسکریپت bash را تحلیل میکند و بیشتر خطاهای بالا را پیش از اجرا پیدا میکند: کوتیشن جاافتاده، متغیر استفادهنشده، cd بدون بررسی خطا و دهها مورد دیگر.
sudo apt install -y shellcheck
shellcheck check-sites
هر هشدار یک کد دارد، مثلاً SC2086 برای متغیر بدون کوتیشن، و توضیح کاملش در ویکی shellcheck هست1. اگر هشداری را آگاهانه نمیخواهید، بالای همان خط بنویسید # shellcheck disable=SC2086. اجرای shellcheck را بهعنوان یک مرحله در CI هم بگذارید تا هیچ اسکریپت بازبینینشدهای وارد مخزن نشود؛ راهاندازی خود CI در CI/CD با GitHub Actions آمده است.
جمعبندی
چکلیست هر اسکریپت bash:
- خط اول
#!/usr/bin/env bashو بلافاصلهset -euo pipefail. - همهی متغیرها داخل
"..."؛ آرگومانهای اختیاری با${2:-default}. - آرگومانها را بررسی کنید و پیام usage بدهید.
- منطق را در تابعها با متغیرهای
localبنویسید. - فایل را با
while IFS= read -rبخوانید، نه باforرویcat. - لاگ با زمان، خطا به
stderrو کد خروج معنادار. - قبل از commit،
shellcheck.
وقتی اسکریپت از حدود صد خط گذشت یا به ساختار دادهی پیچیده نیاز پیدا کرد، وقت آن است که به Python یا زبان اصلی پروژه فکر کنید. ولی برای چسباندن دستورهای سیستمی به هم، bash هنوز سادهترین و همهجا حاضرترین ابزار است.
فهرست کدها در https://www.shellcheck.net/wiki/ و راهنمای کامل bash در
man bash. ↩