Docker المتقدم وأمن الحاويات

مشروع: صورة إنتاجية مُصلَّبة

25 دقيقة الدرس 10 من 28

مشروع: صورة إنتاجية مُصلَّبة

تناولت الدروس التسعة السابقة كل بُعد من أبعاد أمان الحاوية بمعزل عن غيره. يجمع هذا المشروع الختامي جميعها في سير عمل واحد متكامل من البداية إلى النهاية: أخذ واجهة برمجية Node.js واقعية ودفعها عبر التصغير متعدد المراحل، وفحص الصورة، وتوقيع سلسلة التوريد، وتصليب التشغيل بمستخدم غير root. النتيجة هي صورة إنتاجية يمكن الدفاع عنها في أي مراجعة أمنية لدى أي شركة من الشركات الكبرى.

سنعمل مع بنية تطبيق ملموسة ونطبق كل خطوة تصليب بالترتيب، مع عرض الأوامر الدقيقة والتحسين القابل للقياس بعد كل خطوة ونقاط الاندماج مع مسار CI/CD.

نقطة البداية: الصورة الساذجة

تبدأ معظم الفرق من هنا — ملف Dockerfile أحادي المرحلة مبني على صورة Node الرسمية الكاملة ويعمل بصلاحيات root:

# قبل التصليب — الخط الأساسي الساذج وغير الآمن FROM node:20 WORKDIR /app COPY . . RUN npm install EXPOSE 3000 CMD ["node", "src/index.js"]

التقييم السريع لهذه الصورة يكشف ثلاث مشاكل فورية: حجمها نحو 1.1 غيغابايت، وتعمل بمستخدم root (معرّف المستخدم 0)، وتشمل جميع تبعيات التطوير إلى جانب سلسلة أدوات npm الكاملة. يجد Grype وحده نحو 300 ثغرة CVE في الصورة الأساسية فقط.

الخطوة 1 — البناء متعدد المراحل لتقليص سطح الهجوم

التحويل الأول يفصل أدوات وقت البناء عن أصول وقت التشغيل. تشمل node_modules الإنتاجية فقط الحزم المُدرجة تحت dependencies في package.json، وليس devDependencies.

# syntax=docker/dockerfile:1.7 # ── المرحلة 0: تثبيت جميع التبعيات + البناء (ترجمة TypeScript / تحزيم الأصول) ── FROM node:20-alpine AS builder WORKDIR /app COPY package.json package-lock.json tsconfig.json ./ RUN --mount=type=cache,target=/root/.npm \ npm ci COPY src ./src RUN npm run build # tsc تُخرج JavaScript المُترجَم إلى /app/dist # ── المرحلة 1: تثبيت تبعيات الإنتاج فقط ────────────────────────────────── FROM node:20-alpine AS prod-deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm \ npm ci --omit=dev --ignore-scripts # ── المرحلة 2: بيئة تشغيل مُصغَّرة ────────────────────────────────────── FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist EXPOSE 3000 CMD ["node", "dist/index.js"]

ينخفض حجم الصورة من ~1.1 غيغابايت إلى ~180 ميغابايت. وينخفض عدد ثغرات CVE بنحو 70 % لأن سلسلة أدوات البناء اختفت. لكننا لا نزال نعمل بصلاحيات root — لننتقل إلى الخطوة التالية.

الخطوة 2 — مستخدم غير root ونظام ملفات للقراءة فقط

تشتمل صورة node:20-alpine على مستخدم node (معرّف المستخدم 1000) مُنشأ مسبقًا. كل ما نحتاجه هو تملّك الملفات والتبديل إليه قبل نقطة الدخول:

# تكملة المرحلة 2 — إضافة المستخدم وتصليب نظام الملفات FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist # تغيير ملكية الملفات للمستخدم غير الجذر RUN chown -R node:node /app USER node # تعريف مجلد مؤقت للكتابة إن احتاجها التطبيق VOLUME ["/tmp"] EXPOSE 3000 CMD ["node", "dist/index.js"]

عند النشر على Kubernetes، أضف SecurityContext لتصليب الحاوية أكثر:

# kubernetes/deployment.yaml (كتلة سياق الأمان) securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 readOnlyRootFilesystem: true allowPrivilegeEscalation: false seccompProfile: type: RuntimeDefault capabilities: drop: - ALL
لماذا يهم readOnlyRootFilesystem: true. الحاوية ذات نظام ملفات الجذر القابل للكتابة يمكن استغلالها لاستبدال ملفها الثنائي، أو تثبيت باب خلفي، أو كتابة مهمة cron. نظام الملفات للقراءة فقط يُجبر جميع الكتابات على وحدات التخزين الصريحة مثل emptyDir أو وحدات التخزين الدائمة القابلة للتدقيق. التطبيق الذي لا يستطيع العمل مع نظام ملفات للقراءة فقط يحمل افتراضًا خفيًا يستحق الإصلاح على مستوى الكود.

الخطوة 3 — الفحص قبل الرفع

ادمج Grype في مسار CI بحيث يفشل البناء الذي يحمل ثغرات حرجة قبل وصول الصورة إلى السجل:

# بناء الصورة docker build --tag myapi:$GIT_SHA . # الفحص — الخروج بقيمة غير صفرية إذا وُجدت ثغرة CRITICAL أو HIGH grype myapi:$GIT_SHA \ --fail-on critical \ --output table # إنتاج SBOM أولاً ثم فحصه (يتجنب إعادة جلب الصورة للتدقيق اللاحق) syft myapi:$GIT_SHA -o spdx-json > sbom.spdx.json grype sbom:sbom.spdx.json --fail-on critical

في GitHub Actions يبدو هذا هكذا:

# .github/workflows/build.yml (خطوة الفحص) - name: Scan image for vulnerabilities uses: anchore/scan-action@v3 with: image: "myapi:${{ github.sha }}" fail-build: true severity-cutoff: critical output-format: table
لا تكتفِ بالفحص وقت البناء. تُكشف الثغرات باستمرار. صورة تجتاز الفحص اليوم قد تصبح حرجة الأسبوع المقبل. شغّل عمليات إعادة فحص مجدولة ليلية على جميع الصور العاملة في الإنتاج باستخدام فحص السجل المدمج (ECR Inspector أو GCR Artifact Analysis أو مهايئ Trivy في Harbor) وأرسل تنبيهات لقناة الأمان عند اختراق حد الخطورة.

الخطوة 4 — التوقيع بـ Cosign (Sigstore)

يسد التوقيع الثغرة في سلسلة التوريد بين CI والإنتاج. بدون توقيع، لا شيء يمنع أحدًا من رفع صورة مختلفة تحت الوسم نفسه في سجلك. مع التوقيع بلا مفاتيح من Cosign (المدعوم بسجل شفافية Sigstore)، تحمل كل صورة إيصالًا مشفرًا يربطها بتشغيل سير العمل المحدد في GitHub Actions الذي أنتجها.

# تثبيت cosign (عبر brew أو تحميل الملف الثنائي) # brew install cosign # في CI — التوقيع بلا مفاتيح (رمز OIDC من GitHub Actions) # يستخدم cosign متغير ACTIONS_ID_TOKEN_REQUEST_URL تلقائيًا # رفع الصورة أولاً docker push ghcr.io/org/myapi:$GIT_SHA # التوقيع (بلا مفاتيح — لا إدارة مفاتيح مطلوبة، يستخدم Fulcio + Rekor) cosign sign --yes ghcr.io/org/myapi:$GIT_SHA # التحقق على أي جهاز (استبدل مُصدر OIDC والموضوع المتوقعَين) cosign verify \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-identity-regexp "https://github.com/org/myapi/.github/workflows/build.yml@refs/heads/main" \ ghcr.io/org/myapi:$GIT_SHA | jq .

في Kubernetes، طبّق هذا عند القبول باستخدام Policy Controller أو Kyverno، لترفض الصور غير الموقعة قبل وصولها إلى أي عقدة:

# kyverno/verify-image-policy.yaml apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: require-signed-images spec: validationFailureAction: Enforce rules: - name: verify-image-signature match: any: - resources: kinds: [Pod] verifyImages: - imageReferences: - "ghcr.io/org/myapi:*" attestors: - entries: - keyless: subject: "https://github.com/org/myapi/.github/workflows/build.yml@refs/heads/main" issuer: "https://token.actions.githubusercontent.com"

مسار التصليب الكامل

يُظهر المخطط التالي جميع المراحل الأربع مترابطة في سير عمل CI/CD واحد، من دفع المطور إلى صورة موثقة ومتوافقة مع السياسات تعمل في الإنتاج.

Hardened Image Pipeline: git push to production git push CI trigger Multi-Stage Build alpine base non-root USER prod-deps only ~180 MB SBOM + Scan syft + grype SPDX SBOM fail on CRITICAL ✗ block if CVE ✓ pass = continue Push + Sign cosign keyless OIDC token Rekor audit log SBOM attestation ghcr.io push Production Kubernetes Kyverno policy verify signature readOnlyRootFS runAsNonRoot Dev Build Scan Sign Deploy
مسار الصورة المُصلَّبة الكامل: كل مرحلة تعمل كبوابة للمرحلة التالية، وصولاً إلى صور مُصغَّرة ومفحوصة وموقعة وغير root في الإنتاج.

قائمة مراجعة التحقق

بعد بناء صورتك المُصلَّبة، مرّ عبر هذه القائمة قبل وضع علامة عليها بأنها جاهزة للإنتاج. في شركات مثل Google وNetflix، تُطبَّق هذه القائمة تلقائيًا في CI — فشل أي فحص يوقف النشر.

# 1. التحقق من عدم تشغيل العملية بصلاحيات root docker run --rm myapi:$GIT_SHA id # المتوقع: uid=1000(node) gid=1000(node) — وليس uid=0(root) # 2. فحص حجم الصورة docker image inspect myapi:$GIT_SHA --format '{{.Size}}' | numfmt --to=iec # الهدف: أقل من 200 ميغابايت لخدمات Node.js، أقل من 30 ميغابايت للثنائيات Go/distroless # 3. التأكد من غياب Shell في صورة التشغيل docker run --rm --entrypoint sh myapi:$GIT_SHA -c "echo hi" 2>&1 || echo "No shell — good" # 4. سرد العمليات التي تعمل بصلاحيات root داخل الحاوية docker run --rm myapi:$GIT_SHA ps aux | grep root # المتوقع: فارغ (فقط عملية تطبيقك تعمل بمعرّف المستخدم 1000) # 5. التأكد من وجود SBOM syft myapi:$GIT_SHA -o table | head -20 # 6. التحقق من صحة التوقيع cosign verify \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-identity-regexp "https://github.com/org/myapi/.github/workflows/.*" \ ghcr.io/org/myapi:$GIT_SHA # 7. فحص الصورة النهائية grype myapi:$GIT_SHA --fail-on high
ثبّت الصورة الأساسية بالمجزم الرقمي وليس بالوسم. الوسوم قابلة للتغيير — يمكن أن تتغير node:20-alpine دون تحذير حين يرفع المُشرف الأصلي تصحيحًا. ثبّت المجزم الرقمي غير القابل للتغيير في Dockerfile وحدّثه عمدًا عبر Dependabot أو Renovate:

FROM node:20-alpine@sha256:a7f5...

يعزز هذا أيضًا التحقق من توقيع cosign: فالمجزم الرقمي جزء من الحمولة الموقعة، لذا أي تلاعب بطبقة الصورة الأساسية يكسر التوقيع.

حالات الفشل الإنتاجية التي يجب معرفتها

حتى الصور المُصلَّبة جيدًا تظهر مفاجآت في وقت التشغيل. هذه أكثر حالات الفشل شيوعًا التي تواجهها الفرق بعد التبديل إلى هذا الإعداد:

  • التطبيق يكتب في /app وقت التشغيل. تكتب كثير من الأطر ملفات القفل أو القوالب المُترجَمة أو مجلدات تحميل مؤقتة داخل مجلد العمل. مع readOnlyRootFilesystem: true، تنتج هذه الكتابات أخطاءً حرجة. الحل: ثبّت وحدة emptyDir عند المسار القابل للكتابة المحدد ووجّه إعداد الإطار إليه بدلًا من مجلد العمل.
  • شهادات CA المفقودة. تشتمل Alpine على حزمة ca-certificates، لكن إذا بدأت من scratch أو متغيرة distroless مُجرَّدة، تفشل اتصالات TLS بالخدمات الخارجية مع ظهور خطأ certificate signed by unknown authority. الحل: COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/.
  • ربط المنفذ أقل من 1024 بمستخدم غير root. تتطلب المنافذ 80 و443 صلاحية CAP_NET_BIND_SERVICE. تشغيل معرّف المستخدم 1000 بدون تلك الصلاحية ومحاولة ربط المنفذ 80 تعطي خطأ permission denied. الحل: ارتبط بالمنفذ 3000 (أو أي منفذ غير مميز) ودع خدمة Kubernetes أو موازن التحميل يتولى المنافذ 80 و443 خارجيًا.
  • معالجة الإشارات. حين تكون node هي PID 1 (وليس عملية init)، قد لا ينتقل SIGTERM القادم من kubectl rollout restart بشكل صحيح إلى العمليات الفرعية، مما يُخلّف عمليات ميتة. الحل: استخدم ["dumb-init", "node", "dist/index.js"] — أضف dumb-init من Alpine وعيّنه غلافًا لنقطة الدخول.
تصليب الأمان ليس حدثًا لمرة واحدة. يمكن أن تُبطَل الصورة المُصلَّبة المبنية اليوم في السبرينت القادم إذا أضاف مطور USER root لإصلاح مشكلة في الصلاحيات، أو رفّع صورة أساسية دون إعادة الفحص. طبّق هذه الضوابط في CI وفي webhooks القبول وفي أداة أمان وقت التشغيل (Falco أو Sysdig). عامل الحاوية العاملة التي تُولّد shell أو تكتب في مسارات غير متوقعة كحادثة أمنية وليس كإعداد خاطئ.

لديك الآن مسار قابل للتكرار والتدقيق من الكود المصدري إلى صورة إنتاجية تجتاز المراجعة الأمنية بمعايير الشركات الكبرى: مُصغَّرة (180 ميغابايت مقابل 1.1 غيغابايت)، وغير root (معرّف المستخدم 1000)، ومفحوصة (صفر ثغرات حرجة)، وموقعة (cosign + Rekor)، ومطبَّقة بالسياسات (Kyverno للقبول). شغّل هذا السير على كل دمج إلى الفرع الرئيسي يمنحك وضعًا أمنيًا مستمرًا وقابلًا للقياس، لا تدقيقًا في لحظة زمنية محددة.