توی این درس یاد میگیری اسکریپتی بنویسی که بیصدا خراب نشود: وقتی دستوری شکست خورد، متوقف شود، بگوید کجا و چرا، و آشغال (فایل موقت، قفل، نیمهکارهها) را پشت سرش جا نگذارد. سه کلید معروف set -euo pipefail را یکییکی و با استثناهایشان میشناسی، با trap یک تابع پاکسازی (cleanup) مینویسی که در هر حالتی (موفق، خطا، Ctrl+C) اجرا شود، و کد خروج سفارشی میدهی تا اسکریپتها و cron بفهمند دقیقاً چه شد. آخر درس، اسکریپت بکاپ را به مدیریت خطا و پاکسازی مجهز میکنی.
مسئله: اسکریپتی که بعد از خطا ادامه میدهد
Section titled “مسئله: اسکریپتی که بعد از خطا ادامه میدهد”بهصورت پیشفرض، Bash بعد از شکست یک دستور خیلی راحت میرود سراغ خط بعد. این برای ترمینال تعاملی خوب است، ولی در اسکریپت میتواند فاجعه باشد. این دو خط را ببین:
cd /var/www/myapp/cacherm -rf *اگر پوشهی cache وجود نداشته باشد (اشتباه تایپی، سرور تازه، پوشهای که کسی پاکش کرده)، cd شکست میخورد، اسکریپت در همان پوشهی قبلی میماند و rm -rf * همهچیزِ آنجا را پاک میکند. مشکل اصلی این نیست که cd خطا داد؛ مشکل این است که کسی به خطا گوش نداد.
تشبیه: خط تولید با دکمهی توقف اضطراری
Section titled “تشبیه: خط تولید با دکمهی توقف اضطراری”اسکریپت مثل یک خط تولید است: هر ایستگاه (دستور) کارش را میکند و محصول را به ایستگاه بعد میدهد. خط تولید بدون مدیریت خطا، حتی وقتی یک ایستگاه قطعهی خراب تحویل میدهد، کار را ادامه میدهد و آخرش جعبهی خالی بستهبندی میکند. مدیریت خطا یعنی سه چیز:
- حسگر روی هر ایستگاه که خرابی را ببیند (کد خروج،
set -eوpipefail)، - دکمهی توقف اضطراری که کل خط را نگه دارد (
exitبا کد معنادار)، - تیم نظافت که بعد از توقف، چه عادی چه اضطراری، میز کار را تمیز کند (
trap ... EXIT).
کد خروج: زبان موفقیت و شکست
Section titled “کد خروج: زبان موفقیت و شکست”هر دستور وقتی تمام میشود یک کد خروج (exit status) بین ۰ تا ۲۵۵ برمیگرداند: ۰ یعنی موفق و هر چیز دیگر یعنی شکست (درس شرطها). کد آخرین دستور در $? است. کد خروج خود اسکریپت، اگر صریح exit N ننویسی، کد آخرین دستوری است که اجرا شد؛ این همان چیزی است که cron، systemd، CI و اسکریپتهای دیگر میبینند.
مدیریت خطا در Bash از چند ابزار ساخته میشود:
| ابزار | کار |
|---|---|
set -e (errexit) |
اگر دستوری شکست خورد (و «بررسینشده» بود)، اسکریپت متوقف شود |
set -u (nounset) |
استفاده از متغیر تعریفنشده خطا باشد، نه رشتهی خالی |
set -o pipefail |
کد خروج pipe، کد اولین دستور شکستخورده باشد، نه فقط آخری |
trap 'کد' EXIT |
کدی که موقع خروج اسکریپت (به هر دلیلی) اجرا شود: پاکسازی |
trap 'کد' ERR |
کدی که موقع شکست یک دستور اجرا شود: گزارش خطا |
exit N |
پایان با کد خروج مشخص و معنادار |
مثالهای عملی
Section titled “مثالهای عملی”مثال ۱: اسکریپت بدون مدیریت خطا
Section titled “مثال ۱: اسکریپت بدون مدیریت خطا”اول ببین پیشفرض Bash چطور رفتار میکند. این اسکریپت شبیه همان cd و rm خطرناک است، ولی بهجای rm فقط چاپ میکند که کجا پاک میکرد:
#!/usr/bin/env bash# no-checks.sh: what happens when nobody listens to errorscd /var/www/myapp/cacheecho "I would now run 'rm -rf *' in: $(pwd)"cp /etc/no-such-config.conf ./backup.confecho "backup finished"cd ~/bashlabbash no-checks.shecho "کد خروج اسکریپت: $?"no-checks.sh: line 3: cd: /var/www/myapp/cache: No such file or directoryI would now run 'rm -rf *' in: /home/ali/bashlabcp: cannot stat '/etc/no-such-config.conf': No such file or directorybackup finishedکد خروج اسکریپت: 0هر سه مشکل را ببین:
cdشکست خورد، ولی اسکریپت ادامه داد و حالا در پوشهی خانه است. اگر آنجاrm -rf *بود، همهچیز پاک میشد.cpهم شکست خورد و باز ادامه داد.- چون دستور آخر (
echo) موفق بود، کد خروج کل اسکریپت ۰ است. cron یا CI فکر میکند همهچیز عالی بوده.
مثال ۲: set -e، توقف در اولین خطا
Section titled “مثال ۲: set -e، توقف در اولین خطا”با set -e (اسم بلندش set -o errexit)، Bash بعد از اولین دستور شکستخورده متوقف میشود و کد خروج همان دستور را برمیگرداند:
#!/usr/bin/env bash# with-e.sh: stop at the first failing commandset -ecd /var/www/myapp/cacheecho "I would now run 'rm -rf *' in: $(pwd)"echo "backup finished"cd ~/bashlabbash with-e.shecho "کد خروج اسکریپت: $?"with-e.sh: line 4: cd: /var/www/myapp/cache: No such file or directoryکد خروج اسکریپت: 1دو echo بعد از cd هیچوقت اجرا نشدند و کد خروج ۱ است (کد خروج cd). حالا صدازننده میفهمد چیزی خراب شده.
ولی set -e همهجا اسکریپت را نمیبندد. دستوری که شکستش «بررسی» میشود، اسکریپت را متوقف نمیکند، چون Bash فرض میکند خودت حواست به آن هست:
cd ~/bashlabcat > e-exceptions.sh <<'EOF'#!/usr/bin/env bashset -eif grep -q root /etc/no-such-file 2>/dev/null; then echo "found"; else echo "1) inside if: not stopped"; fifalse || echo "2) after ||: not stopped"false && echo "never printed"echo "3) after &&: not stopped (only the last command of an && list counts)"! falseecho "4) after !: not stopped"while false; do :; doneecho "5) loop condition: not stopped"falseecho "never printed: plain false stops the script"EOFbash e-exceptions.shecho "کد خروج: $?"1) inside if: not stopped2) after ||: not stopped3) after &&: not stopped (only the last command of an && list counts)4) after !: not stopped5) loop condition: not stoppedکد خروج: 1قاعده: set -e روی دستوری که شرطِ if/while/until است، دستورهای قبل از آخرین عضو یک زنجیرهی &&/||، و دستوری که با ! برعکس شده اثر ندارد. فقط false تنها، یعنی شکستی که «کسی بررسیاش نکرد»، اسکریپت را بست. این رفتار درست است: اگر شکست را عمداً با if یا || بررسی کنی، یعنی برایش برنامه داری.
مثال ۳: set -u، متغیر تعریفنشده خطاست
Section titled “مثال ۳: set -u، متغیر تعریفنشده خطاست”بدون set -u (اسم بلند: nounset)، متغیر تعریفنشده بیصدا رشتهی خالی میشود. یک اشتباه تایپی در اسم متغیر میتواند مسیر را عوض کند:
cd ~/bashlabcat > typo.sh <<'EOF'#!/usr/bin/env bashbackup_dir="/home/ali/bashlab/old-backups"mkdir -p "$backup_dir"touch "$backup_dir/old.tar.gz"# typo: backup_dri instead of backup_direcho "would run: rm -rf \"$backup_dri/\"*"EOFecho "--- بدون set -u:"bash typo.shecho "--- با set -u:"bash -u typo.shecho "کد خروج: $?"--- بدون set -u:would run: rm -rf "/"*--- با set -u:typo.sh: line 6: backup_dri: unbound variableکد خروج: 1بدون set -u مسیر نهایی "/"* شد، یعنی کل ریشهی سیستم! (فقط چاپش کردیم.) با -u (اینجا با bash -u، همان اثر set -u در اول فایل) Bash همانجا با unbound variable متوقف شد.
گاهی واقعاً میخواهی ببینی متغیری تعریف شده یا نه (مثلاً آرگومان اختیاری). با set -u از مقدار پیشفرض استفاده کن (درس کار با رشتهها):
cd ~/bashlabcat > optional.sh <<'EOF'#!/usr/bin/env bashset -uname="${1:-World}"echo "Hello, $name"echo "DEBUG is: [${DEBUG:-}]"if [[ -n ${DEBUG:-} ]]; then echo "debug mode on"; fiecho "number of args: $#"echo "first arg without default: $1"EOFbash optional.shecho "کد خروج: $?"echo "---"DEBUG=1 bash optional.sh AliHello, WorldDEBUG is: []number of args: 0optional.sh: line 8: $1: unbound variableکد خروج: 1---Hello, AliDEBUG is: [1]debug mode onnumber of args: 1first arg without default: Ali${1:-World} و ${DEBUG:-} با set -u امناند (اگر تعریف نشده بود، پیشفرض یا رشتهی خالی). $# همیشه تعریف شده است. ولی $1 خالی بدون پیشفرض، خطا داد. نکته: در Bash ۴.۴ به بعد، "${arr[@]}" برای آرایهی خالی با set -u خطا نمیدهد.
مثال ۴: set -o pipefail و PIPESTATUS
Section titled “مثال ۴: set -o pipefail و PIPESTATUS”کد خروج یک pipe بهصورت پیشفرض فقط کد آخرین دستور است. پس شکست وسط pipe دیده نمیشود، حتی با set -e:
cd ~/bashlabcat > pipe.sh <<'EOF'#!/usr/bin/env bashset -emysqldump_fake() { echo "connection refused" >&2; return 2; }mysqldump_fake | gzip > db.sql.gzecho "pipeline status was 0, so set -e did not stop; db.sql.gz size: $(stat -c %s db.sql.gz) bytes"EOFbash pipe.shecho "کد خروج: $?"connection refusedpipeline status was 0, so set -e did not stop; db.sql.gz size: 20 bytesکد خروج: 0این دقیقاً سناریوی اول درس است: dump شکست خورد، gzip با موفقیت یک فایل تقریباً خالی ساخت و اسکریپت با ۰ تمام شد. با pipefail، کد pipe کد آخرین دستور شکستخورده (از راست) میشود:
cd ~/bashlabsed -i 's/^set -e$/set -eo pipefail/' pipe.shbash pipe.shecho "کد خروج: $?"echo "--- PIPESTATUS: کد تکتک عضوهای آخرین pipe"true | false | trueecho "${PIPESTATUS[@]}"set -o pipefailtrue | false | trueecho "با pipefail، \$? = $?"connection refusedکد خروج: 2--- PIPESTATUS: کد تکتک عضوهای آخرین pipe0 1 0با pipefail، $? = 1حالا اسکریپت با کد ۲ (کد mysqldump_fake) متوقف شد. آرایهی PIPESTATUS کد همهی عضوهای آخرین pipe را نگه میدارد (0 1 0)؛ باید بلافاصله بعد از pipe خوانده شود، چون دستور بعدی آن را بازنویسی میکند.
مثال ۵: trap ... EXIT، پاکسازی در هر حالت
Section titled “مثال ۵: trap ... EXIT، پاکسازی در هر حالت”اسکریپتها اغلب فایل یا پوشهی موقت میسازند. اگر وسط کار متوقف شوند (با set -e یا Ctrl+C)، این فایلها میمانند و دیسک را پر میکنند. trap 'دستور' EXIT به Bash میگوید «هر وقت این اسکریپت تمام شد، به هر دلیلی، این را اجرا کن». پوشهی موقت را هم با mktemp بساز تا اسمش یکتا و امن باشد:
#!/usr/bin/env bash# tempwork.sh: always remove the temp directory, whatever happensset -euo pipefail
workdir="$(mktemp -d)"echo "created $workdir"
cleanup() { local status=$? rm -rf "$workdir" echo "cleanup: removed $workdir (exit status $status)"}trap cleanup EXIT
echo "data" > "$workdir/part1"if [[ ${FAIL:-no} == yes ]]; then echo "simulating a failure..." falsefiecho "all steps done"cd ~/bashlabchmod +x tempwork.shecho "=== اجرای موفق:"./tempwork.sh; echo "کد خروج: $?"echo "=== اجرای ناموفق:"FAIL=yes ./tempwork.sh; echo "کد خروج: $?"echo "=== پوشهی موقتی از این اسکریپت در /tmp مانده؟"ls -d /tmp/tmp.* 2>/dev/null | wc -l=== اجرای موفق:created /tmp/tmp.ZQjtZgQg4qall steps donecleanup: removed /tmp/tmp.ZQjtZgQg4q (exit status 0)کد خروج: 0=== اجرای ناموفق:created /tmp/tmp.sVCiNYloY0simulating a failure...cleanup: removed /tmp/tmp.sVCiNYloY0 (exit status 1)کد خروج: 1=== پوشهی موقتی از این اسکریپت در /tmp مانده؟0هر دو بار تابع cleanup اجرا شد و پوشه را پاک کرد. چند نکتهی مهم:
local status=$?اولین خطcleanupاست. وقتی trap EXIT اجرا میشود،$?کد خروجی است که اسکریپت با آن تمام میشود. اگر اولrmرا بزنی،$?مالrmمیشود.- trap EXIT کد خروج را عوض نمیکند: اسکریپت ناموفق، با پاکسازی هم ناموفق (کد ۱) ماند.
trapرا بلافاصله بعد از ساختن منبع بگذار. اگر بینmktempوtrapخطایی رخ دهد، پوشه میماند.mktemp -dیک پوشه با اسم تصادفی (مثل/tmp/tmp.XXXXXXXXXX) و دسترسی فقط برای خودت میسازد. اسم ثابت مثل/tmp/myworkبا اجرای همزمان دو نسخه یا کاربر دیگر تداخل دارد.
مثال ۶: trap روی Ctrl+C و kill
Section titled “مثال ۶: trap روی Ctrl+C و kill”وقتی کاربر Ctrl+C میزند، پروسه سیگنال SIGINT میگیرد؛ kill بهصورت پیشفرض SIGTERM میفرستد. trap EXIT در Bash در این حالتها هم اجرا میشود. این را در یک ترمینال واقعی (داخل tmux) امتحان میکنیم: اسکریپت را اجرا میکنیم، وسط کار Ctrl+C میزنیم و بعد کد خروج را میپرسیم:
cd ~/bashlabcat > slow.sh <<'EOF'#!/usr/bin/env bashset -euo pipefailworkdir="$(mktemp -d)"cleanup() { local status=$? rm -rf "$workdir" echo "cleanup ran (status inside trap: $status); $workdir removed"}trap cleanup EXITecho "working in $workdir ... (press Ctrl+C)"sleep 3echo "finished normally"EOFchmod +x slow.shtmux new-session -d -s lx -x 100 -y 12 "bash --norc"tmux send-keys -t lx 'cd ~/bashlab && ./slow.sh' Entersleep 1.5tmux send-keys -t lx C-csleep 0.5tmux send-keys -t lx 'echo "exit status: $?"' Entersleep 0.5tmux capture-pane -t lx -p | grep -v '^$'tmux kill-session -t lxbash-5.2$ cd ~/bashlab && ./slow.shworking in /tmp/tmp.otIRJBICzd ... (press Ctrl+C)^Ccleanup ran (status inside trap: 0); /tmp/tmp.otIRJBICzd removedbash-5.2$ echo "exit status: $?"exit status: 130bash-5.2$^C همان Ctrl+C است. دو نکته در این خروجی هست:
- پاکسازی اجرا شد و کد خروج واقعی اسکریپت ۱۳۰ است. قاعده: پروسهای که با سیگنال شمارهی N بسته شود، کد ۱۲۸+N میگیرد؛ SIGINT شمارهی ۲ است، پس ۱۳۰.
- ولی داخل trap،
$?عدد ۰ را نشان داد، نه ۱۳۰. وقتی اسکریپت با سیگنال کشته میشود،$?داخل trap EXIT کد آخرین دستور قبلی است و قابل اعتماد نیست.
راهحل: برای سیگنالها trap جدا بگذار که خودش با کد درست exit کند؛ آنوقت trap EXIT کد درست را میبیند:
cd ~/bashlabsed -i 's/^trap cleanup EXIT$/trap cleanup EXIT\ntrap "exit 130" INT\ntrap "exit 143" TERM/' slow.shgrep -n '^trap' slow.shtmux new-session -d -s lx -x 100 -y 12 "bash --norc"tmux send-keys -t lx 'cd ~/bashlab && ./slow.sh' Entersleep 1.5tmux send-keys -t lx C-csleep 0.5tmux send-keys -t lx 'echo "exit status: $?"' Entersleep 0.5tmux capture-pane -t lx -p | grep -v '^$'tmux kill-session -t lxecho "=== با kill (SIGTERM) از بیرون:"start=$SECONDS./slow.sh &pid=$!sleep 1kill "$pid"wait "$pid"echo "exit status after kill: $? (after $((SECONDS - start)) seconds)"9:trap cleanup EXIT10:trap "exit 130" INT11:trap "exit 143" TERMbash-5.2$ cd ~/bashlab && ./slow.shworking in /tmp/tmp.5DAtoyypsK ... (press Ctrl+C)^Ccleanup ran (status inside trap: 130); /tmp/tmp.5DAtoyypsK removedbash-5.2$ echo "exit status: $?"exit status: 130bash-5.2$=== با kill (SIGTERM) از بیرون:working in /tmp/tmp.hv4gleTo3C ... (press Ctrl+C)cleanup ran (status inside trap: 143); /tmp/tmp.hv4gleTo3C removedexit status after kill: 143 (after 3 seconds)حالا داخل trap هم ۱۳۰ دیده شد. با kill کد ۱۴۳ است (۱۲۸ + ۱۵ که شمارهی SIGTERM است). ولی به زمان دقت کن: اسکریپت تا تمام شدن sleep 3 صبر کرد و بعد trap را اجرا کرد. قاعدهی Bash این است: وقتی منتظر یک دستور پیشزمینه است و سیگنالی میرسد که برایش trap داری، trap بعد از پایان آن دستور اجرا میشود. با Ctrl+C این مشکل پیش نمیآید، چون ترمینال SIGINT را به همهی پروسههای پیشزمینه (از جمله sleep) میفرستد.
(فقط kill -9 یعنی SIGKILL را هیچ برنامهای نمیتواند بگیرد؛ با آن trap اجرا نمیشود و فایلهای موقت میمانند. برای همین همیشه اول kill معمولی بزن.)
مثال ۷: trap ... ERR، گزارش «کجا» و «چه»
Section titled “مثال ۷: trap ... ERR، گزارش «کجا» و «چه»”set -e اسکریپت را میبندد ولی نمیگوید کدام خط. با trap ERR (که بعد از هر دستور شکستخورده اجرا میشود) شمارهی خط و خودِ دستور را چاپ کن:
#!/usr/bin/env bash# deploy.sh: report the failing line and commandset -Eeuo pipefail
on_error() { local status=$? echo "ERROR: '$BASH_COMMAND' failed with status $status at line $1" >&2}trap 'on_error $LINENO' ERR
prepare() { echo "preparing release..." cp /etc/hostname /tmp/release-info}
copy_assets() { echo "copying assets..." cp -r ./assets /tmp/release-assets}
preparecopy_assetsecho "deployed"cd ~/bashlabchmod +x deploy.sh./deploy.shecho "کد خروج: $?"rm -f /tmp/release-infopreparing release...copying assets...cp: cannot stat './assets': No such file or directoryERROR: 'cp -r ./assets /tmp/release-assets' failed with status 1 at line 18کد خروج: 1$LINENOشمارهی خطی است که خطا در آن رخ داد و$BASH_COMMANDمتن دستوری که در حال اجرا بود. چون trap با کوتیشن تکی نوشته شده،$LINENOدر لحظهی خطا باز میشود (نه موقع تعریف trap).-E(اسم بلندerrtrace) لازم است تا trap ERR داخل تابعها هم اجرا شود. بدون آن، خطای داخلcopy_assetsگزارش نمیشود و اسکریپت بدون گزارش خط و دستور بسته میشود؛ امتحانش کن:
cd ~/bashlabsed 's/^set -Eeuo pipefail$/set -euo pipefail/' deploy.sh > deploy-no-E.shbash deploy-no-E.shecho "کد خروج: $?"rm -f /tmp/release-infopreparing release...copying assets...cp: cannot stat './assets': No such file or directoryکد خروج: 1- trap ERR فقط گزارش میدهد؛ توقف کار
set -eاست. اگر trap EXIT هم داشته باشی، بعد از ERR آن هم اجرا میشود.
مثال ۸: کد خروج سفارشی و مدیریت خطای قابلانتظار
Section titled “مثال ۸: کد خروج سفارشی و مدیریت خطای قابلانتظار”همهی شکستها یکی نیستند. «آرگومان غلط دادی» با «دیسک پر است» فرق دارد و اسکریپت صدازننده شاید بخواهد برای هر کدام کار متفاوتی بکند. کدهای خروج ثابت و مستند تعریف کن و با exit N برگردان:
#!/usr/bin/env bash# check-site.sh: check a directory before publishing it# Exit codes:# 0 ok# 1 generic error# 2 usage error (wrong arguments)# 3 directory not found# 4 index.html missingset -euo pipefail
readonly E_USAGE=2 E_NODIR=3 E_NOINDEX=4
die() { local code=$1 shift echo "check-site: $*" >&2 exit "$code"}
[[ $# -eq 1 ]] || die "$E_USAGE" "usage: check-site.sh <directory>"dir=$1[[ -d $dir ]] || die "$E_NODIR" "'$dir' is not a directory"[[ -f $dir/index.html ]] || die "$E_NOINDEX" "'$dir/index.html' is missing"
# grep exits 1 when nothing matches: an expected result, not an errorif ! grep -qi '<title>' "$dir/index.html"; then echo "warning: index.html has no <title>"ficount=$(find "$dir" -type f | wc -l)echo "ok: $count files ready in $dir"cd ~/bashlabchmod +x check-site.shmkdir -p site-a site-becho '<h1>hi</h1>' > site-a/index.htmlfor args in "" "nope" "site-b" "site-a"; do ./check-site.sh $args status=$? case $status in 0) echo " -> [$args] published" ;; 2) echo " -> [$args] fix the command line" ;; 3|4) echo " -> [$args] fix the site files (status $status)" ;; *) echo " -> [$args] unknown error $status" ;; esacdonecheck-site: usage: check-site.sh <directory> -> [] fix the command linecheck-site: 'nope' is not a directory -> [nope] fix the site files (status 3)check-site: 'site-b/index.html' is missing -> [site-b] fix the site files (status 4)warning: index.html has no <title>ok: 1 files ready in site-a -> [site-a] published(اینجا $args را عمداً بدون کوتیشن نوشتیم تا رشتهی خالی هیچ آرگومانی نشود؛ استثنای آگاهانه.)
نکتهی مهم این مثال grep: کد ۱ grep یعنی «پیدا نشد»، که خطا نیست. اگر آن را تنها مینوشتی، set -e اسکریپت را میبست. وقتی شکستی قابلانتظار است، صریح بررسیاش کن: با if ! ...، یا cmd || true اگر واقعاً نتیجه برایت مهم نیست، یا cmd || die ... اگر میخواهی پیام بهتری بدهی.
پشت پرده: این گزینهها کجا نگه داشته میشوند؟
Section titled “پشت پرده: این گزینهها کجا نگه داشته میشوند؟”set فقط چند «پرچم» در خود شل را روشن و خاموش میکند. متغیر ویژهی $- پرچمهای تکحرفی فعلی را نشان میدهد و set -o همه را با اسم بلند:
echo "قبل: $-"set -euo pipefailecho "بعد: $-"set -o | grep -E '^(errexit|nounset|pipefail|errtrace)'set +euo pipefailecho "خاموش با +: $-"قبل: hBبعد: ehuBerrexit onerrtrace offnounset onpipefail onخاموش با +: hBe و u به $- اضافه شدند (pipefail حرف کوتاه ندارد). با + بهجای - همان گزینه خاموش میشود؛ گاهی برای یک تکهی کوچک لازم است: set +e; دستور; status=$?; set -e.
trapها هم در حافظهی شلاند. trap -p همهی trapهای فعلی را نشان میدهد و kill -l فهرست سیگنالها و شمارهشان را:
trap 'echo bye' EXITtrap 'echo "error!"' ERRtrap -ptrap - ERRecho "--- بعد از trap - ERR:"trap -ptrap - EXITecho "--- چند سیگنال مهم:"kill -l 2 15 9 1trap -- 'echo bye' EXITtrap -- 'echo "error!"' ERR--- بعد از trap - ERR:trap -- 'echo bye' EXIT--- چند سیگنال مهم:INTTERMKILLHUPkill -l با شماره، اسم سیگنال را میدهد: ۲ ← INT، ۱۵ ← TERM، ۹ ← KILL، ۱ ← HUP. trap - SIGNAL یک trap را برمیدارد. EXIT و ERR سیگنال واقعی سیستمعامل نیستند؛ رویدادهای «شبهسیگنال» خود Bashاند.
$(...) گزینهی -e را به ارث نمیبرد. جایگزینی دستور در یک پروسهی فرزند اجرا میشود و Bash بهصورت پیشفرض errexit را داخل آن خاموش میکند:
bash -c 'set -e; x=$(false; echo "still running inside"); echo "x=[$x]"'echo "--- با shopt -s inherit_errexit:"bash -c 'set -e; shopt -s inherit_errexit; x=$(false; echo "still running inside"); echo "x=[$x]"'echo "کد خروج: $?"x=[still running inside]--- با shopt -s inherit_errexit:کد خروج: 1در حالت اول false داخل $(...) نادیده گرفته شد. با shopt -s inherit_errexit (Bash ۴.۴+)، فرزند هم در اولین خطا میایستد و چون انتساب x=$(...) کد خروج فرزند را میگیرد، کل اسکریپت متوقف شد. خیلیها این خط را هم کنار set -euo pipefail میگذارند.
جدولهای مرجع
Section titled “جدولهای مرجع”گزینههای set برای اسکریپتنویسی امن:
| گزینه | اسم بلند | اثر | خاموشکردن |
|---|---|---|---|
-e |
errexit |
توقف بعد از دستور شکستخوردهی بررسینشده | set +e |
-u |
nounset |
متغیر تعریفنشده خطاست | set +u |
-o pipefail |
pipefail |
کد pipe = کد آخرین عضو شکستخورده | set +o pipefail |
-E |
errtrace |
trap ERR داخل تابعها و subshellها هم اجرا شود | set +E |
-x |
xtrace |
چاپ هر دستور قبل از اجرا (درس دیباگ) | set +x |
shopt -s inherit_errexit |
-e داخل $(...) هم فعال باشد |
shopt -u inherit_errexit |
trapهای پرکاربرد:
| trap | کی اجرا میشود | کاربرد |
|---|---|---|
trap cleanup EXIT |
هر نوع پایان اسکریپت (عادی، exit، set -e، SIGINT/SIGTERM) |
پاککردن فایل موقت، آزادکردن قفل |
trap 'on_error $LINENO' ERR |
بعد از هر دستور شکستخورده (همانهایی که set -e میبیند) |
گزارش خط و دستور |
trap 'echo stop; exit 130' INT |
Ctrl+C | رفتار سفارشی موقع لغو |
trap '' HUP |
(رشتهی خالی یعنی نادیدهگرفتن) | ادامه بعد از بستهشدن ترمینال |
trap - EXIT |
برداشتن trap | |
trap -p |
دیدن trapهای فعلی |
کدهای خروج رایج:
| کد | معنی |
|---|---|
0 |
موفق |
1 |
خطای عمومی |
2 |
استفادهی غلط (آرگومان نادرست)؛ همچنین خطای نحو در دستورهای داخلی bash |
126 |
فایل پیدا شد ولی قابل اجرا نیست (مجوز) |
127 |
دستور پیدا نشد |
128+N |
بستهشده با سیگنال N: 130 = Ctrl+C، 143 = kill، 137 = kill -9 |
3 تا 125 |
آزاد برای کدهای سفارشی خودت (مستندشان کن) |
اشتباهات رایج
Section titled “اشتباهات رایج”۱) ((i++)) اسکریپت را میبندد
Section titled “۱) ((i++)) اسکریپت را میبندد”cd ~/bashlabcat > counter.sh <<'EOF'#!/usr/bin/env bashset -ecount=0for f in a b c; do ((count++)) echo "processed $f (count=$count)"doneecho "total: $count"EOFbash counter.shecho "کد خروج: $?"کد خروج: 1هیچ چیزی چاپ نشد! ((count++)) مقدار قبلی (۰) را برمیگرداند و (( 0 )) یعنی «غلط»، کد خروج ۱. set -e آن را شکست میبیند. راهحل: ((++count)) (مقدار جدید، ۱، درست است)، یا count=$((count + 1))، یا ((count++)) || true.
۲) local x=$(cmd) خطا را پنهان میکند
Section titled “۲) local x=$(cmd) خطا را پنهان میکند”bash -c 'set -ef() { local out=$(false); echo "masked: still running after a failed command"; }f'echo "--- راهحل:"bash -c 'set -eg() { local out; out=$(false); echo "never printed"; }g'echo "کد خروج: $?"masked: still running after a failed command--- راهحل:کد خروج: 1local کد خروج خودش (۰) را برمیگرداند (درس تابعها). راهحل: local out در یک خط و out=$(cmd) در خط بعد. همین برای export x=$(cmd) و readonly x=$(cmd) هم درست است.
۳) تابعی که در if صدا زده میشود، از set -e بیرون است
Section titled “۳) تابعی که در if صدا زده میشود، از set -e بیرون است”bash -c 'set -esetup() { mkdir /root/forbidden 2>/dev/null; echo "setup continued after mkdir failed!"; }if setup; then echo "setup reported success"; fi'setup continued after mkdir failed!setup reported successچون setup شرطِ if است، set -e داخل کل تابع خاموش است؛ mkdir شکست خورد و تابع ادامه داد و موفق گزارش شد. راهحل: داخل تابعهای مهم شکست را صریح برگردان: mkdir ... || return 1.
۴) trap با کوتیشن دوتایی
Section titled “۴) trap با کوتیشن دوتایی”bash -c 'trap "echo error on line $LINENO" ERRecho line 2false'echo "--- با کوتیشن تکی:"bash -c "trap 'echo error on line \$LINENO' ERRecho line 2false"line 2error on line 1--- با کوتیشن تکی:line 2error on line 3با کوتیشن دوتایی، $LINENO همان لحظهی تعریف trap باز شد (خط ۱). با کوتیشن تکی، موقع اجرای trap (خط ۳). راهحل: متن trap را همیشه در کوتیشن تکی بنویس، یا آن را در یک تابع بگذار.
۵) پاکسازی بدون محافظت از متغیر خالی
Section titled “۵) پاکسازی بدون محافظت از متغیر خالی”اگر trap قبل از ساختن پوشهی موقت ثبت شود و mktemp شکست بخورد، rm -rf "$workdir" با متغیر خالی اجرا میشود. با rm -rf "$workdir"/* این یعنی rm -rf /*! راهحل: اول mktemp و بعد trap؛ از set -u استفاده کن؛ و در cleanup بنویس [[ -n ${workdir:-} ]] && rm -rf -- "$workdir".
۶) فراموشکردن pipefail
Section titled “۶) فراموشکردن pipefail”مثال ۴: cmd | gzip > file حتی با set -e، شکست cmd را نمیبیند. راهحل: همیشه set -o pipefail (یا در یک خط: set -euo pipefail).
۷) خطایی که «انتظارش را داری» اسکریپت را میبندد
Section titled “۷) خطایی که «انتظارش را داری» اسکریپت را میبندد”bash -c 'set -eo pipefailerrors=$(grep -c ERROR /etc/hostname)echo "errors: $errors"'echo "کد خروج: $?"echo "--- راهحل:"bash -c 'set -eo pipefailerrors=$(grep -c ERROR /etc/hostname || true)echo "errors: $errors"'کد خروج: 1--- راهحل:errors: 0grep -c وقتی چیزی پیدا نکند 0 چاپ میکند ولی کد خروج ۱ میدهد و set -e اسکریپت را بیپیام میبندد. راهحل: || true برای شکستی که معنیاش «نتیجهی خالی» است.
این اسکریپت سه ایراد امنیتی دارد که با set -euo pipefail پیدا میشوند. اول آن را بدون تغییر اجرا کن و ببین با کد ۰ تمام میشود؛ بعد سطر set -euo pipefail را اضافه کن و بگو کجا متوقف شد و چرا. در پایان ایرادها را درست کن.
#!/usr/bin/env bashlogdir=/var/log/myapplines=$(cat $logdir/app.log | wc -l)echo "log has $lines lines"echo "report for $USERNAME"دیدن جواب
cd ~/bashlabcat > report.sh <<'EOF'#!/usr/bin/env bashlogdir=/var/log/myapplines=$(cat $logdir/app.log | wc -l)echo "log has $lines lines"echo "report for $USERNAME"EOFecho "--- بدون set:"bash report.sh; echo "کد خروج: $?"sed -i '1a set -euo pipefail' report.shecho "--- با set -euo pipefail:"bash report.sh; echo "کد خروج: $?"echo "--- نسخهی درست:"cat > report.sh <<'EOF'#!/usr/bin/env bashset -euo pipefaillogdir=/var/log/myapplogfile="$logdir/app.log"if [[ ! -f $logfile ]]; then echo "report: $logfile not found" >&2 exit 3filines=$(wc -l < "$logfile")echo "log has $lines lines"echo "report for ${USERNAME:-$USER}"EOFbash report.sh; echo "کد خروج: $?"--- بدون set:cat: /var/log/myapp/app.log: No such file or directorylog has 0 linesreport forکد خروج: 0--- با set -euo pipefail:cat: /var/log/myapp/app.log: No such file or directoryکد خروج: 1--- نسخهی درست:report: /var/log/myapp/app.log not foundکد خروج: 3۱) بدون گزینهها، cat شکست خورد ولی wc «۰ خط» گزارش کرد و $USERNAME (که در این سیستم تعریف نشده؛ اسم درست USER است) خالی چاپ شد؛ کد ۰. ۲) با pipefail، شکست cat به lines=$(...) رسید و set -e اسکریپت را با کد ۱ بست (اگر از آن رد میشد، set -u روی USERNAME میایستاد). ۳) نسخهی درست وجود فایل را صریح بررسی میکند، کد خروج معنادار (۳) میدهد، cat | بیدلیل را حذف کرده و برای متغیر اختیاری مقدار پیشفرض دارد.
تمرین اصلی درس: به اسکریپت بکاپ مدیریت خطا و پاکسازی اضافه کن. اسکریپت backup.sh <پوشهی-مبدأ> <پوشهی-مقصد>:
۱. set -euo pipefail داشته باشد و کدهای خروج مستند: ۲ برای استفادهی غلط، ۳ برای نبودن مبدأ.
۲. آرشیو را اول در یک پوشهی موقت (mktemp -d) بسازد و فقط اگر کامل و سالم بود (tar -tzf بدون خطا) به مقصد منتقل کند؛ تا هیچوقت آرشیو نیمهکاره در مقصد نماند.
۳. با trap روی EXIT پوشهی موقت را در هر حالتی پاک کند و با trap ERR خط و دستور شکستخورده را گزارش کند.
ثابت کن در حالت موفق یک آرشیو سالم در مقصد هست، و در حالتی که ساخت آرشیو شکست میخورد (مثلاً یک فایل غیرقابلخواندن در مبدأ) هیچ آرشیو و هیچ پوشهی موقتی نمیماند.
دیدن جواب
cd ~/bashlabcat > backup.sh <<'EOF'#!/usr/bin/env bash# backup.sh: ./backup.sh <source-dir> <dest-dir># Exit codes: 0 ok, 1 error, 2 usage, 3 source not foundset -Eeuo pipefail
readonly E_USAGE=2 E_NOSRC=3
die() { local code=$1 shift echo "backup: $*" >&2 exit "$code"}
[[ $# -eq 2 ]] || die "$E_USAGE" "usage: backup.sh <source-dir> <dest-dir>"src=$1dest=$2[[ -d $src ]] || die "$E_NOSRC" "source '$src' not found"mkdir -p "$dest"
tmpdir="$(mktemp -d)"cleanup() { local status=$? [[ -n ${tmpdir:-} ]] && rm -rf -- "$tmpdir" if (( status != 0 )); then echo "backup: FAILED (status $status); nothing was written to $dest" >&2 fi}trap cleanup EXITtrap 'echo "backup: error at line $LINENO: $BASH_COMMAND" >&2' ERR
name="$(basename "$src")-$(date +%F_%H%M%S).tar.gz"tar -czf "$tmpdir/$name" -C "$(dirname "$src")" "$(basename "$src")"tar -tzf "$tmpdir/$name" > /dev/nullmv "$tmpdir/$name" "$dest/"echo "backup: created $dest/$name"EOFchmod +x backup.shmkdir -p data/docsecho "report" > data/docs/report.txtecho "notes" > data/notes.txtecho "=== حالت موفق:"./backup.sh data backups; echo "کد خروج: $?"ls backupstar -tzf backups/*.tar.gzecho "=== استفادهی غلط و مبدأ ناموجود:"./backup.sh; echo "کد خروج: $?"./backup.sh nothing backups; echo "کد خروج: $?"echo "=== حالت ناموفق (فایل غیرقابلخواندن):"rm -rf backupsecho "secret" > data/locked.txtchmod 000 data/locked.txt./backup.sh data backups; echo "کد خروج: $?"echo "آرشیو در مقصد: $(ls backups | wc -l)"echo "پوشهی موقت باقیمانده: $(find /tmp -maxdepth 1 -name 'tmp.*' -user ali | wc -l)"chmod 644 data/locked.txt=== حالت موفق:backup: created backups/data-2026-10-04_114245.tar.gzکد خروج: 0data-2026-10-04_114245.tar.gzdata/data/notes.txtdata/docs/data/docs/report.txt=== استفادهی غلط و مبدأ ناموجود:backup: usage: backup.sh <source-dir> <dest-dir>کد خروج: 2backup: source 'nothing' not foundکد خروج: 3=== حالت ناموفق (فایل غیرقابلخواندن):tar: data/locked.txt: Cannot open: Permission deniedtar: Exiting with failure status due to previous errorsbackup: error at line 33: tar -czf "$tmpdir/$name" -C "$(dirname "$src")" "$(basename "$src")"backup: FAILED (status 2); nothing was written to backupsکد خروج: 2آرشیو در مقصد: 0پوشهی موقت باقیمانده: 0در حالت ناموفق: tar نتوانست locked.txt را بخواند و کد ۲ داد؛ trap ERR خط و دستور را گفت؛ set -e اسکریپت را بست؛ cleanup پوشهی موقت را پاک کرد و پیام FAILED داد؛ و چون آرشیو فقط بعد از موفقیت به مقصد mv میشود، پوشهی backups خالی ماند. این الگوی «اول در موقت، بعد انتقال» برای هر فایل مهمی که میسازی (تنظیمات، گزارش، آرشیو) به کار میآید؛ چون mv در یک فایلسیستم تقریباً آنی است، کسی هرگز فایل نیمهکاره نمیبیند. (اگر /tmp و مقصد روی دو دیسک جدا باشند، mv در واقع کپی است؛ آنوقت پوشهی موقت را کنار مقصد بساز: mktemp -d -p "$dest".)
اسکریپتی بنویس که فقط یک نسخه از آن همزمان اجرا شود: اول کار یک «فایل قفل» (/tmp/myjob.lock) بسازد و اگر قفل از قبل بود، با پیام و کد ۷۵ بیرون برود؛ و قفل را با trap EXIT در هر حالتی بردارد. برای ساختن قفل از set -o noclobber استفاده کن (که > روی فایل موجود را خطا میکند) تا بررسی و ساختن در یک قدم انجام شود. دو نسخه را همزمان اجرا کن.
دیدن جواب
cd ~/bashlabcat > single.sh <<'EOF'#!/usr/bin/env bash# single.sh: only one instance may run at a timeset -euo pipefaillock=/tmp/myjob.lock
if ! (set -o noclobber; echo "$$" > "$lock") 2>/dev/null; then echo "[$$] another instance is running (pid $(cat "$lock")); exiting" >&2 exit 75fitrap 'rm -f "$lock"' EXIT
echo "[$$] got the lock, working..."sleep 2echo "[$$] done"EOFchmod +x single.shrm -f /tmp/myjob.lock./single.sh &sleep 0.5./single.sh; echo "نسخهی دوم، کد خروج: $?"waitecho "قفل بعد از پایان مانده؟ $(ls /tmp/myjob.lock 2>/dev/null || echo 'نه')"[4637] got the lock, working...[4641] another instance is running (pid 4637); exitingنسخهی دوم، کد خروج: 75[4637] doneقفل بعد از پایان مانده؟ نهنکتهها: ۱) set -o noclobber باعث میشود > روی فایلی که هست شکست بخورد؛ پس «ببین قفل هست؟» و «بساز» یک عمل واحد است و دو نسخه نمیتوانند هر دو موفق شوند (اگر اول با [[ -f ]] بررسی کنی و بعد بسازی، بین این دو قدم نسخهی دیگری میتواند برسد). ۲) داخل پرانتز نوشتیم تا noclobber فقط همانجا روشن باشد. ۳) trap فقط بعد از گرفتن قفل ثبت شد؛ وگرنه نسخهی دوم موقع خروج، قفل نسخهی اول را پاک میکرد. ۴) کد ۷۵ در قرارداد sysexits یعنی «موقتاً ممکن نیست، بعداً دوباره امتحان کن». (ابزار آمادهی flock هم همین کار را میکند و قفل را حتی بعد از kill -9 آزاد میکند.)
تابع run_step بنویس که یک «قدم» از یک فرایند را اجرا کند: run_step "اسم قدم" دستور آرگومانها.... اگر دستور موفق بود [ OK ] اسم قدم چاپ کند؛ اگر شکست خورد [FAIL] اسم قدم (status N) را روی stderr بنویسد، خروجی خطای دستور را (که در یک فایل موقت نگه داشته) نشان دهد، و اسکریپت را با همان کد خروج دستور ببندد. فایل موقت در هر حالتی پاک شود. با سه قدم امتحانش کن که قدم دوم شکست میخورد.
دیدن جواب
cd ~/bashlabcat > steps.sh <<'EOF'#!/usr/bin/env bash# steps.sh: run named steps, stop at the first failure with its exit statusset -euo pipefail
errfile="$(mktemp)"trap 'rm -f -- "$errfile"' EXIT
run_step() { local name=$1 shift local status=0 "$@" > /dev/null 2> "$errfile" || status=$? if (( status == 0 )); then echo "[ OK ] $name" else echo "[FAIL] $name (status $status)" >&2 sed 's/^/ | /' "$errfile" >&2 exit "$status" fi}
run_step "check disk space" df -h /run_step "read the config" cat /etc/myapp/config.ymlrun_step "say hello" echo helloEOFchmod +x steps.sh./steps.sh; echo "کد خروج: $?"echo "فایل موقت باقیمانده: $(find /tmp -maxdepth 1 -name 'tmp.*' -user ali -type f | wc -l)"[ OK ] check disk space[FAIL] read the config (status 1) | cat: /etc/myapp/config.yml: No such file or directoryکد خروج: 1فایل موقت باقیمانده: 0نکتهها: ۱) "$@" ... || status=$? شکست را بررسیشده میکند، پس set -e اسکریپت را وسط تابع نمیبندد و ما فرصت داریم پیام بدهیم؛ $? بعد از || هنوز کد دستور است. ۲) exit "$status" همان کد واقعی (cat: ۱) را به صدازننده میدهد. ۳) خروجی خطا را با sed تورفتگی دادیم تا از پیام خودمان جدا دیده شود. ۴) trap EXIT فایل موقت را حتی بعد از exit داخل تابع پاک کرد. قدم سوم اجرا نشد.
آزمونک
Section titled “آزمونک”اسکریپتی بدون set -e دستور cp را با شکست اجرا میکند و دستور آخرش echo done است. کد خروج اسکریپت چیست؟
بدون مدیریت خطا، شکستهای وسط اسکریپت پنهان میمانند؛ برای همین set -e و exit صریح لازم است.
با set -e، کدام خط اسکریپت را متوقف میکند؟
شکست در شرط if، قبل از || یا &&، و با ! «بررسیشده» حساب میشود و اسکریپت را نمیبندد.
mysqldump db | gzip > db.gz با set -e؛ اگر mysqldump شکست بخورد چه میشود؟
pipefail کد pipe را کد آخرین عضو شکستخورده میکند.
در تابع cleanup که با trap ... EXIT اجرا میشود، چرا local status=$? باید خط اول باشد؟
اگر اول rm بزنی، $? مال rm میشود.
اسکریپتی با set -e حلقهای با count=0 و ((count++)) دارد و بیصدا در اولین دور بسته میشود. چرا؟
یکی از معروفترین دامهای set -e.
اسکریپتی با Ctrl+C متوقف شده و کد خروجش ۱۳۰ است. ۱۳۰ از کجا آمده؟
kill (SIGTERM=15) ۱۴۳ و kill -9 ۱۳۷ میدهد.
trap "echo error on line $LINENO" ERR همیشه خط ۱ را گزارش میکند. چرا؟
متن trap را در کوتیشن تکی بنویس تا موقع اجرا باز شود.
جمعبندی
Section titled “جمعبندی”- بدون مدیریت خطا، Bash بعد از شکست ادامه میدهد و کد خروج اسکریپت کد آخرین دستور است؛ خطاهای وسط پنهان میمانند.
set -euo pipefailرا اول هر اسکریپت جدی بگذار:-eتوقف در دستور شکستخوردهی بررسینشده،-uخطا برای متغیر تعریفنشده (${var:-}برای اختیاریها)،pipefailتا شکست وسط pipe دیده شود.-Eتا trap ERR در تابعها هم اجرا شود.set -eدر شرطif/while، قبل از||/&&، با!، داخل تابعی که درifصدا زده شده و داخل$(...)(مگرinherit_errexit) اثر ندارد. دستورهای حساس را صریح بررسی کن:cmd || die ....- شکست قابلانتظار را صریح مدیریت کن:
if ! cmd،cmd || true. دامها:((i++))با مقدار ۰،local x=$(cmd). trap cleanup EXITبلافاصله بعد از ساختن منبع موقت (mktemp -d)؛ در هر پایانی اجرا میشود (جزkill -9). اولین خط cleanup:local status=$?.trap '... $LINENO ... $BASH_COMMAND' ERRبا کوتیشن تکی برای گزارش «کجا و چه».- کدهای خروج سفارشی را ثابت و مستند کن (
readonly E_USAGE=2)؛ ۱۲۶، ۱۲۷ و ۱۲۸+N معنی خاص دارند. - الگوی «اول در موقت، بعد mv» تا هیچکس فایل نیمهکاره نبیند.
| دستور | کاری که میکند |
|---|---|
set -euo pipefail | توقف در خطا، متغیر تعریفنشده خطا، شکست وسط pipe |
set -Eeuo pipefail | همان، بهعلاوهی اجرای trap ERR داخل تابعها |
shopt -s inherit_errexit | اثر set -e داخل $(...) |
trap cleanup EXIT | پاکسازی در هر نوع خروج |
trap 'echo "line $LINENO: $BASH_COMMAND"' ERR | گزارش خط و دستور شکستخورده |
tmp="$(mktemp -d)" | پوشهی موقت امن و یکتا |
exit 2 die() { echo "$*" >&2; exit 1; } | پایان با کد خروج معنادار |
cmd || true if ! cmd; then | شکست قابلانتظار، بدون توقف |
echo "${PIPESTATUS[@]}" | کد خروج همهی عضوهای آخرین pipe |
${var:-} | متغیر اختیاری با set -u |
trap -p trap - EXIT | دیدن و برداشتن trapها |
echo $- set -o | گزینههای فعلی شل |