Jenkins Pipeline: أتمتة متقدمة
يبقى Jenkins حلاً شائعاً للتكامل والنشر المستمرين (CI/CD) لدى المؤسسات التي تحتاج إلى تحكم كامل في بنية البناء الخاصة بها. فبينما تفرض GitHub Actions أو GitLab CI منصتها، يُثبَّت Jenkins على خوادمك الخاصة، ويتصل بأي مستودع Git، ويتوسع بفضل منظومة إضافات (plugins) واسعة جداً. لهذه الحرية ثمن: فتحديث وحدة التحكم (controller) والوكلاء (agents) والإضافات مسؤوليتك أنت.
منذ Jenkins 2، لم تعد الطريقة الصحيحة لوصف عملية البناء هي النقر في الواجهة، بل كتابة ملف Jenkinsfile مُدار بالإصدارات في جذر المشروع. عندها يتطور خط الأنابيب (pipeline) مع الشيفرة: يمكن لفرع أن يعدّل سلسلة البناء الخاصة به، وتمر كل تغييراته بالمراجعة مثل أي تعديل آخر.
تصريحي أم برمجي؟
يقدم Jenkins صيغتين. خط الأنابيب البرمجي (scripted) هو Groovy حر: قوي، لكنه صعب القراءة والتحقق. أما خط الأنابيب التصريحي (declarative) فيفرض بنية محددة (pipeline، agent، stages، post) يُتحقق منها قبل التنفيذ. بالنسبة لتطبيق PHP تقليدي، تغطي الصيغة التصريحية كل الاحتياجات؛ وحين يصبح منطق معقد ضرورياً، تتيح كتلة script { } أو مكتبة مشتركة الخروج منها عند الحاجة.
خط أنابيب تصريحي
إليك خط أنابيب كاملاً لمشروع Symfony: تثبيت الاعتماديات، ثم التحليل الساكن والاختبارات بالتوازي، ثم النشر انطلاقاً من الفرع الرئيسي.
pipeline {
agent {
dockerfile {
filename 'Dockerfile.ci'
}
}
environment {
APP_ENV = 'test'
DATABASE_URL = credentials('database-url')
}
stages {
stage('Install') {
steps {
sh 'composer install --prefer-dist'
}
}
stage('Quality') {
parallel {
stage('PHPStan') {
steps {
sh 'vendor/bin/phpstan analyse src'
}
}
stage('CS Fixer') {
steps {
sh 'vendor/bin/php-cs-fixer fix --dry-run --diff'
}
}
}
}
stage('Test') {
steps {
sh 'php bin/phpunit --log-junit results.xml'
}
post {
always {
junit 'results.xml'
}
}
}
stage('Deploy') {
when {
branch 'main'
}
steps {
sh './deploy.sh production'
}
}
}
post {
failure {
slackSend channel: '#ci', message: "Build FAILED: ${env.JOB_NAME}"
}
}
}
قراءة خط الأنابيب كتلةً كتلة
agent { dockerfile { … } }: يُنفَّذ كل بناء داخل حاوية مؤقتة تُنشأ من الصورة الموصوفة في ملفDockerfile.ciالخاص بالمشروع (المفصّل أدناه). تلزم إضافة Docker Pipeline، ويجب أن يتوفر Docker على الوكيل.environment: يعرّف متغيرات مرئية لكل الخطوات. تجلبcredentials('database-url')سراً مخزناً في Jenkins؛ وبالنسبة لبيانات اعتماد من نوع «Secret text»، يحتوي المتغير على القيمة مباشرة ويخفيها Jenkins في السجلات.parallel: لا يعتمد PHPStan وPHP-CS-Fixer أحدهما على الآخر، لذا يعملان في الوقت نفسه. تفشل المرحلة الأم إذا فشل أيٌّ منهما.junit: عند وضعه في كتلةpost { always { } }، ينشر تقرير الاختبارات حتى عند فشل بعضها. يعرض Jenkins حينها السجل التاريخي والاختبارات غير المستقرة.when { branch 'main' }: لا يعمل هذا الشرط إلا في مهمة من نوع Multibranch Pipeline، حيث يعرف Jenkins اسم الفرع. في مهمة Pipeline بسيطة، ستُتجاوز هذه المرحلة دائماً.post { failure { } }: لا يُرسل إشعار Slack إلا عند الفشل. الأمرslackSendتوفره إضافة Slack Notification.
صورة بناء تحتوي على Composer
لا تحتوي الصورة الرسمية php:8.3-cli على Composer ولا على git ولا على الامتداد zip. لذلك يعتمد خط الأنابيب على ملف Dockerfile.ci مُدار بالإصدارات مع المشروع، يضيف إليها ما يحتاجه البناء:
FROM php:8.3-cli
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip libzip-dev libicu-dev openssh-client \
&& docker-php-ext-install zip intl pdo_mysql \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
مع dockerfile { filename 'Dockerfile.ci' }، يبني Jenkins الصورة، ويخزنها مؤقتاً على الوكيل، ويشغّل البناء داخلها. وهكذا تصبح بيئة CI موصوفة في المستودع لا في إعدادات جهاز بعينه.
خط أنابيب أكثر متانة
يعمل المثال الأول، لكنه يفتقر إلى عدة ضمانات تُضاف دائماً في بيئة الإنتاج: مدة قصوى، ومنع عمليات البناء المتزامنة على الفرع نفسه، وتدوير السجل، وأرشفة المخرجات (artifacts)، وموافقة بشرية قبل النشر في الإنتاج.
pipeline {
agent {
dockerfile {
filename 'Dockerfile.ci'
}
}
options {
timeout(time: 30, unit: 'MINUTES')
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '20'))
timestamps()
}
environment {
APP_ENV = 'test'
COMPOSER_HOME = "${env.WORKSPACE}/.composer"
}
stages {
stage('Install') {
steps {
sh 'composer install --prefer-dist --no-progress --no-interaction'
}
}
stage('Package') {
steps {
sh 'mkdir -p dist && tar --exclude=./dist --exclude=./.git -czf dist/app.tar.gz .'
archiveArtifacts artifacts: 'dist/app.tar.gz', fingerprint: true
}
}
stage('Deploy production') {
when {
branch 'main'
beforeInput true
}
input {
message 'Deploy to production?'
ok 'Deploy'
submitter 'release-managers'
}
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh './deploy.sh production'
}
}
}
}
post {
always {
cleanWs()
}
}
}
بعض التوضيحات:
timeoutيوقف بناءً عالقاً (اختبار ينتظر خدمة غير متاحة مثلاً) بدلاً من احتجاز منفِّذ (executor) لساعات.disableConcurrentBuilds()يمنع تداخل عمليتي نشر للفرع نفسه.COMPOSER_HOMEيشير إلى مساحة العمل (workspace): يشغّل Jenkins الحاوية بمعرّف UID الخاص بالوكيل، وغالباً ما يكون مجلده الشخصي غير قابل للكتابة داخل الصورة.inputيوقف خط الأنابيب مؤقتاً إلى أن يوافق عضو من مجموعةrelease-managers. ومعbeforeInput true، يُقيَّم شرط الفرع قبل طلب الموافقة، فلا تُحجب الفروع الأخرى أبداً.sshagent(إضافة SSH Agent) يحمّل مفتاح النشر طوال مدة الخطوة فقط، دون أن يكتبه أبداً في مساحة العمل.cleanWs()(إضافة Workspace Cleanup) يحذف مساحة العمل في النهاية، لينطلق البناء التالي من حالة نظيفة.
أخطاء شائعة
- استيفاء الأسرار: كتابة
sh "mysql -p${DB_PASSWORD}"بعلامتي تنصيص مزدوجتين تجعل Groovy يستوفي السر قبل التنفيذ، ويعرض Jenkins تحذيراً أمنياً. استخدم علامات تنصيص مفردة،sh 'mysql -p"$DB_PASSWORD"'، ليقرأ الـ shell متغير البيئة بنفسه. - وكيل محتجز أثناء
input: مع وكيل عام، يبقى المنفِّذ محجوزاً أثناء انتظار الموافقة، ويستمرtimeoutالعام في العد. في حالات الانتظار الطويل، صرّح بـagent noneعلى مستوى خط الأنابيب ووكيل لكل مرحلة. - إضافات غير مُصانة: كل إضافة هي اعتمادية. اكتفِ بما تحتاجه منها وحدّثها بانتظام، فهي مصدر متكرر للثغرات.
- البناء على وحدة التحكم: اضبط عدد المنفِّذين على صفر في العقدة المدمجة، وشغّل عمليات البناء على وكلاء مخصصين، لحماية وحدة التحكم وأسرارها.
التحقق من Jenkinsfile قبل الدفع
غالباً لا يُكتشف خطأ الصياغة في Jenkinsfile إلا بعد الدفع (push). يوفر Jenkins أداة تدقيق (linter) لخطوط الأنابيب التصريحية، يمكن استخدامها برمز API:
curl -X POST --user "$JENKINS_USER:$JENKINS_TOKEN" \
-F "jenkinsfile=<Jenkinsfile" \
"$JENKINS_URL/pipeline-model-converter/validate"
تبيّن الاستجابة ما إذا كان الملف صالحاً أو تحدد السطر الذي يحتوي على الخطأ. ويمكن دمج هذا الأمر بسهولة في Git hook أو في المحرر.
المشاركة عبر Shared Libraries
عندما تتشارك عدة مشاريع الخطوات نفسها (الإشعار، النشر، نشر الصور)، يصبح تكرار Jenkinsfile سريعاً أمراً يصعب التحكم فيه. تتيح Shared Libraries وضع هذه الشيفرة المشتركة في مستودع Git منفصل، يُحمَّل بـ @Library('library-name') _ في أعلى Jenkinsfile. وهكذا يحتفظ كل مشروع بخط أنابيب قصير وسهل القراءة.
أفضل ممارسات Jenkins
- استخدام وكلاء Docker للعزل
- تشغيل المراحل المستقلة بالتوازي
- تخزين بيانات الاعتماد في Jenkins Credentials
- إعداد إشعارات عند الفشل
- أرشفة مخرجات البناء
- إدارة Jenkinsfile وصورة البناء بالإصدارات مع الشيفرة
- تحديد
timeoutوتدوير للسجل دائماً
متى لا تختار Jenkins
إذا كانت شيفرتك مستضافة على GitHub أو GitLab ولم يكن لديك قيد استضافة خاص، فإن الأدوات المدمجة في هاتين المنصتين تتطلب صيانة أقل بكثير. يصبح Jenkins منطقياً تماماً حين يجب أن تبقى عمليات البناء داخل شبكتك، أو تصل إلى موارد داخلية، أو حين يكون لديك فريق قادر على إدارة المنصة على المدى الطويل.