التعليم والبحث العلمي الأدلة التقنية

مختبرات البرمجة للطلاب في المتصفح مع JupyterHub

شغل JupyterHub ليكتب كل طالب الكود في المتصفح دون تثبيت شيء، وصحح الواجبات تلقائياً بـ nbgrader مع معرفة ما يلزمها من عزل.

مختبرات البرمجة للطلاب في المتصفح مع JupyterHub

في أول محاضرة من مقرر البرمجة أو تحليل البيانات تضيع نصف الساعة الأولى في تثبيت Python على أجهزة الطلاب: طالب على Windows ونسخة Python قديمة، وطالب على Mac ومكتبة لا تتثبت، وطالب جهازه ضعيف لا يحتمل pandas، والمعلم يركض بين الصفوف بدلاً من أن يشرح. والحل الذي تستخدمه الجامعات هو أن يكون مختبر البرمجة على السيرفر، فيفتح الطالب المتصفح ويجد بيئة Jupyter جاهزة بالمكتبات نفسها التي يستخدمها زملاؤه، وهذا ما يقدمه JupyterHub. وفي الأنظمة التي تحتاجها الجامعة ذكرنا مختبرات البرمجة بين الأنظمة التي تكمل منصة التعلم، والآن نشغلها فعلاً.

وفي هذا المقال سوف نتناول:

  • كيف يعمل JupyterHub من الداخل، وأي طريقة تثبيت تناسب فصلاً واحداً، وأيها تناسب جامعة.
  • مختبر على Docker فيه معلمة وثلاثة طلاب، وكم يستهلك كل طالب من الذاكرة فعلاً، وما الذي حدث عندما بدأت ثلاثة سيرفرات في الوقت نفسه.
  • دورة واجب كاملة بـ nbgrader: إنشاء الواجب، وتوزيعه، وتسليمه، والتصحيح الآلي، وملف الدرجات.
  • ثلاث نقاط أمان وجدناها في التجربة: حد الذاكرة الذي لا يطبق، ومجلد التسليم، وكود الطالب الذي يعمل بحساب المعلم.

كيف يعمل JupyterHub؟

Jupyter وحده برنامج لمستخدم واحد، يشغله الشخص على جهازه ويفتح دفاتره Notebooks في المتصفح. وJupyterHub يضع أمامه بوابة لعدة مستخدمين، فعندما يدخل الطالب يشغل له سيرفر Jupyter خاصاً به، ويوجه طلباته إليه. والشكل التالي يبين المكونات كما شغلناها، مع الذاكرة التي قسناها لكل منها:

مخطط: متصفحات الطلاب تصل إلى الوكيل Proxy وهو configurable-http-proxy بذاكرة 80MB، والوكيل يوجه المسار hub إلى المحور Hub وفيه المصادقة Authenticator والمشغل Spawner وقاعدة بيانات المستخدمين بذاكرة 143MB، ويوجه المسار user name إلى سيرفر Jupyter مستقل لكل طالب بحساب Linux الخاص به، ثلاثة سيرفرات لكل منها نحو 168MB، وتحتها مجلد كل طالب في home ومجلد التبادل Exchange
بوابة واحدة، وسيرفر Jupyter مستقل لكل طالب يبدأ عند دخوله

في الشكل أعلاه لاحظ التالي:

  • الوكيل Proxy: البرنامج الوحيد الذي يستقبل طلبات المتصفح، ويوجه /hub/ إلى المحور و/user/<name>/ إلى سيرفر الطالب.
  • المصادقة Authenticator: تقرر من هو المستخدم، والافتراضية PAM أي حسابات Linux على السيرفر، وقائمة المصادقات فيها OAuth لربطه بمزود هوية مثل authentik الذي ربطنا به Moodle في دخول موحد إلى Moodle.
  • المشغل Spawner: يقرر أين يعمل سيرفر الطالب، وهو أهم قرار في التصميم كله، والجدول التالي يبين الخيارات.
الطريقةأين يعمل سيرفر الطالبحد الذاكرة لكل طالبيناسب
LocalProcessSpawner (الافتراضي)عملية Process بحساب Linux على السيرفر نفسهلا يطبقالتجربة، أو فصل صغير تثق بطلابه
The Littlest JupyterHubسيرفر واحد، وكل طالب في وحدة systemdيطبق بالإعداد limits.memoryمقرر أو قسم حتى عشرات الطلاب على سيرفر واحد
DockerSpawnerContainer لكل طالبيطبقه Dockerسيرفر واحد مع عزل أقوى
Zero to JupyterHub على KubernetesPod لكل طالب على عدة سيرفراتيطبقه Kubernetesجامعة بمئات الطلاب في الوقت نفسه

وقد يتساءل البعض: لماذا نبدأ بالطريقة الأولى إذا كانت لا تطبق الحدود؟ والإجابة أنها الطريقة التي يفترضها دليل nbgrader لمعلم واحد ومقرر واحد، وأنها تجعل كل مكون ظاهراً، فنرى بأعيننا ما الذي تضيفه الطرق الأخرى، وسوف نقيس ذلك في قسم الأمان.

المتطلبات

  • الذاكرة: في مختبرنا استخدم المحور 143MB والوكيل 80MB، وكل سيرفر طالب نحو 168MB وهو خامل قبل أن يشغل أي كود، فثلاثون طالباً في محاضرة واحدة يعنون نحو 5GB قبل أن يحمل أحدهم ملف بيانات، ثم يضاف ما تستهلكه pandas والبيانات نفسها، وهذا هو الرقم الذي تخطط عليه وليس عدد الطلاب المسجلين في المقرر.
  • نطاق وشهادة HTTPS: مثل https://lab.example.com عبر Reverse Proxy كما في تثبيت Caddy، والسبب أن كلمات مرور الطلاب ورموز الجلسة تمر عبره، ولاحظ أن الوكيل يحتاج دعم WebSocket لأن الدفتر يتصل بالنواة Kernel عبره.
  • حساب لكل طالب: سواء كان حساب Linux كما في المختبر، أو حساباً من مزود الهوية.

المختبر: JupyterHub وnbgrader على Docker

بنينا Docker Image فوق ال Image الرسمي quay.io/jupyterhub/jupyterhub:6.1.1، وأضفنا إليه JupyterLab وnbgrader ومكتبات البيانات، وأنشأنا حسابات Linux للمعلمة وثلاثة طلاب:

FROM quay.io/jupyterhub/jupyterhub:6.1.1
RUN python3 -m pip install --no-cache-dir jupyterlab==4.6.4 notebook==7.6.3 nbgrader==0.9.6 \
    numpy==2.3.4 pandas==2.3.3 matplotlib==3.10.7
# One instructor and three students as Linux users (the lab uses PAM, the default authenticator)
RUN for u in t.sara s2026001 s2026002 s2026003; do useradd -m -s /bin/bash "$u" && echo "$u:Lab-Pass-2026!" | chpasswd; done \
 && mkdir -p /srv/nbgrader/exchange && chmod 777 /srv/nbgrader/exchange
COPY jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
COPY nbgrader_config.py /etc/jupyter/nbgrader_config.py
WORKDIR /srv/jupyterhub
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]

ثم إعداد JupyterHub في jupyterhub_config.py:

c = get_config()  # noqa
c.JupyterHub.bind_url = "http://0.0.0.0:8000"
c.Authenticator.allowed_users = {"t.sara", "s2026001", "s2026002", "s2026003"}
c.Authenticator.admin_users = {"t.sara"}
c.Spawner.default_url = "/lab"
c.Spawner.mem_limit = "512M"
# A service token for the lab script that starts and stops the students' servers
c.JupyterHub.services = [{"name": "lab-runner", "api_token": "lab-hub-api-token-4c8d"}]
c.JupyterHub.load_roles = [{"name": "lab-runner", "scopes": ["admin:users", "admin:servers"], "services": ["lab-runner"]}]
c.Spawner.http_timeout = 120
c.Spawner.start_timeout = 180

وإعداد nbgrader المشترك في /etc/jupyter/nbgrader_config.py، وفيه اسم المقرر ومجلد المعلمة ومجلد التبادل الذي تمر عبره الواجبات بين المعلم والطلاب:

c = get_config()  # noqa
c.CourseDirectory.course_id = "py101"
c.CourseDirectory.root = "/home/t.sara/py101"
c.Exchange.root = "/srv/nbgrader/exchange"
name: edu3-jh
services:
  hub:
    image: edu3-jhub:6.1.1
    container_name: edu3-jh-hub
    ports: ["127.0.0.1:29280:8000"]
    volumes: ["home:/home", "exchange:/srv/nbgrader/exchange"]
    mem_limit: 2g
volumes:
  home: { name: edu3-jh-home }
  exchange: { name: edu3-jh-exchange }
networks:
  default: { name: edu3-jh-net }
docker build -t edu3-jhub:6.1.1 .
docker compose up -d

في الإعداد أعلاه لاحظ التالي:

  • Python داخل ال Image: ال Image الرسمي مبني على Ubuntu 26.04.1، وكل حزم JupyterHub في بيئة افتراضية Virtual Environment في /srv/venv، بينما الأمر python3 في جلسة su - s2026001 هو Python النظام الذي لا يعرف nbgrader، فأول أمر شغلناه بحساب المعلمة فشل بالرسالة ModuleNotFoundError: No module named 'nbformat'. لذلك في الأوامر التالية من سطر الأوامر نضيف /srv/venv/bin إلى PATH، وأما في الطرفية Terminal داخل JupyterLab فالبيئة صحيحة.
  • مجلدات الطلاب: أنشأ useradd كل مجلد بالصلاحيات drwxr-x---، فلا يرى الطالب مجلد زميله، وجربنا ذلك فكانت النتيجة Permission denied. وهذا من إعدادات النظام في الإصدارات الحديثة من Ubuntu، فتأكد منه في نظامك ولا تفترضه.
  • ال Volume home: كل عمل الطلاب فيه، فهو أهم ما تنسخه احتياطياً.
  • الإصدارات: JupyterHub 6.1.1 وnbgrader 0.9.6 كانا أحدث الإصدارات عند التجربة.
صفحة دخول JupyterHub: شعار jupyterhub في الأعلى، ونموذج Sign in فيه اسم المستخدم s2026001 وكلمة المرور وزر Sign in برتقالي
صفحة الدخول الافتراضية، والحساب هنا حساب Linux على السيرفر

ثلاثة طلاب يبدؤون في اللحظة نفسها

في الإعداد الأول لم يكن السطران الأخيران من jupyterhub_config.py موجودين، وطلبنا من JupyterHub تشغيل سيرفرات الطلاب الثلاثة معاً عبر الواجهة البرمجية، كما يحدث عندما يفتح الفصل كله الصفحة في بداية المحاضرة:

for u in s2026001 s2026002 s2026003; do
  curl -s -o /dev/null -w "$u %{http_code}\n" -X POST -H 'Authorization: token lab-hub-api-token-4c8d' \
    http://127.0.0.1:29280/hub/api/users/$u/server
done

قبل JupyterHub الطلبات الثلاثة بالرمز 202، ولكن لم يصبح أي سيرفر جاهزاً، وفي السجل رسائل تقترح الحل:

       This may be a configuration issue specific to your chosen Spawner.
       To fix: increase `Spawner.http_timeout` configuration
       to a number of seconds that is enough for servers to become responsive.

والقيمة الافتراضية لـ http_timeout هي 30 ثانية بحسب مرجع ال Spawner، وعلى سيرفر مشغول يحتاج سيرفر الطالب أكثر منها ليبدأ، فيعتبره JupyterHub فاشلاً ويوقفه. بعد رفع المهلة إلى 120 ثانية وتشغيل الطلاب واحداً بعد الآخر أصبحت السيرفرات جاهزة بعد 34 و31 و32 ثانية. والدرس: جرب على السيرفر الحقيقي سيناريو بداية المحاضرة، أي ثلاثين طالباً يدخلون في الدقيقة نفسها، وارفع المهلة بحسب ما تقيسه، كما شرحنا في اختبار الأحمال والضغط.

الواجبات والتصحيح الآلي مع nbgrader

nbgrader أداة لإنشاء الواجبات في دفاتر Jupyter وتصحيحها، ودليل إنشاء الواجبات يشرح الفكرة: المعلم يكتب دفتراً واحداً فيه الحل والاختبارات، والأداة تخرج منه نسخة الطالب دون الحل، ثم تصحح ما يسلمه الطالب بتشغيل الاختبارات عليه. والشكل التالي يبين الدورة كاملة كما نفذناها:

مخطط بثمان خطوات في ثلاثة مسارات: المعلمة t.sara تكتب دفتر المصدر بالحل والاختبارات، ثم generate_assignment يحذف الحل والاختبارات المخفية، ثم release_assignment إلى مجلد التبادل py101/outbound/hw1 الذي يقرؤه كل الطلاب، ثم الطالب يجلب الواجب بالأمر fetch_assignment ويكتب الحل ويسلم بالأمر submit إلى py101/inbound الذي يكتب فيه ولا يرى محتواه، ثم المعلمة تجمع بالأمر collect وتصحح بالأمر autograde وهي خطوة حمراء لأنها تشغل الكود بحساب المعلم، ثم export إلى grades.csv
من دفتر المعلم إلى ملف الدرجات، والخطوة 7 هي التي تحتاج انتباهك

الخطوة 1: دفتر المصدر

كل خلية في دفتر المصدر لها بيانات nbgrader في الحقل metadata، تقول هل هي خلية حل يكتب فيها الطالب، أم خلية اختبار مقفلة لها درجة. وفي الواجهة تضعها من شريط Create Assignment على يمين JupyterLab، ونحن أنشأنا الدفتر بسكربت حتى يكون قابلاً للتكرار:

# Writes the instructor's source notebook py101/source/hw1/hw1.ipynb with nbgrader cell metadata:
# two answer cells (solution) and two locked test cells worth 2 and 3 points, the second with hidden tests.
import nbformat as nbf, os

def md(text, gid):
    c = nbf.v4.new_markdown_cell(text)
    c.metadata["nbgrader"] = {"grade": False, "grade_id": gid, "locked": True, "schema_version": 3, "solution": False, "task": False}
    return c

def code(src, gid, solution=False, points=None):
    c = nbf.v4.new_code_cell(src)
    c.metadata["nbgrader"] = {"grade": points is not None, "grade_id": gid, "locked": not solution,
                              "schema_version": 3, "solution": solution, "task": False}
    if points is not None:
        c.metadata["nbgrader"]["points"] = points
    return c

nb = nbf.v4.new_notebook()
nb.cells = [
    md("# الواجب الأول: درجات الطلاب\nاكتب الدالتين التاليتين، ثم شغل خلايا الاختبار للتأكد من إجابتك قبل التسليم.", "intro"),
    md("**السؤال 1:** اكتب الدالة `average(grades)` التي ترجع متوسط الدرجات، وتتجاهل القيم `None` (طالب غائب).", "q1"),
    code("def average(grades):\n    ### BEGIN SOLUTION\n    marks = [g for g in grades if g is not None]\n    return sum(marks) / len(marks)\n    ### END SOLUTION", "average", solution=True),
    code("assert average([80, 90, 100]) == 90\nassert average([70, None, 90]) == 80", "test_average", points=2),
    md("**السؤال 2:** اكتب الدالة `letter(score)` التي ترجع «ممتاز» من 90 فما فوق، و«جيد جداً» من 80، و«جيد» من 70، و«راسب» لما دون ذلك.", "q2"),
    code("def letter(score):\n    ### BEGIN SOLUTION\n    if score >= 90:\n        return \"ممتاز\"\n    if score >= 80:\n        return \"جيد جداً\"\n    if score >= 70:\n        return \"جيد\"\n    return \"راسب\"\n    ### END SOLUTION", "letter", solution=True),
    code("assert letter(95) == \"ممتاز\"\nassert letter(85) == \"جيد جداً\"\n### BEGIN HIDDEN TESTS\nassert letter(90) == \"ممتاز\"\nassert letter(69.5) == \"راسب\"\n### END HIDDEN TESTS", "test_letter", points=3),
]
nb.metadata["kernelspec"] = {"name": "python3", "display_name": "Python 3", "language": "python"}
os.makedirs("py101/source/hw1", exist_ok=True)
nbf.write(nb, "py101/source/hw1/hw1.ipynb")
print("wrote py101/source/hw1/hw1.ipynb with", len(nb.cells), "cells")

في السكربت أعلاه لاحظ التالي:

  • ما بين ### BEGIN SOLUTION و### END SOLUTION هو الحل، ويحذفه nbgrader من نسخة الطالب ويضع مكانه raise NotImplementedError().
  • ما بين ### BEGIN HIDDEN TESTS و### END HIDDEN TESTS اختبارات لا يراها الطالب، وتعود عند التصحيح، وهي هنا تختبر الحدود: الدرجة 90 بالضبط، والدرجة 69.5.

الخطوتان 2 و3: إنشاء نسخة الطالب وتوزيعها

su - t.sara -c 'export PATH=/srv/venv/bin:$PATH; python3 /tmp/make_assignment.py && cd py101 && nbgrader generate_assignment hw1 --create && nbgrader release_assignment hw1'
wrote py101/source/hw1/hw1.ipynb with 7 cells
[GenerateAssignmentApp | INFO] Updating/creating assignment 'hw1': {}
[GenerateAssignmentApp | INFO] Converting notebook /home/t.sara/py101/source/./hw1/hw1.ipynb
[GenerateAssignmentApp | INFO] Making directory /home/t.sara/py101/release/./hw1
[GenerateAssignmentApp | INFO] Writing 3795 bytes to /home/t.sara/py101/release/hw1/hw1.ipynb
[GenerateAssignmentApp | INFO] Setting destination file permissions to 644
[ReleaseAssignmentApp | INFO] Source: /home/t.sara/py101/release/./hw1
[ReleaseAssignmentApp | INFO] Destination: /srv/nbgrader/exchange/py101/outbound/hw1
[ReleaseAssignmentApp | INFO] Released as: py101 hw1

وهذه خلايا الكود في النسخة التي وصلت إلى مجلد التبادل، أي ما يراه الطالب:

def average(grades):
    # YOUR CODE HERE
    raise NotImplementedError()
---
assert average([80, 90, 100]) == 90
assert average([70, None, 90]) == 80
---
def letter(score):
    # YOUR CODE HERE
    raise NotImplementedError()
---
assert letter(95) == "ممتاز"
assert letter(85) == "جيد جداً"

الحل اختفى، والاختبارات المخفية اختفت، وبقيت الاختبارات الظاهرة ليجرب بها الطالب إجابته قبل التسليم.

الخطوتان 4 و5: الطلاب يجلبون ويسلمون

من JupyterLab يجد الطالب الواجب في قائمة Nbgrader ثم Assignment List، ويضغط Fetch ثم Submit، والأمران المقابلان في سطر الأوامر هما nbgrader fetch_assignment hw1 --course py101 وnbgrader submit hw1 --course py101. وجعلنا كل طالب يكتب إجابة مختلفة:

  • s2026001: إجابة صحيحة.
  • s2026002: متوسط يقسم على عدد الدرجات مع الغائبين، ودرجة «ممتاز» تبدأ بعد 90 وليس من 90، أي خطأ في الحدود لا تكشفه الاختبارات الظاهرة.
  • s2026003: إجابة صحيحة، ومعها سطر يكتب في ملف اسم المستخدم الذي يشغل الكود والمجلد الذي يعمل فيه، وسوف نعرف سببه بعد قليل.
== s2026001
[FetchAssignmentApp | INFO] Destination: /home/s2026001/hw1
[FetchAssignmentApp | INFO] Fetched as: py101 hw1
answered correct
[SubmitApp | INFO] Source: /home/s2026001/hw1
[SubmitApp | INFO] Destination: /srv/nbgrader/exchange/py101/inbound/s2026001+hw1+2026-10-22 01:07:40.749315 UTC+jQrwWzFOeYso
[SubmitApp | INFO] Submitted as: py101 hw1 2026-10-22 01:07:40.749315 UTC

والصورة التالية للدفتر في JupyterLab بحساب الطالب بعد أن كتب إجابته:

JupyterLab بحساب الطالب s2026001: المجلد hw1 وفيه hw1.ipynb، والدفتر مفتوح بعنوان الواجب الأول درجات الطلاب، ونص السؤالين بالعربية، وخلية الدالة average بإجابة الطالب، وخلية اختبار فيها assert، ثم خلية الدالة letter بإجابة الطالب وخلية اختبار، وفي القائمة العلوية قائمة Nbgrader، وعلى اليمين شريط Create Assignment
دفتر الطالب: العناوين العربية تظهر بخط صحيح، ولكن الفقرات بمحاذاة اليسار وعلامات الترقيم في غير مكانها

لاحظ في الصورة أن JupyterLab يعرض نص الخلايا العربي من اليسار، فتظهر النقطة والقوس في أول السطر، وهذا مزعج في واجب عربي طويل، ومن الحلول التي تستحق التجربة أن تضع نص السؤال داخل وسم HTML يحدد اتجاه الكتابة <div dir="rtl"> في خلية Markdown، ثم تتأكد من شكله عند الطالب قبل توزيع الواجب.

الخطوات 6 إلى 8: الجمع والتصحيح والدرجات

su - t.sara -c 'export PATH=/srv/venv/bin:$PATH; cd py101 && nbgrader collect hw1 && nbgrader autograde hw1 && nbgrader export && cat grades.csv'
[CollectApp | INFO] Processing 3 submissions of 'hw1' for course 'py101'
[CollectApp | INFO] Collecting submission: s2026001 hw1
[CollectApp | INFO] Collecting submission: s2026002 hw1
[CollectApp | INFO] Collecting submission: s2026003 hw1
[AutogradeApp | WARNING] Attribute 'checksum' for cell test_letter has changed! (should be: b7b536840c3f16c0c88debf8a7403fee, got: 9ee79326aa2c469f7e1f55fbe5375f19)
[AutogradeApp | INFO] Autograding /home/t.sara/py101/autograded/s2026001/hw1/hw1.ipynb
...
[AutogradeApp | INFO] Overwriting files with master versions from the source directory
...
[ExportApp | INFO] Exporting grades to grades.csv
assignment,duedate,timestamp,student_id,last_name,first_name,email,raw_score,late_submission_penalty,score,max_score
hw1,,2026-10-22 01:07:40.749315,s2026001,,,,5.0,0.0,5.0,5.0
hw1,,2026-10-22 01:08:21.700744,s2026002,,,,0.0,0.0,0.0,5.0
hw1,,2026-10-22 01:08:59.915875,s2026003,,,,5.0,0.0,5.0,5.0

في المخرج أعلاه لاحظ التالي:

  • تحذير checksum: ظهر لكل طالب على خلية test_letter، والسبب أن نسخة الطالب لا تحتوي الاختبارات المخفية، فبصمة الخلية تختلف عن بصمة المصدر، ثم يعيد nbgrader الخلايا المقفلة من نسخة المصدر كما في السطر «Overwriting files with master versions»، فالاختبارات المخفية تعمل والطالب لا يستطيع أن يغير خلايا الاختبار ليجتاز الواجب.
  • درجة الطالب الثاني صفر: عند سؤال قاعدة الدرجات Gradebook عن كل خلية كانت النتيجة test_average 0.0 من 2.0 وtest_letter 0.0 من 3.0. والسؤال الثاني هو المهم، فالاختباران الظاهران نجحا عند الطالب قبل التسليم، والاختبار المخفي letter(90) هو الذي كشف خطأ الحدود، وهذا بالضبط سبب وجود الاختبارات المخفية. ولاحظ أن الخلية كلها تأخذ صفراً إذا فشل فيها سطر واحد، فاجعل كل خلية اختبار تختبر فكرة واحدة إذا أردت درجات جزئية.
  • الأعمدة الفارغة: الاسم والبريد فارغان في الملف، لأن الطلاب أضيفوا إلى قاعدة الدرجات بأسماء المستخدمين فقط، فأضف الطلاب قبل الواجب الأول بالأمر nbgrader db student import من ملف CSV حتى يخرج ملف الدرجات جاهزاً للرفع إلى منصة التعلم.

والصورة التالية لواجهة التصحيح formgrader بحساب المعلمة، وفيها الواجب وعدد التسليمات:

صفحة nbgrader بعنوان Manage Assignments، وعلى اليسار روابط Manage Assignments وManual Grading وManage Students، وفي الجدول الواجب hw1 بحالة released وأمامه أيقونات Edit وGenerate وPreview وRelease وCollect وعدد التسليمات 3 وGenerate Feedback وRelease Feedback
واجهة formgrader مفتوحة مباشرة على المسار /user/t.sara/formgrader

فتحناها مباشرة على المسار /user/t.sara/formgrader في تبويب مستقل، والسبب أن دليل nbgrader مع JupyterHub ينبه إلى أن JupyterHub منذ الإصدار 4.1 يمنع عرض الصفحات داخل إطار iframe لأسباب أمنية، وواجهة التصحيح تعرض داخل إطار في JupyterLab فتظهر فارغة، والحلول التي يذكرها الدليل: فتحها في تبويب جديد، أو تفعيل النطاقات الفرعية Subdomains في JupyterHub.

ثلاث نقاط أمان قبل أول مقرر حقيقي

كود الطالب يعمل بحساب المعلم

هذا هو سبب السطر الإضافي في إجابة الطالب الثالث، فقد كتب في الملف /tmp/who-ran-my-code.txt اسم المستخدم الذي شغل كوده، وبعد التصحيح كان محتوى الملف كما يلي:

-rw-rw-r-- 1 t.sara t.sara 49 Oct 22 01:11 /tmp/who-ran-my-code.txt
t.sara /home/t.sara/py101/autograded/s2026003/hw1

أي أن الأمر autograde شغل كود الطالب بحساب المعلمة t.sara وداخل مجلدها، وهذا يعني أن طالباً يكتب في إجابته سطراً يقرأ ملف gradebook.db ويغير درجته، أو يقرأ إجابات زملائه من مجلد submitted، أو يحذف ملفات المعلم، سوف ينجح كوده لحظة التصحيح. والأسئلة الشائعة في nbgrader تقول صراحة إن الأداة لا تحمي من الكود غير الآمن بشكل مباشر، وتحيل إلى التصحيح داخل Docker Container عبر نواة معزولة. لذلك لا تقم بتشغيل autograde بحساب المعلم على سيرفر فيه بيانات مهمة، والأنسب أن يكون التصحيح بحساب مخصص لا يملك إلا مجلد المقرر، أو داخل Container لكل تصحيح.

حد الذاكرة الذي لا يطبق

في الإعداد كتبنا c.Spawner.mem_limit = "512M"، فهل يمنع ذلك طالباً من استهلاك ذاكرة السيرفر كلها؟ جربنا من حساب الطالب الأول:

su - s2026001 -c 'p=$(pgrep -u s2026001 -f singleuser | head -1); tr "\0" "\n" < /proc/$p/environ | grep -E "^MEM_LIMIT"
/srv/venv/bin/python3 -c "
b = bytearray(900 * 1024 * 1024)
import resource; print(\"allocated 900 MB, peak RSS\", resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024, \"MB\")"'
MEM_LIMIT=536870912
allocated 900 MB, peak RSS 910 MB

الحد وصل إلى سيرفر الطالب كمتغير بيئة Environment Variable فقط، والعملية استهلكت 910MB دون أي اعتراض. ومرجع ال Spawner يقول ذلك صراحة: الحد يحتاج Spawner يطبقه، والمشغل الافتراضي LocalProcessSpawner لا يطبقه. فإذا كان السيرفر مشتركاً بين ثلاثين طالباً، فطالب واحد بحلقة تحمل ملف بيانات كبيراً يستطيع أن يوقف المحاضرة للجميع، والحل أن تستخدم طريقة تطبق الحدود فعلاً من الجدول في أول المقال، مثل limits.memory في The Littlest JupyterHub أو حدود Kubernetes.

مجلد التسليم

مجلد التبادل /srv/nbgrader/exchange مفتوح للكتابة للجميع، والمجلد الذي تصل إليه التسليمات له الصلاحيات drwx-ws-wx، أي يكتب فيه الطالب ولا يستطيع عرض محتواه:

ls: cannot open directory '/srv/nbgrader/exchange/py101/inbound': Permission denied

ولكن لاحظ أن مجلد كل تسليم داخله بالصلاحيات drwxrwxrwx، وجربنا من حساب الطالب الثاني قراءة ملف تسليم الطالب الأول بمساره الكامل فنجحت القراءة. والذي يحمي التسليم هنا هو أن اسم المجلد فيه الوقت بأجزاء المليون من الثانية ونص عشوائي من 12 حرفاً مثل jQrwWzFOeYso، فلا يمكن تخمينه عملياً، ولكنه ليس حماية بالصلاحيات. والقاعدة: اجمع التسليمات بالأمر collect بعد الموعد مباشرة، ولا تجعل المجلد على مساحة مشتركة يصل إليها من لا علاقة له بالمقرر، وفي الأنظمة الكبيرة استخدم خدمة تبادل بديلة من إضافات nbgrader.

النسخ الاحتياطي والترقية

  • ما الذي تنسخه؟ مجلدات الطلاب في /home، ومجلد المقرر عند المعلم وفيه gradebook.db وكل التسليمات والتصحيحات، وملفي الإعداد، وقاعدة بيانات JupyterHub jupyterhub.sqlite في مجلد عمله. ثم طبق ما في النسخ الاحتياطي بقاعدة 3-2-1.
  • الترقية Upgrade: لا ترق JupyterHub أو JupyterLab أو nbgrader في منتصف الفصل الدراسي، والسبب أن واجهات nbgrader في JupyterLab إضافات Extensions مبنية لإصدار محدد من JupyterLab، فإذا تقدم أحدهما على الآخر قد تتعطل قائمة الواجبات عند كل الطلاب في يوم التسليم. فرق بين الفصول، وجرب دورة واجب كاملة كالتي في هذا المقال قبل أن تفتح السيرفر للطلاب.

مشكلات شائعة وحلولها

ما تراهالسببالحل
السيرفر لا يبدأ، وفي السجل «increase Spawner.http_timeout»السيرفر لم يرد خلال 30 ثانيةارفع c.Spawner.http_timeout وأضف ذاكرة ومعالجاً للسيرفر
ModuleNotFoundError: No module named 'nbformat' في سطر الأوامرPython النظام وليس بيئة JupyterHubأضف /srv/venv/bin إلى PATH أو استخدم الطرفية داخل JupyterLab
واجهة formgrader فارغة داخل JupyterLabJupyterHub 4.1 وما بعده يمنع الإطارات iframeافتحها في تبويب مستقل أو فعل النطاقات الفرعية
تحذير checksum عند autogradeالاختبارات المخفية غير موجودة في نسخة الطالبمتوقع، ويعيد nbgrader الخلايا المقفلة من المصدر
طالب واحد يبطئ السيرفر للجميعLocalProcessSpawner لا يطبق حد الذاكرةThe Littlest JupyterHub أو DockerSpawner أو Kubernetes
الاسم والبريد فارغان في grades.csvالطلاب غير مضافين إلى قاعدة الدرجاتnbgrader db student import قبل أول واجب

تنظيف المختبر

docker compose down -v
docker image rm edu3-jhub:6.1.1

تدريب عملي

  • أضف إلى الواجب سؤالاً ثالثاً يقرأ ملف CSV بالمكتبة pandas ويحسب متوسط كل مادة، مع اختبار مخفي لملف فيه قيمة فارغة.
  • أنشئ حساب Linux اسمه grader لا يملك إلا مجلد المقرر، وشغل autograde به، ثم أعد تسليم الطالب الثالث وانظر من أصبح يشغل الكود.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • JupyterHub يعطي كل طالب سيرفر Jupyter خاصاً به في المتصفح، وكل سيرفر استهلك في تجربتنا نحو 168MB وهو خامل، فخطط على عدد الطلاب في المحاضرة نفسها.
  • المشغل Spawner هو القرار الأهم: الافتراضي لا يطبق حد الذاكرة، وقد استهلك طالب 910MB رغم حد 512MB، فاختر The Littlest JupyterHub أو Docker أو Kubernetes لأي مقرر حقيقي.
  • nbgrader يغطي دورة الواجب كاملة، والاختبارات المخفية هي التي كشفت خطأ الحدود الذي اجتاز الاختبارات الظاهرة.
  • التصحيح الآلي يشغل كود الطالب بحساب المعلم وداخل مجلده، فاعزل التصحيح في حساب مخصص أو Container قبل أول مقرر.
  • جرب بداية المحاضرة قبل أن تبدأ، فقد فشلت ثلاثة سيرفرات بدأت معاً بالمهلة الافتراضية 30 ثانية.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال وتجربته على JupyterHub 6.1.1 وJupyterLab 4.6.4 وnbgrader 0.9.6.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.