انتقل إلى المحتوى الرئيسي

الوحدة 6 — تحزيم النموذج في حاوية

نموذج في السجلّ ليس خدمة. لنشره نحتاج إلى قطعة قابلة للنشر يمكن تشغيلها على أي بنية تحتيّة، وتستجيب لطلبات، ولا تعتمد على ما هو مثبَّت على الجهاز المُضيف. الحاوية هي الجواب القياسي. في هذه الوحدة نبنيها للنموذج، ونعالج الأخطاء الشائعة التي تجعلها ثقيلة أو غير آمنة.

الصورة الدنيا لخدمة تنبّؤ

نُحزّم نموذج التسرّب في خدمة FastAPI بسيطة. Dockerfile الأدنى:

FROM python:3.11-slim@sha256:d5f2b3...
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY src/ src/
COPY models/churn.pkl models/

EXPOSE 8000
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]

خمس ملاحظات على هذه الأسطر:

  • python:3.11-slim وليس python:3.11: الفرق 800 ميغابايت، لأنّ الصورة الكاملة تجرّ نصف نظام Debian.
  • بصمة sha256: مذكورة في الوحدة 2؛ بدونها الصورة تتغيّر بصمت مع كلّ بناء.
  • --no-cache-dir: يمنع pip من تخزين ملفّات مؤقّتة تُضخّم الصورة دون فائدة.
  • EXPOSE 8000: توثيق فقط، لا يفتح المنفذ فعلًا؛ ذلك يفعله docker run -p.
  • نسخ الشفرة والاعتماديات في خطوتين مختلفتين: نسخ requirements.txt أوّلًا وتثبيته يجعل ذاكرة البناء تحتفظ بطبقة التثبيت طالما لم يتغيّر الملفّ. تغيير سطر واحد في src/api.py لا يُعيد تثبيت الاعتماديات، ما يُوفّر دقائق في كلّ بناء.

تثبيت الاعتماديات: قفل صارم

ما وضعناه في الوحدة 2 يبقى صحيحًا: requirements.txt بلا نسخ خطأ. في سياق الحاوية يزداد الأمر خطورة، لأنّ الصورة تُعاد بناؤها في CI بدون تدخل بشري، وقد يجلب pip نسخة جديدة من مكتبة أدخل خطأ لا نلاحظه إلّا في الإنتاج.

الحلّ: نُنتج requirements-lock.txt بأمر pip freeze بعد اختبار بيئة العمل، ونُشير إليه من Dockerfile. أو نستعمل pip-tools أو uv أو poetry لتوليد ملفّ قفل يُدرَج في المستودع ويُلتزم به مع كلّ تغيير.

تحميل النموذج عند البدء، لا عند كلّ طلب

خطأ شائع: تحميل النموذج داخل معالج الطلب:

# غير موصى به إطلاقًا
@app.post("/predict")
def predict(features: dict):
model = joblib.load("models/churn.pkl") # يُحمَّل عند كلّ طلب
return {"proba": model.predict_proba(...)}

نموذج بحجم 50 ميغابايت يستغرق تحميله 300 ميلي ثانية. تحميله في كلّ طلب يعني إضافة 300 ميلي ثانية لكلّ استدلال، وحرمان الخدمة من قدرة تحميل قواعد بيانات أخرى في الذاكرة. الصواب:

from fastapi import FastAPI
import joblib

app = FastAPI()
model = joblib.load("models/churn.pkl") # يُحمَّل مرّة واحدة عند البدء

@app.post("/predict")
def predict(features: dict):
return {"proba": float(model.predict_proba([list(features.values())])[0, 1])}

النموذج يبقى في الذاكرة طوال حياة العملية. الحاوية تُشغَّل، تُحمِّل النموذج، ثم تخدم الطلبات بسرعة قصوى.

بديل أفضل للنماذج الكبيرة أو لعزل التحميل: استعمال آلية lifespan في FastAPI أو startup event في uvicorn لتحميل النموذج بأمر صريح ولوغاريتم إن فشل التحميل الحاوية لا تبدأ (فشل سريع، وهو ما نريده).

متغيّرات البيئة والأسرار

الحاوية لا تعرف كلمات المرور. عندما يحتاج الاستدلال إلى قاعدة بيانات لجلب بيانات المستخدم، نمرّر بيانات الاتّصال عبر متغيّرات بيئة:

import os
DB_URL = os.environ["DATABASE_URL"]

لا نُدرج الأسرار في Dockerfile أو في الصورة أبدًا. كلّ متغيّر مُثبَّت بـENV يُصبح جزءًا من طبقات الصورة، مرئيًا لأيّ من يستطيع سحب الصورة، حتّى بعد حذف السطر. الأسرار تُمرَّر عند التشغيل عبر:

  • docker run -e DATABASE_URL=... في التجارب المحلّية
  • Kubernetes Secrets في الإنتاج
  • HashiCorp Vault أو AWS Secrets Manager للحلقات الأكثر نضجًا

حجم الصورة: لماذا هو مهمّ

صورة بحجم 3 غيغابايت مقابل صورة بحجم 300 ميغابايت تُغيّر ثلاثة أشياء:

  • زمن النشر: سحب الصورة على عقدة جديدة يستغرق دقائق بدل ثوانٍ. عندما نحتاج توسيعًا آليًّا سريعًا (مثلًا يوم إطلاق العرض التجاري)، هذا حاسم.
  • زمن التراجع: العودة إلى إصدار سابق يعني سحبها من جديد إذا لم تكن في ذاكرة العقدة. الصورة الأصغر تُتيح تراجعًا أسرع.
  • سطح الهجوم: صورة أكبر = مكتبات أكثر = ثغرات أمنية معروفة أكثر. تحاول أدوات الفحص (Trivy، Grype) اكتشاف CVE في الصورة؛ صورة صغيرة تُنتج تقاريرًا أنظف.

تقنيتان أساسيتان لتقليص الحجم:

  • البناء متعدّد المراحل (multi-stage build): مرحلة أولى تُثبّت أدوات البناء (gcc، رؤوس C)، ومرحلة ثانية تنسخ فقط النتيجة النهائية. الصورة الأخيرة لا تحتوي المُترجم أبدًا.
  • صور أساس دنيا: distroless أو alpine بدلًا من slim. alpine يستعمل musl بدل glibc، ويقتضي أحيانًا تكييفًا (بعض بكرات Python لا تعمل عليه).

أمن الصورة

قاعدتان لا تُتَرَك:

  • لا نُشغّل كـroot داخل الحاوية. نُضيف RUN useradd -m app && USER app قبل CMD. مستخدم عادي داخل الحاوية يحدّ من الضرر إذا تمّ اختراق الخدمة.
  • نفحص الصورة قبل النشر. أدوات مثل trivy image mon-image:tag تُظهر ثغرات معروفة في المكتبات المُدرَجة. نُدمج هذا في خطّ CI (الوحدة 7).
ملفّات .pyc والأسرار المنسيّة

لا تنسَ .dockerignore. من دونه، COPY . . ينسخ .git/ (تاريخ كامل!) و.env (أسرار!) و__pycache__/. مثال أدنى: .git, .env, __pycache__, *.pyc, mlruns/.

في الخلاصة

  • الصورة تبدأ من قاعدة slim مثبّتة بـsha256، وتنسخ الاعتماديات قبل الشفرة للاستفادة من ذاكرة البناء.
  • النموذج يُحمَّل مرّة واحدة عند بدء العملية، لا عند كلّ طلب: الفرق مئات المللي ثانية بشكل نظاميّ.
  • الأسرار تُمرَّر عبر متغيّرات البيئة عند التشغيل، ولا تُدرَج في Dockerfile ولا في طبقات الصورة أبدًا.
  • حجم الصورة يؤثّر على النشر والتراجع وسطح الهجوم؛ البناء متعدّد المراحل والفحص بأدوات CVE ممارستان معياريّتان.