◀ العودة إلى المدونة
Linux

كتابة سكربتات Shell لـ DevOps

نشر في 01 May 2024· 8 min قراءة
#Linux#Bash#Automatisation

فن كتابة سكربتات Shell

تظل كتابة سكربتات Shell أداة أساسية لكل مهندس DevOps. فرغم انتشار Ansible وTerraform وخطوط CI التصريحية، تأتي دائماً لحظة تكتب فيها بضعة أسطر من Bash: خطّاف نشر (deployment hook)، أو نسخة احتياطية ليلية، أو نقطة دخول لحاوية، أو خطوة في خط CI. غالباً ما تُكتب هذه السكربتات على عجل ونادراً ما تُراجَع، مما يجعلها مصدراً كلاسيكياً للحوادث. إليك الأنماط وأفضل الممارسات الأساسية لكتابة سكربتات موثوقة وسهلة القراءة والصيانة.

متى تختار Bash (ومتى تتجنبه)

يتفوق Bash في تنسيق الأوامر الموجودة: ربط git وrsync وdocker وsystemctl، والتحقق من رمز الخروج، وكتابة سطر في السجل. وهو متوفر على كل خوادم Linux تقريباً ولا يتطلب أي اعتمادية.

في المقابل، بمجرد أن يتعامل السكربت مع هياكل بيانات (JSON، مصفوفات ترابطية متداخلة)، أو يجري حسابات غير بسيطة، أو يتجاوز بضع مئات من الأسطر، تصبح لغة مثل Python أو PHP أكثر أماناً ووضوحاً. في بيئة الإنتاج، أوصي بقاعدة بسيطة: إذا بدأت تحلل JSON باستخدام grep وsed، فقد حان وقت تغيير الأداة (أو على الأقل استخدام jq).

بنية سكربت متين

يتبع السكربت الجيد دائماً الهيكل نفسه: سطر shebang صريح، ووضع صارم، وثوابت في الأعلى، ودوال، وتنظيف مضمون، ودالة main تُستدعى في النهاية.

#!/bin/bash
set -euo pipefail
IFS=$'\n\t'

# Variables
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly LOG_FILE="/var/log/deploy.log"
readonly TEMP_DIR="$(mktemp -d)"

# Functions
log() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

cleanup() {
    log "Cleaning up..."
    rm -rf "$TEMP_DIR"
}
trap cleanup EXIT

# Main script
main() {
    log "Starting deployment"
    # ...
    log "Deployment finished"
}

main "$@"

لنفصّل كل عنصر:

  • set -e: يتوقف السكربت فور فشل أي أمر، بدلاً من الاستمرار في حالة غير متسقة.
  • set -u: أي متغير غير معرَّف يسبب خطأ. لم يعد خطأ إملائي في $TEMP_DIR يتحول إلى rm -rf /.
  • set -o pipefail: يفشل خط الأنابيب (pipeline) إذا فشل أي أمر فيه، وليس الأخير فقط. من دونه، يُعدّ failing_command | tee file ناجحاً.
  • IFS=$'\n\t': لم يعد تقسيم الكلمات يتم على المسافات، مما يجنّبك المفاجآت السيئة مع أسماء الملفات التي تحتوي على مسافات.
  • readonly: لا يمكن الكتابة فوق الثوابت عن طريق الخطأ لاحقاً في السكربت.
  • mktemp -d: ينشئ مجلداً مؤقتاً فريداً، بدلاً من مسار ثابت مثل /tmp/deploy قد يتعارض مع تنفيذ آخر.
  • trap cleanup EXIT: تُنفَّذ الدالة cleanup مهما كانت طريقة انتهاء السكربت (نجاح، خطأ، exit صريح). لا تبقى الملفات المؤقتة أبداً.
  • main "$@": يُنفَّذ كل الكود داخل دالة، مما يجعل التدفق واضحاً ويضمن أن Bash قرأ الملف كاملاً قبل البدء.

حدود set -e

الوضع الصارم ليس سحراً. يُتجاهل set -e في بعض الحالات: في شرط if، وعلى يسار && أو ||، وفي دالة تُستدعى من أحد هذه السياقات. وبالمثل، فإن local var=$(command) يخفي رمز خروج الأمر، لأن الرمز المعتبر هو رمز local. صرّح بالمتغير ثم أسند قيمته على سطرين منفصلين:

# Bad: the curl failure is hidden by "local"
local body=$(curl -fsS "$url")

# Good: the curl failure stops the script
local body
body=$(curl -fsS "$url")

فخ كلاسيكي آخر: يعيد ((counter++)) رمز خطأ عندما تكون القيمة قبل الزيادة تساوي 0. مع set -e، يتوقف السكربت دون أي رسالة. فضّل counter=$((counter + 1)) عندما يمكن أن يبدأ العداد من الصفر.

أنماط مفيدة

تتكرر بعض الدوال في كل سكربتات التشغيل تقريباً. الدالتان التاليتان مثالان جيدان: التحقق من حالة خدمة، وإعادة المحاولة مع تأخير تصاعدي.

# Check whether a service is active
check_service() {
    if systemctl is-active --quiet "$1"; then
        echo "$1 is active"
    else
        echo "$1 is inactive" && return 1
    fi
}

# Retry with backoff
retry() {
    local max_attempts=$1; shift
    local attempt=1
    while [ $attempt -le $max_attempts ]; do
        if "$@"; then return 0; fi
        echo "Attempt $attempt/$max_attempts failed"
        sleep $((attempt * 2))
        ((attempt++))
    done
    return 1
}

تأخذ الدالة retry عدد المحاولات كوسيط أول، ثم الأمر المراد تنفيذه. يزيل shift الوسيط الأول، وينفّذ "$@" الباقي كما هو مع الحفاظ على المسافات وعلامات الاقتباس. تزداد مدة الانتظار بعد كل فشل (2، 4، 6 ثوانٍ…)، مما يمنح قاعدة البيانات أو الـ API وقتاً للعودة. هنا يبدأ attempt من 1، لذا فإن ((attempt++)) آمن مع set -e. مثال على الاستخدام:

retry 5 curl -fsS https://example.com/health
check_service nginx || systemctl restart nginx

التحقق من الوسائط والمتطلبات المسبقة

يجب أن يفشل السكربت الذي يُشغَّل بوسائط خاطئة فوراً، برسالة واضحة، قبل أن يغيّر أي شيء. وبالمثل، تحقق من أن الأدوات اللازمة مثبتة:

usage() {
    echo "Usage: $(basename "$0") <environment> [version]" >&2
    exit 1
}

require() {
    local cmd
    for cmd in "$@"; do
        command -v "$cmd" >/dev/null 2>&1 || {
            echo "Required command not found: $cmd" >&2
            exit 1
        }
    done
}

[ $# -ge 1 ] || usage
readonly ENVIRONMENT="$1"
readonly VERSION="${2:-latest}"

case "$ENVIRONMENT" in
    staging|production) ;;
    *) echo "Invalid environment: $ENVIRONMENT" >&2; exit 1 ;;
esac

require git rsync curl

لاحظ استخدام ${2:-latest} لقيمة افتراضية متوافقة مع set -u، وتوجيه رسائل الخطأ إلى مخرج الأخطاء القياسي (>&2) حتى لا تختلط بمخرجات قد يستهلكها برنامج آخر.

منع التنفيذ المتزامن

قد يتداخل سكربت يشغّله cron كل خمس دقائق مع التنفيذ السابق إذا تأخر. نسختان احتياطيتان أو عمليتا نشر بالتوازي تعنيان حتماً حالة غير متسقة. يحل الأمر flock (من حزمة util-linux) هذه المشكلة بشكل نظيف:

exec 9>/var/lock/deploy.lock
if ! flock -n 9; then
    echo "Another run is already in progress" >&2
    exit 1
fi

تحرر النواة القفل تلقائياً عند انتهاء السكربت، حتى في حالة انهياره: لا يوجد ملف قفل يتيم يجب حذفه يدوياً.

علامات الاقتباس والمتغيرات

تأتي معظم أخطاء Bash من المتغيرات غير المحمية بعلامات الاقتباس. اكتب دائماً "$variable" بين علامتي اقتباس مزدوجتين، إلا إذا كنت تريد صراحةً تقسيم الكلمات. فضّل $(command) على علامات backticks التي يصعب تداخلها، و[[ ... ]] على [ ... ] في سكربتات Bash: فهو يتعامل بشكل أفضل مع السلاسل الفارغة ويدعم التعابير النمطية عبر =~.

أفضل الممارسات

  • استخدم دائماً set -euo pipefail
  • وثّق المتغيرات والدوال
  • استخدم الدوال لتحسين القراءة
  • عالج الأخطاء باستخدام trap
  • اختبر باستخدام shellcheck
  • ضع المتغيرات دائماً بين علامتي اقتباس مزدوجتين
  • اكتب الأخطاء على stderr وأعد رمز خروج غير صفري
  • اجعل السكربتات متساوية القوة (idempotent): يجب ألا يكسر التنفيذ الثاني أي شيء

يستحق ShellCheck إشارة خاصة: فهذا المحلل الساكن يكتشف المتغيرات غير المحمية، والمقارنات الخاطئة، وفخاخ local، وعشرات الأخطاء الأخرى. يندمج مع معظم المحررات ويسهل تشغيله في CI:

shellcheck scripts/*.sh
bash -n scripts/deploy.sh   # syntax check only

متى لا تكتب سكربتاً

قبل كتابة سكربت جديد، تحقق مما إذا كانت هناك أداة مخصصة موجودة بالفعل. لجدولة مهمة، يوفّر مؤقّت systemd (timer) التسجيل ومعالجة حالات الفشل. ولإعداد مجموعة من الخوادم بشكل قابل للتكرار، يُعدّ Ansible أنسب من حلقة SSH. السكربت الجيد قصير، ويؤدي مهمة واحدة، ويفشل بصوت عالٍ، ويمكن مراجعته في دقيقتين.