الوحدة 9 — التحزيم في حاويات والنشر
الخدمة تعمل على الحاسوب المحلّيّ للمطوّر، بكلّ إعداداته الخاصّة. لكي تعمل على خادم إنتاج بيد فريق مختلف، نحتاج حاوية Docker: صورة قابلة للنقل تحمل كلّ شيء ضروريّ للتشغيل، بالضبط، بلا مفاجآت. هذه الوحدة تبني تلك الصورة بحرفيّة، وتنشرها بعمّال متعدّدين على خادم أو على خدمة مُدارة.
Dockerfile ساذج: كلّ ما لا يجب فعله
الشيفرة التالية تعمل، لكنّها تُنتج صورة بحجم 1.4 غيغابايت مع ثغرات أمنيّة متعدّدة:
FROM python:3.11
WORKDIR /app
COPY . .
RUN pip install fastapi uvicorn scikit-learn lightgbm pandas joblib
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0"]
المشاكل:
python:3.11صورة كاملة بمترجمات ومكتبات مطوّرين (700 ميغا فارغة).COPY . .ينسخ كلّ شيء: ملفّ.env، مستودع.gitبالكامل، ذاكرة التخزين المؤقّت لـpytest، ملفّmodels/بأربعين نموذجًا للاختبار.pip installبلا تثبيت إصدارات: صورة اليوم تختلف عن صورة الغد.CMDبعامل واحد Uvicorn: خدمة أحاديّة الخيط، غير قابلة للتوسّع.- الحاوية تعمل بمستخدم
root، فأيّ استغلال في FastAPI يُصبح استغلال root في الحاوية.
Dockerfile محترف متعدّد المراحل
نُصلح كلّ نقطة أعلاه:
# ============= مرحلة البناء: تجهيز البيئة =============
FROM python:3.11-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /build
# تبعيّات النظام لتصفح scikit-learn وlightgbm (تُحذف في المرحلة النهائيّة)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
# نُثبّت في بيئة افتراضيّة ننقلها لاحقًا
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ============= مرحلة التشغيل: خفيفة، بلا مترجمات =============
FROM python:3.11-slim AS runtime
# مكتبات نظام مطلوبة عند التشغيل فقط (openmp لـlightgbm)
RUN apt-get update && apt-get install -y --no-install-recommends \
libgomp1 \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --shell /bin/bash app
# ننسخ البيئة الافتراضيّة من مرحلة البناء
COPY --from=builder /venv /venv
WORKDIR /app
COPY --chown=app:app app/ ./app/
COPY --chown=app:app models/churn_pipeline.joblib ./models/
ENV PATH="/venv/bin:$PATH" \
PYTHONPATH=/app \
MODEL_PATH=/app/models/churn_pipeline.joblib \
WORKERS=2
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -f http://localhost:8000/livez || exit 1
CMD ["sh", "-c", "gunicorn app.main:app \
--workers ${WORKERS} \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--access-logfile - --error-logfile -"]
المكاسب مقاسة: الصورة النهائيّة تنزل إلى 220 ميغا بدل 1.4 غيغا. البناء يستفيد من ذاكرة تخزين مؤقّت طبقيّة (كلّ RUN طبقة). تشغيل الحاوية بمستخدم غير root يُقلّص سطح الهجوم. HEALTHCHECK يسمح لـDocker Swarm بمراقبة الحاوية بلا Kubernetes.
.dockerignore: الشقيق المنسيّ
بدون هذا الملفّ، COPY ينسخ ملفّات لا ينبغي نسخها. ينبغي أن يوجد بجانب Dockerfile:
.git/
.env
.env.*
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.vscode/
.idea/
tests/
notebooks/
data/raw/
models/*.joblib
!models/churn_pipeline.joblib
docs/
*.md
الصيغة الأخيرة (!) تستثني نموذجًا واحدًا من الاستبعاد العامّ. لو تجاهلنا .dockerignore، صورة قد تحمل مفاتيح API في .env أو مجموعات بيانات ضخمة في data/.
متغيّرات البيئة: مبدأ 12-Factor
الحاوية نفسها تعمل في التطوير، الاختبار، والإنتاج. الفرق: قيم متغيّرات البيئة. الشيفرة تقرأها ولا تحمل قيمًا مثبتة:
import os
from pydantic_settings import BaseSettings
class Reglages(BaseSettings):
model_path: str = "models/churn_pipeline.joblib"
model_stage: str = "Production"
apikey_dashboard_sha256: str
jwt_secret: str
log_level: str = "INFO"
workers: int = 2
class Config:
env_file = ".env" # للتطوير المحلّيّ فقط
env_file_encoding = "utf-8"
reglages = Reglages()
في الإنتاج، متغيّرات البيئة تأتي من Secret Manager (AWS Secrets Manager، Kubernetes Secrets، Docker Swarm secrets). لا تُكتب في docker-compose.yml ولا في مستودع git.
عدد العمّال: القاعدة العمليّة
توصية Gunicorn التاريخيّة: 2 × عدد النوى + 1. صحيح لتطبيق ويب كلاسيكيّ. لخدمة تنبّؤ ML، الحسبة تختلف:
- كلّ عامل يحمل نسخة كاملة من النموذج في الذاكرة. نموذج LightGBM بمئة ميغا × 4 عمّال = 400 ميغا ذاكرة قبل أيّ طلب.
predict_probaتستهلك CPU بشكل مركّز. أربعة عمّال على أربع نوى يعطون توازيًا حقيقيًّا؛ ثمانية على أربع نوى يعطون تنافسًا مضرًّا.
القاعدة العمليّة لخدمة ML: عدد العمّال = عدد النوى المتاحة، مع مراقبة الذاكرة. على حاوية بنواتَين و2 غيغا ذاكرة، عاملان مناسبان لنموذج بمئتَي ميغا (200×2 = 400، يترك 1.6 غيغا للطلبات والنظام).
اختبار الصورة محلّيًّا
قبل الدفع إلى مستودع الصور، دورة اختبار سريعة:
# بناء
docker build -t churn-api:v1.3.0 .
# تشغيل
docker run --rm -p 8000:8000 \
-e APIKEY_DASHBOARD_SHA256=$(echo -n "test-key" | sha256sum | cut -d' ' -f1) \
-e JWT_SECRET=dev-secret \
churn-api:v1.3.0
# اختبار في محطّة أخرى
curl -s http://localhost:8000/livez
curl -s -X POST http://localhost:8000/predict \
-H "X-API-Key: test-key" \
-H "Content-Type: application/json" \
-d '{"tenure_months":24,"monthly_charges":79.5,"contract":"month-to-month",
"payment_method":"electronic-check","fiber_optic":true,"online_security":false}'
الطلب الثاني يجب أن يُعيد churn_probability صالحًا. لو رجع 401، مفتاح API خاطئ. لو رجع 500، النموذج غير محمَّل (مسار خاطئ في MODEL_PATH).
الدفع إلى مستودع الصور
بعد الاختبار المحلّيّ، ندفع إلى Docker Hub، GitHub Container Registry، أو AWS ECR:
docker tag churn-api:v1.3.0 registry.example.com/churn-api:v1.3.0
docker tag churn-api:v1.3.0 registry.example.com/churn-api:latest
docker push registry.example.com/churn-api:v1.3.0
docker push registry.example.com/churn-api:latest
قاعدة صارمة: الوسم latest غير كافٍ. النشر يجب أن يشير إلى وسم إصدار محدّد (v1.3.0). الرجوع إلى إصدار سابق يكون بتغيير الوسم لا بإعادة بناء. لو استعمل النشر latest فقط، إعادة نشر تعطي إصدارًا مختلفًا كلّ مرّة، ولا نعرف بالضبط ما يعمل في الإنتاج.