الوحدة 2 · Images جاهزة للإنتاج
Base images صغيرة ومستخدمون غير root
20 دقراءة 4 داكتشف الخطأ
الدرس في لمحة
اختر base image لـ notes-api بالموازنة بين slim وAlpine وdistroless، وشغّل التطبيق بمستخدم غير root، وأحكِم إغلاق الـ container أكثر وقت التشغيل.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
في 2024 فشلت image لـ Skylane في مراجعة أمنية أجراها أحد العملاء، مع 140 نتيجة. أقل من خمس منها كانت في كود كتبناه أو استدعيناه. والباقي في حزم صادف أن الـ base image تتضمّنها: مكتبة XML، ومحوّل صور، ووكيل لنقل البريد. لا شيء منها كان يعمل، وكلها كانت مشكلتنا. الـ base image اعتمادية ترثها بالجملة، فاخترها عن قصد.
اختيار الـ base
| الـ Base | ما بداخلها | مناسبة لـ | انتبه إلى |
|---|---|---|---|
node:24 |
Debian كاملة، ومترجِمات، ومكتبات كثيرة | مراحل البناء التي تترجم إضافات أصلية | كبيرة؛ وفيها الكثير ليُفحص |
node:24-slim |
Debian مصغّرة + Node | معظم images التشغيل | ما زال فيها shell وapt |
node:24-alpine |
Alpine Linux (musl) + Node | images صغيرة جدًا | الفروق بين musl وglibc |
gcr.io/distroless/nodejs24-debian13 |
Node ومكتبات تشغيله فقط | images إنتاج محصّنة | لا shell ولا مدير حزم |
خياري الافتراضي لفريق جديد على الـ containers هو slim: صغيرة، ومبنية على glibc فتعمل الوحدات الأصلية الجاهزة، وما زلت تستطيع الدخول إليها بـ docker exec وفتح shell فيها حين تقع أزمة في الإنتاج.
تعطيك Alpine images أصغر، لكنها تستخدم مكتبة C musl بدلًا من glibc. الإضافات الأصلية الجاهزة المترجَمة لـ glibc تفشل في التحميل، أما الفروق الدقيقة في تحليل أسماء DNS وفي حجز الذاكرة فقد سبّبت لفرق كثيرة متاعب في الإنتاج. اخترها حين تكون قد اختبرت شجرة اعتمادياتك كاملة عليها، لا لأن الرقم في صفحة الـ tags أصغر.
الـ images من نوع Distroless تحتوي على Node.js والمكتبات التي يحتاج إليها، ولا شيء غير ذلك: لا shell، ولا apt، ولا curl. المهاجم الذي يحصل على تنفيذ كود يجد القليل جدًا ليعمل به، والماسح يجد القليل جدًا ليُبلغ عنه. الثمن هو صعوبة التشخيص. لا تستطيع فتح shell داخلها بـ exec، وستتعلّم طريقة Kubernetes للالتفاف على ذلك، kubectl debug، في الدرس الأخير من هذه الدورة.
توقّف عن التشغيل بصلاحيات root
افتراضيًا، تعمل العملية داخل الـ container كـ root، أي UID 0. الـ namespaces تحدّ ما يستطيع هذا الـ root رؤيته، لكنه يبقى root في نظر النواة. وإذا اجتمع ذلك مع خلل في النواة أو mount مضبوط خطأً، فهكذا يتحوّل اختراق الـ container إلى اختراق للمضيف. التشغيل كمستخدم بلا صلاحيات يحوّل كثيرًا من تلك الثغرات إلى طرق مسدودة.
تأتي الـ images الرسمية لـ Node.js بمستخدم اسمه node رقمه UID 1000. انتقل إليه في مرحلة التشغيل، بعد كل ما يحتاج إلى root:
# …build and deps stages from the previous lesson
FROM node:24-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]لاحظ ما ليس موجودًا: لا --chown=node:node على عمليات النسخ. تبقى الملفات مملوكة لـ root وقابلة للقراءة من الجميع، فيستطيع التطبيق قراءة كوده لكنه لا يستطيع تغييره. إذا وجد مهاجم ثغرة تنفيذ كود عن بُعد، فلن يستطيع إعادة كتابة dist/server.js ليبقى داخل النظام. امنح التطبيق صلاحية الكتابة فقط حيث يحتاج إليها حقًا، وفضّل /tmp أو volume مُركّبًا لذلك.
نسخة distroless
هذه مرحلة التشغيل نفسها على distroless. الـ tag :nonroot يعمل بـ UID 65532 دون أي إعداد إضافي، ونقطة الدخول في الـ image هي node أصلًا، لذا لا تذكر CMD إلا السكربت:
FROM gcr.io/distroless/nodejs24-debian13:nonroot AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["dist/server.js"]ما زالت مرحلتا build وdeps تستخدمان node:24-slim؛ وحدها المرحلة المشحونة تتغيّر. أبقِ الإصدار الرئيسي لـ Node متطابقًا عبر المراحل؛ وإلا فإن الوحدات الأصلية (native modules) المترجَمة وفق الـ ABI الخاص بإصدار معيّن من Node ستُحمَّل في إصدار آخر وتفشل عند بدء التشغيل.
تستطيع التأكّد من المستخدم الذي سيعمل به الـ container دون تشغيله:
docker image inspect notes-api:1.0.0 --format '{{.Config.User}}'أحكِم الإغلاق وقت التشغيل
الـ image تضبط القيم الافتراضية؛ والـ runtime يستطيع تشديدها أكثر:
docker run --rm -p 8080:3000 \
--read-only --tmpfs /tmp \
--cap-drop ALL \
--security-opt no-new-privileges \
notes-api:1.0.0الخيار --read-only يجعل نظام الملفات الجذري للـ container غير قابل للتعديل، ولا يبقى قابلًا للكتابة إلا /tmp بوصفه mount صغيرًا في الذاكرة. والخيار --cap-drop ALL يزيل صلاحيات Linux (capabilities) التي تحتاج إليها العمليات المميّزة الخاصة بالـ root. وno-new-privileges يمنع العملية من اكتساب صلاحيات عبر ملفات setuid التنفيذية. في القسم الخامس ستضبط الأشياء نفسها في securityContext ضمن Kubernetes، حيث تصبح سياسة لكل Pod.
الـ image لديك الآن صغيرة وبلا صلاحيات. التالي: كيف تسمّيها بحيث يكون ما تنشره هو بالضبط ما اختبرته.
أهم النقاط
- كل حزمة في الـ base image شيء عليك ترقيعه وفحصه والدفاع عنه، لذا ابدأ من أصغر base يعمل عليه تطبيقك فعلًا.
- الـ image
node:24-slimخيار افتراضي متين؛ وAlpine تستخدم musl بدلًا من glibc، وdistroless تزيل الـ shell ومدير الحزم كليًا. - الـ images الرسمية لـ Node.js تتضمّن مستخدمًا اسمه
node(UID 1000)؛ انتقل إليه بـUSER nodeبعد الخطوات التي تحتاج إلى root. - اترك ملفات التطبيق مملوكة لـ root وللقراءة فقط بالنسبة إلى مستخدم التطبيق، حتى لا تستطيع عملية مخترَقة إعادة كتابة كودها.
- أضف تحصينات وقت التشغيل مثل
--read-onlyو--cap-drop ALLوno-new-privileges؛ ولـ Kubernetes الضوابط نفسها فيsecurityContext.