اسکریپت bash یک فایل متنی است که چند دستور لینوکس را پشت سر هم نگه می‌دارد تا به‌جای تایپ دستی، با یک فرمان اجرا شوند. کافی است خط اول فایل #!/usr/bin/env bash باشد، دستورها را زیرش بنویسید، با chmod +x script.sh اجرایی‌اش کنید و با ./script.sh اجرا کنید. هر کاری که بیش از دو سه بار با دست روی سرور انجام می‌دهید (پشتیبان‌گیری، پاک‌کردن لاگ‌های قدیمی، بررسی بالا بودن سایت‌ها، انتشار نسخه‌ی جدید) نامزد خوبی برای یک اسکریپت است.

ولی بین «اسکریپتی که کار می‌کند» و «اسکریپتی که می‌شود به آن اعتماد کرد» فاصله هست. اسکریپت bash به‌طور پیش‌فرض بعد از خطا ادامه می‌دهد، متغیر خالی را بی‌صدا قبول می‌کند و نام فایلِ فاصله‌دار را دو تکه می‌کند. در این مقاله پایه‌ها را مرور می‌کنیم، روی همین دام‌ها تأکید می‌کنیم و در آخر یک اسکریپت کامل برای بررسی وضعیت HTTP چند سایت می‌نویسیم. فرض بر این است که با دستورهای پایه‌ی لینوکس آشنایید.

shebang و اجرای اسکریپت

hello.sh
#!/usr/bin/env bash
echo "سلام از $(hostname)"

خط اول (shebang) به سیستم می‌گوید فایل را با چه مفسری اجرا کند. #!/usr/bin/env bash اولین bash موجود در PATH را پیدا می‌کند؛ #!/bin/bash هم روی Ubuntu کاملاً درست است. فقط #!/bin/sh ننویسید اگر از امکانات bash استفاده می‌کنید: روی Ubuntu، sh در واقع dash است و چیزهایی مثل [[ ]] و آرایه را نمی‌شناسد.

bash
chmod +x hello.sh
./hello.sh

set -euo pipefail: سه خط بیمه

تقریباً هر اسکریپتی را با این خط شروع کنید:

bash
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، و در عبارت‌های && و || عمل نمی‌کند؛ چون آنجا شکست بخشی از منطق است. یک دام معروف هم دارد:

bash
set -e
count=0
((count++))   # اسکریپت همین‌جا بی‌صدا تمام می‌شود

مقدار عبارت count++ قبل از افزایش صفر است و (( )) برای صفر کد خروج ۱ برمی‌گرداند. به جایش بنویسید count=$((count + 1)).

اگر عمداً می‌خواهید خطای یک دستور نادیده گرفته شود، صریح بنویسید: grep -q pattern file || true.

متغیرها و کوتیشن

bash
name="app"            # بدون فاصله دور =
dir="/var/www/$name"
today=$(date +%F)     # خروجی دستور در متغیر
echo "deploying to ${dir}/releases/${today}"

قانون طلایی: متغیر را همیشه داخل کوتیشن دوتایی بنویسید: "$file"، نه $file. بدون کوتیشن، bash مقدار را با فاصله تکه می‌کند و * را هم باز می‌کند:

bash
file="my report.pdf"
rm $file      # تلاش برای پاک کردن دو فایل: my و report.pdf
rm "$file"    # درست

کوتیشن تکی ('...') هیچ چیز را باز نمی‌کند؛ '$HOME' همان متن $HOME است. برای مقدار پیش‌فرض از ${VAR:-default} استفاده کنید که با set -u هم سازگار است.

آرگومان‌ها

متغیر معنی
$0 نام اسکریپت
$1، $2، … آرگومان اول، دوم، …
$# تعداد آرگومان‌ها
"$@" همه‌ی آرگومان‌ها، هر کدام جدا
$? کد خروج آخرین دستور
bash
#!/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

bash
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.

حلقه‌ها

bash
# روی فهرست
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 می‌گذارد.

خواندن فایل خط‌به‌خط

bash
while IFS= read -r line || [[ -n "$line" ]]; do
    echo "line: $line"
done < servers.txt

IFS= فاصله‌های ابتدا و انتهای خط را نگه می‌دارد، -r جلوی تفسیر \ را می‌گیرد و || [[ -n "$line" ]] خط آخر را هم می‌خواند اگر فایل با newline تمام نشده باشد. از for line in $(cat file) استفاده نکنید؛ روی فاصله‌ها می‌شکند، نه روی خط‌ها.

توابع و کدهای خروج

bash
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 بگذارید:

bash
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

لاگ‌گرفتن

اسکریپتی که با cron اجرا می‌شود کسی را ندارد که خروجی‌اش را ببیند. یک تابع کوچک کافی است:

bash
log() {
    printf '%s  %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"
}
log "starting backup"

پیام‌های خطا را به stderr بفرستید (echo "..." >&2) تا از خروجی عادی جدا باشند.

مثال کامل: بررسی وضعیت HTTP چند سایت

این اسکریپت فهرست آدرس‌ها را از یک فایل می‌خواند، کد وضعیت HTTP هر کدام را می‌گیرد، نتیجه را لاگ می‌کند و اگر حتی یکی خراب بود با کد ۱ خارج می‌شود:

/usr/local/bin/check-sites
#!/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 "$@"

فایل فهرست، یک آدرس در هر خط:

/etc/check-sites/sites.txt
# سایت‌های اصلی
https://example.com
https://example.com/api/health

نصب و اجرا:

bash
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 بدون بررسی خطا و ده‌ها مورد دیگر.

bash
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 هنوز ساده‌ترین و همه‌جا حاضرترین ابزار است.

  1. فهرست کدها در https://www.shellcheck.net/wiki/ و راهنمای کامل bash در man bash. ↩