انشر بإعادة تسمية واحدة، وتراجع في 9 ميلي ثانية، واحذر 8 فخاخ
عدّة النشر التي أستخدمها لموقع ثابت على VPS صغير: مجلد لكل إصدار، وإعادة تسمية ذرية تنشره، وتراجع في 9 ميلي ثانية، وحاوية Caddy لا تملك إلا القراءة، و8 فخاخ كشفها الاختبار.
كيف أُبقي وحدي نظامًا محاسبيًا من 322 ألف سطر صحيحًا: بوابة حفظ واحدة، وقيود مالية لا تُعدَّل، وسجل تدقيق ببصمات متسلسلة، واختبارات على قواعد حقيقية، وتدقيق كشف ما فاتها.
لماذا تُفسد طابعات ESC/POS الحروف العربية، وكيف تطبعها صورةً أحادية البت بعرض الرأس الحقيقي، وخلل الأبعاد الذي حوّل إيصالًا طوله 12 سم إلى رولٍ كامل ملقى على الأرض.
فوترة محطات الكهرباء دون اتصال: معرّف واحد يرافق الدفعة من الهاتف إلى الإيصال، ولماذا لا تُجمع إجماليات الفواتير، وطابور يعرف ما يعيده، وخطأ 500 صنع حلقة لا تنتهي.
كان الأرشيف 9.4 ميغابايت، فيه 413 مدخلًا، والملف ./index.html في السطر 260 من قائمته. قرأه سكربت النشر ثم طبع:
text
!! /tmp/moghalis-site.tar.gz has no index.html: not a site build.
السكربت كان مخطئًا. الحارس الذي كتبته ليصدّ الرفع المعطوب صدّ للتو رفعًا سليمًا، وكان سيصدّ كل بناء حقيقي بعده.
اكتشفت ذلك في حاوية تجريبية وأنا أختبر عدّة النشر من أولها إلى آخرها قبل كتابة هذا المقال. وكان واحدًا من ثمانية فخاخ وجدتها بالطريقة نفسها، لا يظهر أيٌّ منها حتى تختبره.
في نهاية المقال ستكون معك العدّة كاملة: سكربتان، وملف Caddyfile، وملف compose، وسبب كل سطر فيها، وقائمة تحقق. تنشر بها موقعًا ثابتًا على VPS صغير في خطوة ذرية واحدة، وتعيد الإصدار السابق في 9 ميلي ثانية.
كل إصدار يُفك في مجلد جديد يحمل اسم لحظة نشره. Caddy يخدم current ولا شيء غيره. النشر توجيه current إلى مجلد جديد، والتراجع توجيهه إلى مجلد أقدم.
لا شيء يُكتب فوق ملف قائم، فالمجلد الذي يقرأ منه Caddy لا يكون أبدًا نصفه قديمًا ونصفه جديدًا. ولاحظ أن الرابط مطلق، فهذه التفصيلة هي التي ستحدد طريقة تركيب الحاوية لاحقًا.
الحزمtar للبناء بمسارات تبدأ بـ ./
الرفعscp للأرشيف إلى /tmp على الخادم
الفحصغير فارغ، وفيه ./index.html، ومقروء حتى آخره
الفكفي مجلد جديد releases/<الوقت>/
التبديلإعادة تسمية current.new فوق current
التحققطلب كل صفحة أساسية من الخادم نفسه
نشرة واحدة، من جهازي إلى الموقع الحي
من جهازي، بعد البناء، ثلاثة أوامر:
bash
tar -czf deploy/out/moghalis-site.tar.gz -C dist .scp deploy/out/moghalis-site.tar.gz moghalis@your-vps:/tmp/ssh moghalis@your-vps /srv/moghalis/stack/deploy.sh
الجزء -C dist . أهم مما يبدو: يخزّن كل مسار بصيغة ./index.html و./assets/…، وهي بالضبط السلسلة التي يبحث عنها الحارس.
نسخة GNU تنشئ رابطًا مؤقتًا ثم تعيد تسميته فوق القديم. أما BusyBox، وهو الـ ln الموجود داخل الصور المبنية على Alpine، فيحذف الرابط القديم ثم ينشئ الجديد. وبين الاستدعاءين لا يوجد current أصلًا.
GNU coreutils 9.1BusyBox على Alpine
ينشئ الرابط باسم عشوائييحذف current
يعيد تسميته فوق current في استدعاء واحدينشئ current من جديد
current موجود في كل لحظةفجوة بين استدعاءين للنظام
الأمر نفسه، متتبَّعًا على نظامين
كم مرة يسقط طلب في هذه الفجوة؟ بدّلت الإصدار 10,000 مرة بينما حلقةٌ تقرأ current/index.html بأقصى سرعتها، وكررت التجربة ثلاث مرات.
ln -sfn على BusyBox، التجربة 1357
ln -sfn على BusyBox، التجربة 2269
ln -sfn على BusyBox، التجربة 3260
ln باسم مؤقت ثم mv -T0
قراءات لم تجد current، لكل 10,000 تبديل
نسخة إعادة التسمية أجرت 60,488 قراءة في تجاربها الثلاث، ولم تفشل منها واحدة. النشر الحقيقي يبدّل مرة واحدة، فالفجوة ضئيلة. والضئيل يبقى أكبر من الصفر، والعلاج سطر واحد إضافي.
لذلك لا يثق سكربتي بذرية ln. ينشئ الرابط باسم مؤقت ثم يعيد تسميته بنفسه، لأن rename() يستبدل الاسم في خطوة واحدة على Linux:
لا يحتاج Caddy إلى إعادة تشغيل. النواة تحل الرابط مع كل فتح لملف، فيُخدم الطلب التالي من المجلد الجديد. في تجربتي استغرق التبديل 5 ميلي ثانية، وبقي عدّاد إعادة تشغيل الحاوية على الصفر.
نعود إلى البداية. قبل أن يلمس deploy.sh أي شيء، يفحص مدخلاته:
bash
set -euo pipefail[ -s "$ARCHIVE" ] || { echo "!! $ARCHIVE is missing or empty: upload it first (scp)."; exit 1; }tar -tzf "$ARCHIVE" | grep -qx './index.html' || { echo "!! $ARCHIVE has no index.html: not a site build."; exit 1; }
يبدو سليمًا. أعطه الآن أرشيفًا فيه 413 مدخلًا.
grep -q يتوقف عند أول تطابق. يجد ./index.html في السطر 260 ويخرج. لكن tar ما زال يكتب القائمة، فتذهب كتابته التالية إلى أنبوب لا قارئ له. ترسل له النواة SIGPIPE فيموت بالحالة 141. وتحت pipefail يكفي فشل جزء واحد ليفشل الأنبوب كله، فيبلّغ الحارس عن ملف مفقود وهو أمامه.
الأرشيف التجريبي الصغير ينجح، لأن قائمته كلها تخرج في كتابة واحدة فينتهي tar قبل أن يخرج grep. وبنائي الأقدم ذو الـ 156 مدخلًا يفشل هو الآخر. هكذا ينجو حارس كهذا من الاختبار السريع ثم يوقف البناء الحقيقي.
العلاج حذف حرف واحد. بدون -q يقرأ grep حتى النهاية، وينتهي tar انتهاءً طبيعيًا، ويعود pipefail إلى عمله الحقيقي:
bash
tar -tzf "$ARCHIVE" | grep -x './index.html' >/dev/null || { echo "!! $ARCHIVE has no index.html: not a site build."; exit 1; }
جرّبت النسختين على خمسة أرشيفات:
الأرشيف
مع grep -q
بدون -q
بناء اليوم، 413 مدخلًا
رُفض
قُبل
البناء نفسه مقطوعًا عند 4 MB، كرفعٍ انقطع
رُفض
رُفض
ملف فارغ
رُفض
رُفض
مجلد assets/ وحده
رُفض
رُفض
محزوم من المجلد الخطأ، بصيغة dist/index.html
رُفض
رُفض
الحارس المعطوب نجح في كل اختبار كنت سأفكر في كتابته، لأنها كلها أعطته مدخلات سيئة. الحارس الذي يرفض كل شيء يبدو مثاليًا إلى أن تعطيه شيئًا سليمًا.
والأرشيف المقطوع هو الحالة التي وُجد pipefail من أجلها. tar -tzf يقرأ حتى النهاية، فيفشل مع الرفع المبتور، ويتوقف النشر قبل أن يُفك ملف واحد.
بعد التبديل يرتّب السكربت المكان. وسطر الترتيب هذا قادر على حذف الإصدار الذي نشره للتو.
المجلد الذي أنشأته للتو يُفترض أنه الأحدث، لكنه بحسب الـ mtime ليس كذلك بالضرورة. الأرشيف يحمل مدخلًا للمجلد ./ نفسه، وtar يطبع توقيت هذا المدخل على مجلد الإصدار. فيصير الـ mtime لحظةَ البناء على جهازي، أيًّا كان وقت النشر.
أعد الآن نشر أرشيف أقدم، بناء الأسبوع الماضي مثلًا، لأنك تريده وقد خرج من الخمسة. يبدو مجلده الجديد الأقدمَ على القرص، فيختاره tail -n +6، ويحذف السكربت الإصدار الذي جعله حيًّا قبل لحظة.
أعدت إنتاج ذلك: خمسة إصدارات بُنيت من 10 إلى 14 أكتوبر، ثم بناء من 1 أكتوبر. بعدها أشار current إلى مجلد لم يعد موجودًا، وأجاب Caddy عن كل عنوان بـ 404 وجسم فارغ.
اسم المجلد هو أصلًا وقت النشر، يكتبه date +%Y%m%d-%H%M%S، ويترتب ترتيبًا صحيحًا كنص عادي:
الخيار --resolve يرسل الطلب إلى هذا الجهاز مع إبقاء اسم moghalis.com في مصافحة TLS وفي ترويسة Host. فيخرج من الطريق الـ DNS، وذاكرة المحلّل على جهازي، وكل ما بينهما.
لذلك تثبت الـ 200 هنا أربعة أمور معًا: Caddy يعمل، والشهادة صالحة للاسم (فلا يوجد -k)، والكتلة الصحيحة من الإعداد هي التي أجابت، والملف موجود في الإصدار الحي الآن.
على خادم جديد تمامًا، قبل أن يشير إليه الـ DNS، لا يملك Caddy شهادة بعد، فيقرأ كل سطر 000. بعد أول شهادة يجب أن يقرأ كل سطر 200. و|| true يمنع set -e من قتل التقرير عند أول طلب فاشل.
الفحص يجري بعد التبديل، لأن Caddy لا يخدم إلا current. إنه تقرير، وأنا أقرؤه. وإن ظهر سطر أحمر فالأمر التالي هو rollback.sh.
#!/usr/bin/env bash# Put the previous release back live (one step, no restart).set -euo pipefailROOT=/srv/moghalisCURRENT=$(readlink -f "$ROOT/current")PREVIOUS=$(ls -1dr "$ROOT"/releases/*/ | sed 's:/$::' | grep -vx "$CURRENT" | head -n 1 || true)[ -n "$PREVIOUS" ] || { echo "!! No earlier release to go back to."; exit 1; }ln -sfn "$PREVIOUS" "$ROOT/current.new"mv -Tf "$ROOT/current.new" "$ROOT/current"echo "==> Rolled back to $PREVIOUS (was $CURRENT)"
يختار أحدث إصدار ليس هو الحي، ويبدّل إليه. السكربت كله استغرق 9 ميلي ثانية في تجربتي، والدخول عبر SSH يأخذ وقتًا أطول من التراجع نفسه.
ثم جاءت المفاجأة. شغّله مرتين وستعود إلى حيث بدأت. بعد المرة الأولى يصير أحدث إصدار غير حي هو الذي غادرته للتو، فتعيدك المرة الثانية إلى الأمام. ومع الإصدارات A وB وC المعطوب، يتأرجح بين C وB ولا يبلغ A أبدًا.
والقاعدة نفسها تلدغ لاحقًا. تراجع من C إلى B، ثم انشر إصلاحًا باسم D. فإن احتاج D إلى تراجع، كان أحدث إصدار غير حي هو C، المعطوب. لذلك احذف بعد كل تراجع المجلد الذي تراجعت عنه. وللرجوع أبعد عن قصد، سمِّ المجلد بنفسك:
الكتابة على نظام ملفات الحاوية نفسها: touch /etc/x يفشل بـ Read-only file system
:ro على /srv/moghalis
تعديل أي إصدار، حتى على Caddy نفسه
cap_drop: [ALL]
كل صلاحيات Linux. ما زال يعمل بصفة root في الداخل، لكنه root لا يستطيع حتى تجاوز أذونات الملفات
cap_add: [NET_BIND_SERVICE]
يعيد شيئًا واحدًا: الاستماع على 80 و443
no-new-privileges
كسب صلاحيات عبر ملف setuid
mem_limit: 128m
أن تُسقط عمليةٌ منفلتة الخادمَ كله. استهلك Caddy خاملًا 13 MB في تجاربي
خيارات السجلات
امتلاء القرص بالسجلات: ثلاثة ملفات، 10 MB لكل منها كحد أقصى
لا يكتب Caddy إلا في caddy_data، حيث يحفظ الشهادات التي يستخرجها ويجددها بنفسه. ومنفذ UDP على 443 لأجل HTTP/3. وقبل أول docker compose up -d، تحقّق من الإعداد في الصورة نفسها:
تفصيلتان في التركيب تحملان التصميم كله. الأولى أنه يركّب /srv/moghalis كله. تركيب الـ bind يحل الرابط الرمزي مرة واحدة عند بدء الحاوية، ثم يتمسك بذلك المجلد. اختبرت ذلك: بعد التبديل رأى المضيف الإصدار B، وظل الـ bind mount يعرض A. ركّب current وحده، وسيعلن كل نشر نجاحه بينما تخدم الحاوية الإصدار الذي بدأت به.
الثانية أن المجلد في المسار نفسه داخل الحاوية. الرابط مطلق، فيجب أن يوجد /srv/moghalis/releases/… في الداخل أيضًا. ركّبه في /site بدلًا من ذلك، وسيشير current إلى لا شيء.
أما الخادم نفسه فيُجهَّز مرة واحدة بـ bootstrap.sh: جدار ناري لا يفتح إلا SSH و80 و443، وfail2ban، وتحديثات أمنية تلقائية، وDocker من مستودعه الرسمي، و2 GB من الـ swap. ولا يطفئ الدخول بكلمة المرور إلا بعد أن يملك المستخدم الجديد مفتاح SSH، فلا يمكن أن يغلق الخادم في وجهي.
ملف Caddyfile قصير. سطران منه كانا خطأً في إعدادي السابق، ولم يكن المتصفح قادرًا على إظهار أيٍّ منهما.
كل عنوان كان يعيد الصفحة الرئيسية. جاء الإعداد من أيام تطبيق الصفحة الواحدة:
caddy
try_files {path} /index.html
كل ما ليس ملفًا يأخذ ملف HTML الوحيد في التطبيق. ثم بدأت أبني كل صفحة مسبقًا في <path>/index.html، وهي 94 صفحة، فكان هذا السطر سيجيب عنها كلها بالصفحة الرئيسية.
جرّبته على شجرة إصدار. /ar و/work/saima وعنوان مختلَق مثل /no-such-page عادت كلها بالصفحة الرئيسية، وكلٌّ منها بـ 200 OK. في المتصفح كان التطبيق سيرسم الصفحة الصحيحة فور تشغيل JavaScript، فيبدو التنقل سليمًا. أما محرك البحث، ومعاينة الروابط، ومن يتصفح بلا JavaScript، فكانوا سيحصلون على 94 نسخة من الصفحة الرئيسية، وكل عنوان مكتوب خطأً كان سيصير soft 404.
قاعدة تخزين مؤقت طابقت صفحات. لقطات شاشة دراسات الحالة تقع في مجلد صفحتها نفسه: /work/saima/index.html بجوار /work/saima/01-voice-studio.webp. وقاعدة الأسبوع الكامل على /work/*، التي كُتبت للصور، أمسكت الصفحة أيضًا.
ومع الإصدارات الذرية يصير هذا أسوأ من محتوى قديم. كل مجلد إصدار لا يحوي إلا ملفات CSS وJavaScript ذات البصمة الخاصة به. والمتصفح الذي يحتفظ بصفحة عمرها أسبوع يطلب ملفات لم تعد في الإصدار الحي، وقد تحققت: تعود بـ 404. في أحسن الأحوال يرى الزائر صفحة الأسبوع الماضي. وفي أسوئها احتفظت ذاكرته بالـ HTML وأسقطت الملفات التي يسمّيها، فتُحمَّل الصفحة بلا أي تنسيق.
القواعد الآن حسب نوع الملف. وكل ما ليس ملفًا ذا بصمة ولا صورة فهو صفحة:
وهذا ما يجيب به الإعداد المصحح، مفحوصًا في الحاوية المقيّدة نفسها:
الطلب
الحالة
Cache-Control
/work/saima
200
no-cache
/work/saima/01-voice-studio.webp
200
public, max-age=604800
/assets/index-….js
200
public, max-age=31536000, immutable
/no-such-page
404، بمحتوى 404.html
no-cache
وno-cache يخزّن فعلًا: يحتفظ المتصفح بالصفحة ويسأل الخادم إن تغيّرت، فتكلّف كل زيارة تحققًا سريعًا واحدًا، ويظهر الإصدار الجديد لحظة نشره.
الخطآن بدوا سليمين في المتصفح. وجدتهما بقراءة رموز الاستجابة والترويسات، وهذه الحلقة هي الفحص كله:
bash
for p in / /work/saima /work/saima/01-voice-studio.webp /no-such-page; do curl -s -o /dev/null -w "%{http_code} %header{cache-control} $p\n" "https://moghalis.com$p"done
يحتاج %header{} إلى curl 7.84 أو أحدث، وUbuntu 24.04 يأتي بالإصدار 8.5.
#!/usr/bin/env bash# Publish a new release from /tmp/moghalis-site.tar.gz.set -euo pipefailARCHIVE=${1:-/tmp/moghalis-site.tar.gz}ROOT=/srv/moghalisRELEASE=$ROOT/releases/$(date +%Y%m%d-%H%M%S)[ -s "$ARCHIVE" ] || { echo "!! $ARCHIVE is missing or empty: upload it first (scp)."; exit 1; }tar -tzf "$ARCHIVE" | grep -x './index.html' >/dev/null || { echo "!! $ARCHIVE has no index.html: not a site build."; exit 1; }mkdir -p "$RELEASE"tar -xzf "$ARCHIVE" -C "$RELEASE"ln -sfn "$RELEASE" "$ROOT/current.new"mv -Tf "$ROOT/current.new" "$ROOT/current"echo "==> Live: $RELEASE"# Keep the five newest releases, by name: the name is the deploy time.ls -1dr "$ROOT"/releases/*/ | tail -n +6 | xargs -r rm -rf# Ask this server for the routes, whatever DNS says.for path in / /ar /work /skills /resume /blog /ar/blog /tools /tools/saudi-validators /blog/rss.xml /work/saima /og-image.jpg; do code=$(curl -s -o /dev/null -w '%{http_code}' --resolve moghalis.com:443:127.0.0.1 "https://moghalis.com$path" || true) echo " $code $path"done
وملف Caddyfile، ناقصًا كتلة واحدة لصفحات العروض الخاصة بي:
هذا هو النظام كله: مجلد لكل إصدار، وإعادة تسمية واحدة، وصندوق لا يملك إلا القراءة. يكفيه VPS صغير واحد ولا بناء على الخادم، والإصدار المعطوب على بُعد 9 ميلي ثانية من الزوال. وأغلب أسطره موجودة لتمنع خطأً ما كان المتصفح ليريني إياه.
التعليقات
شارك في النقاش
سجّل الدخول بحساب Google لتعجب بالمقالات وتعلّق عليها. ضغطة واحدة تكفي.