برمجة الصدفة والأتمتة

السكريبتات المتينة: الأخطاء والسلامة

18 دقيقة الدرس 8 من 28

السكريبتات المتينة: الأخطاء والسلامة

الفارق بين سكريبت شل هاوٍ وآخر يعمل دون رقابة في بيئة الإنتاج في الثالثة صباحاً ليس الذكاء — بل الهندسة الدفاعية. السلوك الافتراضي لـ Bash خطير في تساهله: يواصل التنفيذ بعد فشل الأوامر، ويوسّع المتغيرات غير المُعرَّفة إلى سلاسل فارغة بصمت، ويخفي فشل الأوامر داخل الأنابيب خلف كود الخروج للأمر الأخير. لا شيء من هذا مقبول حين يحذف سكريبتك نسخ احتياطية قديمة، أو يُدير أسراراً، أو ينشر إلى الإنتاج. يغطي هذا الدرس المجموعة القياسية من الحراس التي يجب أن يمتلكها كل سكريبت شل احترافي، والأدوات التي تفرضها تلقائياً.

الثلاثة الكبار: set -euo pipefail

تحوّل هذه الأعلام الثلاث مجتمعةً على سطر واحد Bash من مُفسِّر متساهل إلى مُفسِّر صارم. ضعها مباشرةً بعد سطر الشيبانغ — دون استثناءات.

#!/usr/bin/env bash set -euo pipefail

هذا ما يفعله كل علم ولماذا يهمّ:

  • -e (errexit): الخروج فوراً حين يُعيد أي أمر حالة غير صفرية. بدون هذا العلم، يواصل Bash بهجة تنفيذ rm -rf /var/data حتى لو فشل mkdir /var/data السابق بصمت. مع -e، يتوقف السكريبت عند نقطة الفشل بدلاً من التفاقم إلى حالة أسوأ.
  • -u (nounset): معاملة أي إشارة إلى متغير غير مُعرَّف كخطأ. الكارثة الكلاسيكية تبدو هكذا: rm -rf "${DEPLOY_DIR}/" حيث DEPLOY_DIR خطأ إملائي أو لم يُصدَّر قط. بدون -u، يتوسع هذا بصمت إلى rm -rf "/". مع -u، يُجهض السكريبت برسالة DEPLOY_DIR: unbound variable.
  • -o pipefail: يجعل الأنبوب يُعيد حالة خروج أوّل أمر فشل فيه من اليمين، بدلاً من دائماً إعادة حالة الأمر الأخير. بدونه، false | true تخرج بـ 0 — فشل صامت يبتلعه الأنبوب. مع pipefail، تخرج بـ 1.
فخ الإنتاج — -e والشيلات الفرعية: لا ينتشر set -e إلى الشيلات الفرعية المولّدة بـ ( ) أو استبدال الأوامر إلا إذا ضبطته فيها أيضاً. اختبر دائماً مسارات الفشل، لا فقط المسار السعيد.

الفخاخ: تنظيف مضمون

الفخّ (trap) هو معالج يُنفّذه الشيل عند حدوث إشارة أو شبه إشارة محددة. الفخّان اللذان يحتاجهما كل سكريبت إنتاجي هما EXIT وERR.

EXIT يُطلَق كلما انتهى السكريبت — سواء انتهى بشكل طبيعي، أو اصطدم بفشل set -e، أو تلقى إشارة. استخدمه لحذف الملفات المؤقتة، وتحرير الأقفال، أو تسجيل الاكتمال. ERR يُطلَق تحديداً حين يفشل أمر ما (يعمل بالتنسيق مع set -e)، مما يجعله المكان المناسب لإصدار رسالة خطأ منظمة.

#!/usr/bin/env bash set -euo pipefail # ---------- فخ التنظيف ---------- TMPDIR_WORK="" cleanup() { local exit_code=$? if [[ -n "${TMPDIR_WORK}" && -d "${TMPDIR_WORK}" ]]; then rm -rf "${TMPDIR_WORK}" echo "[cleanup] removed temp dir ${TMPDIR_WORK}" >&2 fi exit "${exit_code}" } trap cleanup EXIT # ---------- فخ الخطأ ---------- on_error() { local line=$1 echo "[ERROR] Script failed at line ${line}" >&2 } trap 'on_error ${LINENO}' ERR # ---------- جسم السكريبت ---------- TMPDIR_WORK=$(mktemp -d) echo "Working in ${TMPDIR_WORK}" cp /etc/app/config.yml "${TMPDIR_WORK}/" # ... العمل الفعلي ... echo "Done"

عدة نقاط تستحق الانتباه. أولاً، تلتقط cleanup قيمة $? فوراً — أي أمر لاحق قد يُعيد كتابتها. ثانياً، تتحقق الدالة من أن متغير الدليل المؤقت غير فارغ وأن المسار موجود فعلاً قبل محاولة الحذف؛ حراسة ضد الحالة التي يفشل فيها السكريبت قبل تشغيل mktemp. ثالثاً، تخرج cleanup بكود الخروج الأصلي حتى يتلقى المُستدعي (مشغّل CI، systemd، cron) الحالة الصحيحة.

ملفات القفل والفخاخ: السكريبتات التي يجب ألا تعمل بالتوازي (ترحيل قواعد البيانات، ضغط نظام الملفات) تستخدم ملف قفل مع فخّ. أنشئ القفل بـ flock أو بكتابة PID إلى /var/run/myscript.pid؛ احذفه في فخ EXIT. هذا النمط مستخدم في الإنتاج على نطاق واسع من أدوات مثل mysqld_safe وnginx.

التعامل الدفاعي مع المتغيرات

إضافةً إلى -u، يوفر Bash مُشغّلات التوسع التي تتيح لك التعبير عن النية بدقة والفشل السريع برسالة واضحة.

#!/usr/bin/env bash set -euo pipefail # المتطلب: يجب أن يكون المتغير محدداً وغير فارغ؛ أطبع رسالة عند الفشل : "${DATABASE_URL:?DATABASE_URL must be set and non-empty}" # استخدم القيمة الافتراضية إذا كان المتغير غير محدد أو فارغاً (احتياطي آمن) LOG_LEVEL="${LOG_LEVEL:-info}" # استخدم القيمة الافتراضية فقط إذا كان غير محدد (لكن اسمح بالسلسلة الفارغة) TIMEOUT="${TIMEOUT-30}" echo "Connecting to ${DATABASE_URL} with log level ${LOG_LEVEL}"

الصيغة :? هي الحراسة الاصطلاحية في أعلى أي سكريبت يعتمد على متغيرات البيئة المُضخَّة من نظام CI أو مدير الأسرار. حين تغيب DATABASE_URL، يتوقف السكريبت فوراً برسالة واضحة بدلاً من تمرير سلسلة فارغة إلى أمر لاحق ينتج خطأً غامضاً لاحقاً.

الملفات المؤقتة الآمنة

المسارات المؤقتة المُضمَّنة في الكود مثل /tmp/my-script.tmp هي ثغرة أمنية (هجمات الروابط الرمزية) وخطأ تزامن (تصادم نسختين). استخدم mktemp دائماً.

#!/usr/bin/env bash set -euo pipefail WORKDIR=$(mktemp -d) # دليل فريد: /tmp/tmp.XjK3m9 OUTFILE=$(mktemp) # ملف فريد: /tmp/tmp.aB2cP7 trap 'rm -rf "${WORKDIR}" "${OUTFILE}"' EXIT # آمن: بادئة متوقعة، لاحقة غير متوقعة LOCKFILE=$(mktemp -t deploy.XXXXXX) trap 'rm -f "${LOCKFILE}"' EXIT

التحليل الساكن مع ShellCheck

ShellCheck هو أداة تحليل ساكن تكتشف الأخطاء في سكريبتات الشيل قبل تشغيلها. تكتشف المتغيرات غير المقتبسة، وبنية الشرط الخاطئة، وعدم التوافق بين POSIX وbash، وعشرات الأخطاء الشائعة الأخرى. في شركات التقنية الكبرى، يعمل ShellCheck في خط أنابيب CI كبوابة lint إلزامية — سكريبت الشيل الذي لا يجتاز ShellCheck لا يُدمج.

Script safety layers: ShellCheck, set flags, traps, variable guards Script Safety Layers Layer 1 — Static Analysis shellcheck script.sh (runs in CI before merge) Catches: unquoted vars, bad syntax, POSIX mismatches Layer 2 — Runtime Guards set -euo pipefail (stops on failure, unset var, pipe error) Fails fast; prevents silent error propagation Layer 3 — Signal Handlers trap cleanup EXIT & trap on_error ERR Guaranteed cleanup; structured error logging Layer 4 — Input Validation ${VAR:?msg} • mktemp • [[ -f ]] • flock
أربع طبقات أمان متداخلة يجب أن يمتلكها كل سكريبت Bash على مستوى الإنتاج.

ثبّت ShellCheck وشغّله محلياً قبل الحفظ:

# التثبيت sudo apt-get install shellcheck # Debian/Ubuntu brew install shellcheck # macOS # التشغيل على سكريبت واحد shellcheck deploy.sh # التشغيل على جميع السكريبتات في المستودع (يُستخدم في CI) find . -name "*.sh" -print0 | xargs -0 shellcheck # تعطيل مضمَّن لقاعدة محددة (استخدم باعتدال، أضف تعليقاً يشرح السبب) # shellcheck disable=SC2086 # word-splitting intentional here eval "${DYNAMIC_CMD}"

يتكامل ShellCheck مع VS Code (إضافة shellcheck) وVim (ALE) وGitHub Actions (ludeeus/action-shellcheck). خطوة CI نموذجية تبدو هكذا:

- name: Lint shell scripts uses: ludeeus/action-shellcheck@master with: scandir: './scripts' severity: warning
القالب الدفاعي الكامل: كل سكريبت إنتاجي في مؤسسة هندسية ناضجة يبدأ من قالب يجمع جميع الطبقات الأربع — الشيبانغ، وset -euo pipefail، وفخّ التنظيف، وفخّ الخطأ، والمتغيرات المطلوبة المُتحقق منها. احتفظ بمثل هذا القالب في صندوق أدوات فريقك الداخلي وافرضه عبر الفاحص. السكريبتات التي تنحرف تتطلب مبرراً كتابياً صريحاً، ليس مجرد تعليق.

الجمع بين كل شيء: هيكل سكريبت آمن

إليك الهيكل القياسي الذي يجمع جميع التقنيات في هذا الدرس. انسخه كنقطة بداية لكل سكريبت إنتاجي جديد.

#!/usr/bin/env bash # Description: (سطر واحد يصف ما يفعله هذا السكريبت) # Usage: ./script.sh [--dry-run] <target> # Author: team-sre@company.com set -euo pipefail # ---- متغيرات البيئة المطلوبة ---- : "${APP_ENV:?APP_ENV must be set (staging|production)}" : "${DEPLOY_TOKEN:?DEPLOY_TOKEN must be set}" # ---- اختيارية مع قيم افتراضية ---- LOG_LEVEL="${LOG_LEVEL:-info}" DRY_RUN="${DRY_RUN:-false}" # ---- مساحة عمل مؤقتة ---- WORKDIR=$(mktemp -d) LOGFILE=$(mktemp) # ---- تنظيف عند أي خروج ---- cleanup() { local code=$? rm -rf "${WORKDIR}" "${LOGFILE}" [[ ${code} -ne 0 ]] && echo "[FATAL] Script exited with code ${code}" >&2 exit "${code}" } trap cleanup EXIT # ---- موقع الخطأ ---- trap 'echo "[ERROR] at line ${LINENO}: ${BASH_COMMAND}" >&2' ERR # ---- المنطق الرئيسي ---- main() { echo "[INFO] env=${APP_ENV} dry_run=${DRY_RUN}" # ... أوامرك هنا ... } main "$@"

نمط main "$@" — وضع كل المنطق في دالة main واستدعاؤها في النهاية — يضمن تحليل السكريبت بأكمله قبل تشغيل أي كود، مما يمنع الأخطاء الخفية الناجمة عن استدعاء دالة قبل تعريفها. يجعل السكريبت أيضاً أسهل للاختبار بمعزل عن غيره، وللاستيراد الآمن من سكريبتات أخرى.