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

الوحدة 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 فقط، إعادة نشر تعطي إصدارًا مختلفًا كلّ مرّة، ولا نعرف بالضبط ما يعمل في الإنتاج.

النشر: ثلاثة سيناريوهات

سيناريو أ: خادم VPS واحد بـDocker Compose. مناسب للبداية، عميل واحد، حركة معتدلة:

services:
churn-api:
image: registry.example.com/churn-api:v1.3.0
restart: unless-stopped
ports: ["8000:8000"]
environment:
APIKEY_DASHBOARD_SHA256: ${APIKEY_DASHBOARD_SHA256}
JWT_SECRET: ${JWT_SECRET}
WORKERS: "2"
deploy:
resources:
limits: { cpus: "2", memory: 2G }

سيناريو ب: Kubernetes. لعملاء متعدّدين، توسّع أفقيّ، معالجة أعطال. النشر عبر Deployment + Service + Ingress، بمجسّات liveness/readiness من الوحدة السابقة، وبمعامل replicas: 3 على الأقلّ لضمان توفّر متواصل خلال تحديثات النموذج.

سيناريو ج: خدمة مُدارة. AWS SageMaker Endpoint، Google Cloud Run، Azure Container Instances. الحاوية تُدفع، والخدمة تدير التوسّع التلقائيّ، مراقبة الحمل، والتكرار عبر مناطق متعدّدة. أغلى بحوالي 30%، لكن يوفّر ساعات صيانة أسبوعيّة.

اختيار السيناريو رهين حجم الفريق: فريق DevOps مختصّ يُبرّر Kubernetes؛ فريق أصغر يستفيد من خدمة مُدارة رغم تكلفتها الإضافيّة.

الفخّ الأمنيّ الأخير: الصورة عامّة

خطأ متكرّر: دفع صورة بها نموذج مُدرَّب على بيانات عملاء إلى Docker Hub عامّة. النموذج يحتوي أحيانًا معلومات قابلة للاستخراج (model inversion attacks) عن أفراد التدريب. القاعدة: مستودع صور خاصّ دومًا، بمفاتيح وصول مُدارة، حتّى للنماذج المفتوحة المصدر.

الخلاصة

  • Dockerfile متعدّد المراحل يفصل بيئة البناء عن بيئة التشغيل، ويُنزل حجم الصورة من 1.4 غيغا إلى 220 ميغا.
  • .dockerignore يمنع إدراج ملفّات حسّاسة (.env، .git، مجموعات بيانات) بالخطأ في الصورة.
  • مبدأ 12-Factor: كلّ إعداد يمرّ عبر متغيّرات بيئة، ولا تُثبَّت أسرار في الشيفرة أو في docker-compose.yml.
  • عدد العمّال = عدد النوى لخدمة ML، مع الأخذ بعين الاعتبار حجم النموذج × عدد العمّال في الذاكرة.
  • الوسم latest غير كافٍ: النشر يشير إلى إصدار محدّد، والرجوع يكون بتغيير الوسم لا بإعادة البناء.