استضافة Next.js 16 ذاتيًا في Docker: مخرجات standalone ومعالجات التخزين المشتركة ومشكلات تعدد النسخ
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗الخلاصة: مكان تطبيق Next.js 16 المستضاف ذاتيًا هو صورة Docker مبنية بالإعداد output: "standalone"، مع نسخ المجلدين public و.next/static إليها وضبط HOSTNAME=0.0.0.0. الحاوية الواحدة تعمل دون أي إعداد إضافي، أما النسخة الثانية فتحتاج إلى مفتاح تشفير مشترك لـ Server Functions، وإلى deploymentId، وإلى معالجات تخزين مؤقت مشتركة، لأن Next.js يخزّن ويُبطل الذاكرة المؤقتة داخل كل عملية على حدة افتراضيًا.
ليست كل التطبيقات التي نطلقها في Devya تعمل على Vercel. بعضها يعمل على خادم VPS بجوار واجهة NestJS وقاعدة بيانات Postgres، وهناك ينشر فريقنا Next.js كحاوية Docker خلف nginx. الحاوية الأولى سهلة. تبدأ المشكلات مع النسخة الثانية: صفحة تعرض محتوى مختلفًا حسب الحاوية التي تجيب، وrevalidateTag() يبدو أنه يعمل نصف الوقت فقط، وفحص صحة الحاوية يفشل بينما يعمل الموقع بشكل طبيعي. كل مشكلة منها سببها إعداد افتراضي يفترض وجود عملية واحدة. هذه هي الصورة التي نبنيها والإعدادات التي تجعل عدة نسخ تتصرف كتطبيق واحد.
أهم النقاط
• يجعل الإعداد output: "standalone" الأمر next build يُخرج المجلد .next/standalone: ملف server.js مصغّر مع الملفات المتتبَّعة فقط من node_modules، فلا تحتاج صورة التشغيل إلى تثبيت الاعتماديات.
• يستبعد مجلد standalone المجلدين public و.next/static؛ انسخهما إلى الصورة أو قدّمهما من CDN، وإلا ستظهر صفحة HTML بينما يعيد كل ملف JavaScript الخطأ 404.
• اضبط HOSTNAME=0.0.0.0 داخل الصورة، لأن Docker يضبط HOSTNAME على معرّف الحاوية، وserver.js يربط الخادم بالعنوان الذي يحدده هذا المتغير.
• في Next.js 16 إعدادان لمعالجات التخزين المؤقت: cacheHandler (بالمفرد) يخزّن صفحات ISR واستجابات route handlers، وcacheHandlers (بالجمع) يخدم 'use cache' و'use cache: remote'. وكلاهما يخزّن افتراضيًا داخل عملية واحدة.
• لا يُبطل revalidateTag() إلا ذاكرة النسخة التي نفّذته، ما لم يكتب معالج تخزين مشترك هذا الإبطال في مخزن مشترك مثل Redis، وتقرأه كل نسخة في refreshTags().
ماذا ينتج الإعداد output: "standalone" فعليًا؟
مخرجات standalone هي وضع بناء في Next.js ينسخ خادم الإنتاج والملفات التي يحتاجها فقط إلى .next/standalone. أثناء next build يشغّل Next.js الأداة @vercel/nft، وهي متتبّع ملفات Node.js من Vercel، على كل استدعاء import وrequire وfs، ثم ينسخ الملفات المتتبَّعة وحدها من node_modules. ويُشغَّل الناتج بالأمر node server.js بدلًا من next start. وتفعيله سطر واحد في next.config.ts هو output: "standalone".
يُستبعد مجلدان عن قصد: public و.next/static. تفترض وثائق Next.js أن CDN سيقدّمهما، ولا يقدّمهما server.js بنفسه إلا بعد نسخهما إلى standalone/public وstandalone/.next/static. ونسيان النسخة الثانية يعطي صفحة يظهر HTML فيها لكنها لا تكتمل تفاعليًا أبدًا.
تحتاج المستودعات الأحادية (monorepos) إلى إعداد إضافي. يتوقف تتبّع الملفات عند مجلد مشروع Next.js، فتغيب حزم مساحة العمل الموجودة خارجه عن المخرجات حتى يشير outputFileTracingRoot إلى جذر المستودع. أما الملفات التي تُحمَّل عبر مسارات محسوبة، مثل ملفات JSON للغات تُقرأ بواسطة fs، فتُضاف إلى outputFileTracingIncludes. ومنذ Next.js 15 يستخدم next start ومخرجات standalone المكتبة sharp لتحسين الصور تلقائيًا، فلم نعد نثبّتها يدويًا.
كيف يبدو ملف Dockerfile إنتاجي لـ Next.js 16؟
تحتاج صورة Next.js الإنتاجية إلى ثلاث مراحل: تثبيت الاعتماديات، ثم البناء، ثم مرحلة تشغيل لا تحتوي إلا مخرجات standalone. يتبع ملف Dockerfile لدينا مثال with-docker الرسمي، الذي يستخدم الصورة node:24-slim المبنية على Debian والمستخدم غير المميز node.
تنسخ مرحلة deps الملف package.json وملف القفل وتشغّل pnpm install مع تثبيت الإصدارات. وتنسخ مرحلة builder المجلد node_modules والشيفرة المصدرية، وتستقبل DEPLOYMENT_VERSION كوسيط بناء، وتشغّل pnpm build مع قراءة NEXT_SERVER_ACTIONS_ENCRYPTION_KEY من secret mount في BuildKit، ما يبقي المفتاح خارج سجل الصورة. أما مرحلة runner فتضبط NODE_ENV=production وPORT=3000 وHOSTNAME=0.0.0.0، وتنسخ .next/standalone و.next/static وpublic مع منح ملكيتها للمستخدم node، ثم تنتقل إلى USER node وتبدأ بالأمر CMD ["node", "server.js"].
لا تتضمن الصورة slim الأداة curl، لذلك يشغّل HEALTHCHECK لدينا سكربت node -e من سطر واحد يستدعي fetch() المدمجة في Node.js 24 على العنوان http://127.0.0.1:3000/api/health. ويستدعي هذا الـ route handler الدالة await connection() من next/server قبل أن يعيد Response.json({ status: "ok" })، حتى لا يحوّله Next.js أبدًا إلى ملف ثابت يجيب بالرمز 200 إلى الأبد.
الإعداد HOSTNAME=0.0.0.0 هو ما يصلح فحص الصحة الفاشل. يضبط Docker المتغير HOSTNAME على معرّف الحاوية، ويضبطه Kubernetes على اسم الـ pod. ويقرأ server.js المتغير HOSTNAME كعنوان للربط، فبدون هذا الضبط يستمع فقط على عنوان شبكة الحاوية: تعمل الحركة القادمة عبر المنفذ المنشور، بينما يُرفض أي فحص على 127.0.0.1 من داخل الحاوية.
فخ البيئة الآخر هو البادئة NEXT_PUBLIC_. يدمج Next.js قيم NEXT_PUBLIC_* في حزمة المتصفح أثناء next build، فالصورة المبنية بقيم بيئة الاختبار تحمل عناوين بيئة الاختبار إلى الإنتاج. ولترقية صورة واحدة عبر كل البيئات، نستخدم البادئة فقط للقيم التي لا تتغير، ونقرأ القيم الخاصة بكل بيئة على الخادم بعد await connection() ثم نمررها إلى Client Components كخصائص. ويحتوي ملف .dockerignore على .next وnode_modules و.git و.env* ليبقي البناءات المحلية والأسرار خارج سياق البناء.
ما الذي يتعطل عندما نشغّل أكثر من نسخة من Next.js؟
تُعطّل النسخة الثانية من Next.js خلف موزّع الأحمال ثلاثة أشياء: تشفير Server Functions، واكتشاف اختلاف الإصدارات (version skew)، والتخزين المؤقت. فكل منها يعتمد افتراضيًا على حالة تعيش داخل بناء واحد أو عملية واحدة.
• مفتاح تشفير Server Functions: الافتراضي مفتاح عشوائي جديد لكل بناء، ما يسبب أخطاء "Failed to find Server Action" عندما تأتي النسخ من بناءات مختلفة. والحل هو NEXT_SERVER_ACTIONS_ENCRYPTION_KEY وقت البناء، بقيمة base64 طولها 16 أو 24 أو 32 بايت.
• deploymentId: غير مضبوط افتراضيًا، فيجمع النشر التدريجي بين متصفحات بالإصدار القديم وخوادم بالإصدار الجديد، فتظهر ملفات مفقودة ومعرّفات إجراءات مجهولة. والحل هو deploymentId: process.env.DEPLOYMENT_VERSION في next.config.ts.
• ذاكرة ISR وroute handlers: تُخزَّن افتراضيًا على القرص مع 50 ميغابايت من الذاكرة لكل نسخة، فتقدّم النسخ إصدارات مختلفة من الصفحة نفسها. والحل هو cacheHandler مع cacheMaxMemorySize: 0.
• مدخلات 'use cache': ذاكرة LRU داخل كل عملية افتراضيًا، فتملأ كل نسخة نسختها الخاصة وتُنهي صلاحيتها بمفردها. والحل هو معالج cacheHandlers.remote مع التوجيه 'use cache: remote'.
• revalidateTag(): يُبطل افتراضيًا ذاكرة النسخة المحلية فقط، فتستمر النسخ الأخرى في تقديم محتوى قديم. والحل هو حالة وسوم مشتركة تقرأها كل نسخة في refreshTags().
يضبط ملف next.config.ts لدينا في التطبيقات متعددة النسخ القيم output: "standalone" وcacheComponents: true، وdeploymentId من بصمة git التي يمررها CI، وcacheHandler يشير إلى ملف معالج ISR، وcacheMaxMemorySize: 0 حتى لا تقف طبقة ذاكرة خاصة بكل نسخة أمام Redis، وcacheHandlers بمدخل remote يشير إلى معالج مبني على Redis.
مفتاح التشفير هو البند الأدق. يدمج Next.js مفتاح البناء في مخرجات البناء، فالنسخ التي تبدأ من صورة واحدة متفقة أصلًا. ويسبب المفتاح الأعطال عندما يُبنى الإصدار نفسه أكثر من مرة، مثلًا لكل خادم أو لكل بيئة. ومع ذلك نضبطه صراحةً، بعد توليده مرة واحدة بالأمر openssl rand -base64 32، حتى لا تعتمد صحة التطبيق على عدد مرات البناء في CI.
عند ضبط deploymentId تحمل عناوين الملفات الثابتة المعامل ?dpl=<deploymentId>، وترسل عمليات التنقل في المتصفح الترويسة x-deployment-id، ويؤدي عدم التطابق إلى تحميل كامل للصفحة بدلًا من التنقل داخل المتصفح. كما يُبقي Next.js معرّف البناء ثابتًا عند ضبط deploymentId، ما لم يتجاوزه generateBuildId.
أي معالج تخزين نحتاج: cacheHandler أم cacheHandlers؟
في Next.js 16 إعدادان مستقلان لمعالجات التخزين المؤقت، والتطبيق متعدد النسخ الذي يستخدم Cache Components وISR يحتاج عادةً إلى الاثنين.
• cacheHandler (بالمفرد) يخدم صفحات ISR واستجابات route handlers والصور المحسّنة، ويطبّق الدوال get وset وrevalidateTag وresetRequestCache.
• cacheHandlers (بالجمع) يخدم 'use cache' عبر المفتاح default، و'use cache: remote' عبر المفتاح remote، وأي توجيه مسمّى 'use cache: <name>'، ويطبّق الدوال get وset وrefreshTags وgetExpiration وupdateTags.
نترك 'use cache' على المعالج الافتراضي في الذاكرة للبيانات الرخيصة التي يُقبل فيها اختلاف قصير بين النسخ، ونضع على البيانات المكلفة أو التي تُبطل بالوسوم التوجيه 'use cache: remote' الذي يذهب إلى Redis. ومثال ذلك دالة getProduct(id) تبدأ بالتوجيه "use cache: remote"، وتستدعي cacheTag بالقيمة product:<id> وcacheLife("hours")، ثم تستعلم من قاعدة البيانات.
تنسيق الوسوم هو الجزء الذي يجعل revalidateTag() يصل إلى كل النسخ. تعمل updateTags() على النسخة التي نفّذت الإبطال، وتكتب في Redis طابعًا زمنيًا لكل وسم، مع إضافة اسم الوسم إلى مجموعة في Redis للوسوم المُبطلة. وتعمل refreshTags() مرة واحدة لكل طلب، قبل أول قراءة من ذلك المعالج، فتجلب هذه الطوابع الزمنية إلى النسخة المحلية بأمر SMEMBERS واحد وأمر MGET واحد. ثم تعيد getExpiration() أحدث طابع زمني محلي للوسوم المطلوبة.
هذا التصميم يعامل كل إبطال على أنه فوري. يمرّر revalidateTag(tag, "max") وسيطًا ثانيًا اسمه durations إلى updateTags()، ويستخدمه المعالج الكامل لتقديم المدخلات القديمة أثناء تحديثها. ومن المفيد قراءة المشكلات الموثقة قبل كتابة get وset: لا يمكن قراءة تدفق value الخاص بالمدخل إلا مرة واحدة، ومدخلات ISR تحتوي قيم Buffer وMap لا يستطيع JSON.stringify() استعادتها كما هي، وNext.js لا يحذف أي شيء من المخزن أبدًا، فالإزالة عبر TTL مسؤولية المعالج. ويسرد ملف README لمثال Redis في Next.js معالجات من المجتمع تحل ذلك بالفعل، ونبدأ بأحدها قبل كتابة معالجنا الخاص.
كيف نحافظ على البث والإغلاق الآمن داخل الحاويات؟
يحتاج البث (streaming) إلى أن يتوقف كل وكيل أمام Next.js عن التخزين المؤقت للاستجابات، ويحتاج الإغلاق الآمن إلى مهلة إيقاف كافية للطلبات الجارية ولدوال after().
• التخزين المؤقت للاستجابات: يخزّن nginx الاستجابات الممررة افتراضيًا، فيصل محتوى Suspense الاحتياطي وهيكل Partial Prerendering مع أبطأ البيانات دفعة واحدة. نضبط proxy_buffering off في كتلة location داخل nginx، وتعرض وثائق Next.js الحل نفسه عبر الترويسة X-Accel-Buffering: no من headers().
• الإشارات: صيغة exec في CMD ["node", "server.js"] تجعل Node.js العملية رقم 1، فيستقبل SIGTERM مباشرة؛ أما صيغة shell فتعمل تحت /bin/sh الذي لا يمرر الإشارة.
• وقت التصريف: عند SIGTERM ينهي خادم Next.js الطلبات الجارية ودوال after() المعلقة. يرسل Docker الإشارة SIGKILL بعد 10 ثوانٍ افتراضيًا، وتوصي الوثائق بمهلة تصريف من 10 إلى 30 ثانية، لذلك نضبط stop_grace_period: 30s في Compose.
يعرّف إعداد nginx لدينا upstream بسطر server لكل نسخة، ويمرر الطلبات إليه مع proxy_http_version 1.1 وproxy_buffering off، ويمرر الترويسات Host وX-Forwarded-For وX-Forwarded-Proto حتى يرى Next.js العميل الحقيقي.
الأسئلة الشائعة
س: هل نحتاج إلى output: "standalone" لتشغيل Next.js داخل Docker؟
ج: لا. يعمل next start مع تثبيت كامل لـ node_modules، لكن مخرجات standalone تنسخ فقط الملفات المتتبَّعة التي يحتاجها الخادم، ما يجعل صورة التشغيل أصغر ويزيل تثبيت الاعتماديات من المرحلة الأخيرة.
س: لماذا يعرض تطبيق Next.js بوضع standalone صفحة HTML دون تحميل أي JavaScript؟
ج: لا يتضمن المجلد .next/standalone المجلدين .next/static وpublic. انسخ .next/static إلى .next/standalone/.next/static وpublic إلى .next/standalone/public، أو قدّم الاثنين من CDN باستخدام assetPrefix.
س: هل يمكن ترقية صورة Docker واحدة لـ Next.js من بيئة الاختبار إلى الإنتاج؟
ج: نعم، عندما تُقرأ القيم الخاصة بكل بيئة على الخادم وقت الطلب. تُدمج متغيرات NEXT_PUBLIC_ أثناء next build، فأي قيمة تختلف بين البيئات وتحمل هذه البادئة تفرض بناءً منفصلًا لكل بيئة.
س: هل يعمل revalidateTag() عبر عدة حاويات Next.js؟
ج: ليس افتراضيًا. لا يُبطل revalidateTag() إلا ذاكرة النسخة التي نفّذته؛ وللوصول إلى كل النسخ يجب أن يكتب معالج تخزين مخصص الإبطال في مخزن مشترك، وأن تقرأه كل نسخة في refreshTags() قبل تقديم المدخلات المخزنة.
قراءات إضافية
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.
هندسة الواجهات
صور Open Graph الديناميكية في Next.js 16: كيف نبني opengraph-image.tsx ونتحقق من إصدار Satori الذي تضمّنه next/og ونحافظ على صحة معاينات الروابط
واجهت بطاقات المشاركة المولّدة لدينا ثلاث مفاجآت: خط WOFF2 لم يستطع المحرّك قراءته، وملف README لـ Satori يصف محرّكًا أحدث من المضمّن في Next.js، وصفحة ظلّت تعرض صورة الـ layout. هذه ملاحظاتنا الميدانية حول opengraph-image.tsx في Next.js 16 وأولوية البيانات الوصفية وفحص og:image قبل مشاركة أي رابط.