نشر FastAPI على الإنتاج: Uvicorn وGunicorn وNginx بالإعداد الصحيح

شغّل FastAPI تحت Gunicorn بعمّال Uvicorn وخلف Nginx، وابدأ بعاملين لكل vCPU، واحفظ الأسرار في ملف بيئة بصلاحيات 600. على سيرفر 4 vCPU و8GB يخدم 8 عمّال نحو 2,300 طلب في الثانية بذاكرة 1.2GB فقط.
الإعداد اللي يصمد فعلاً على الإنتاج بسيط: شغّل FastAPI تحت Gunicorn بعمّال Uvicorn، وضع Nginx قدامه للتشفير والتخزين المؤقت والمهلات، وابدأ بنحو عاملَين لكل vCPU إذا كان تطبيقك يقضي وقته في انتظار قاعدة البيانات، وعامل واحد لكل vCPU إذا كانت نقاطك ثقيلة على المعالج. خلِّ الأسرار في ملف بيئة يملكه root بصلاحيات 600، واعرض نقطة /health يفحصها systemd وNginx وأداة المراقبة معاً. على سيرفر بـ4 vCPU و8GB، ثمانية عمّال لواجهة JSON عادية يستهلكون نحو 1.2GB من الذاكرة كلها، ويخدمون من 1,500 إلى 2,500 طلب بسيط في الثانية بزمن p95 أقل من 40ms، ويبقى مكان مريح لـPostgreSQL وRedis على نفس الجهاز. تشغيل uvicorn main:app وحده داخل tmux يكفي للعرض التجريبي، لكنه يعطيك عملية واحدة فقط، بلا إعادة تشغيل تلقائية لو تسرّبت الذاكرة، وبلا حماية من عميل بطيء يحجز الاتصال دقائق. بقية المقال تمشي معك طبقة طبقة بالملفات نفسها: مدير العمليات، وحساب عدد العمّال، وملف systemd، وإعداد Nginx بمهلات تناسب نماذج الذكاء الاصطناعي، والأسرار، وفحوص الصحة، ثم أرقام استهلاك فعلية تحدد بها حجم السيرفر قبل أن تشتريه.
Uvicorn وحده أم Gunicorn مع عمّال Uvicorn
Uvicorn هو خادم ASGI اللي يتكلم HTTP مع تطبيقك فعلياً، أما Gunicorn فمدير عمليات ناضج ينشئ العمّال ويراقبهم، ويستبدل أي عامل يموت أو يعلق أكثر من مدة --timeout. لما تجمعهما يتولى Gunicorn الإشراف ويتولى Uvicorn حلقة الأحداث غير المتزامنة داخل كل عامل.
إصدارات Uvicorn الحديثة تدعم --workers أيضاً، وهذا يكفي لخدمات صغيرة كثيرة، لكن الفرق يظهر تحت الضغط كما يوضح الجدول:
| القدرة | uvicorn --workers | gunicorn مع UvicornWorker |
|---|---|---|
| إعادة تشغيل عامل عالق | محدودة | نعم، بعد --timeout ثانية |
| تدوير العمّال لاحتواء التسرّب | لا | نعم، --max-requests 2000 --max-requests-jitter 200 |
| إعادة تحميل بلا انقطاع عند النشر | أساسية | نعم، بإشارة kill -HUP للعملية الأم |
| ذاكرة إضافية | لا شيء | نحو 25MB للعملية الأم |
لأي خدمة عليها زوار حقيقيون أو تحمّل نموذج تعلم آلة، الـ25MB ثمن رخيص مقابل التعافي التلقائي. ثبّت الاثنين بأمر pip install "gunicorn==23.0.0" "uvicorn[standard]==0.32.0" وثبّت الإصدارات في ملف المتطلبات حتى لا يسحب النشر ترقية مفاجئة.
كم عاملاً لكل نواة
المعادلة المشهورة (2 x الأنوية) + 1 كُتبت للعمّال المتزامنين، بينما العامل غير المتزامن يخدم طلبات كثيرة في الوقت نفسه، لذلك نادراً ما تحتاج هذا العدد. نقطة البداية العملية كالتالي:
- واجهة تنتظر الإدخال والإخراج (استعلامات قاعدة بيانات واتصالات بخدمات أخرى): عاملان لكل vCPU، يعني 8 عمّال على 4 vCPU.
- نقاط ثقيلة على المعالج (معالجة صور أو توليد PDF أو عمليات pandas كبيرة): عامل لكل vCPU، وانقل الجزء الثقيل لطابور خلفي إذا تجاوز ثانيتين.
- نموذج ذكاء اصطناعي داخل التطبيق: كل عامل يحمّل نسخته الخاصة من النموذج، فنموذج حجمه 1.5GB مع 4 عمّال يحتاج 6GB قبل أن يخدم طلباً واحداً. هنا إما تشغّل عاملاً أو عاملين، أو تفصل الاستدلال في خدمة مستقلة.
قِس ثم عدّل. راقب ps -o rss,cmd -C gunicorn بعد ساعة من الزيارات، فعامل FastAPI عادي مع SQLAlchemy يستقر غالباً من 90 إلى 160MB. إذا استمر عامل في النمو فوق 400MB فعندك تسرّب، و--max-requests يحصره لين تلقى سببه.
ملف systemd
systemd يشغّل التطبيق مع إقلاع السيرفر، ويعيد تشغيله عند السقوط، ويجمع السجلات في journald. أنشئ الملف /etc/systemd/system/api.service:
[Unit]
Description=FastAPI app
After=network.target
[Service]
User=app
Group=app
WorkingDirectory=/srv/api
EnvironmentFile=/etc/api/api.env
ExecStart=/srv/api/.venv/bin/gunicorn main:app \
-k uvicorn.workers.UvicornWorker \
--workers 8 --bind 127.0.0.1:8000 \
--timeout 60 --graceful-timeout 30 \
--max-requests 2000 --max-requests-jitter 200
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=3
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
اربط على 127.0.0.1 وليس 0.0.0.0، حتى يبقى Nginx هو المدخل العام الوحيد. نفّذ systemctl daemon-reload && systemctl enable --now api، ومع كل نشر استخدم systemctl reload api لتبديل العمّال بلا توقف.
Nginx في الواجهة بمهلات تناسب الذكاء الاصطناعي
Nginx ينهي التشفير، ويخزّن الرفع البطيء مؤقتاً حتى لا ينتظر عمّالك عميلاً على شبكة ضعيفة، ويفرض حدود حجم الطلب. المهلة الافتراضية proxy_read_timeout بستين ثانية مناسبة للنقاط العادية، لكنها تقطع رد نموذج لغوي طويل في منتصفه، فخصّص للمسارات المتدفقة كتلة مستقلة:
upstream api { server 127.0.0.1:8000; keepalive 32; }
server {
listen 443 ssl http2;
server_name api.example.sa;
client_max_body_size 20m;
location / {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
location /v1/chat/stream {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 300s;
}
}
إيقاف proxy_buffering على مسار البث يخلي الكلمات توصل المتصفح أول بأول بدل أن تنزل دفعة واحدة بعد 40 ثانية. شغّل Gunicorn أيضاً بخيار --forwarded-allow-ips 127.0.0.1 حتى يثق FastAPI بالترويسات الممرّرة ويبني روابط HTTPS صحيحة. إذا كنت تبني على منظومة استدلال، فقوالب استضافة الذكاء الاصطناعي تأتي بـOllama وOpen WebUI مثبتة مسبقاً، ونفس نمط Nginx يقف أمامها.
الأسرار مكانها خارج الكود
كلمات مرور قاعدة البيانات ومفاتيح API ومفاتيح توقيع JWT لا تدخل Git أبداً. ضعها في /etc/api/api.env مع chown root:root وchmod 600، وخلِّ systemd يحقنها عبر EnvironmentFile، ثم اقرأها داخل التطبيق بمكتبة pydantic-settings:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
jwt_secret: str
openai_api_key: str | None = None
settings = Settings()
لو نقص متغير إلزامي يرفض التطبيق الإقلاع برسالة تحقق واضحة، بدل أن يسقط لاحقاً مع أول طلب. دوّر الأسرار كل 90 يوماً على الأقل، واحتفظ بقائمة قصيرة لمكان استخدام كل سر، لأن يوم التدوير المستعجل ليس يوم البحث عنها.
فحوص صحة لها معنى
قسّم الفحص إلى نقطتين: /health ترد في أقل من 5ms وتثبت فقط أن العملية حيّة، و/ready تنفذ SELECT 1 على قاعدة البيانات وتختبر Redis، فترجع 503 لو فشل أحدهما. أداة المراقبة تسأل /ready كل 30 ثانية، بينما موازن الحمل وسكربتات النشر تستخدم /health حتى لا يسبب بطء قاعدة البيانات عاصفة إعادة تشغيل. أخرج النقطتين من سجل الوصول، وإلا صارت 40% من أسطره.
ثلاثة أخطاء نشوفها في أغلب أول نشر
- التشغيل بخيار
--reloadعلى الإنتاج، فيعمل مراقب ملفات يأكل من 3 إلى 5% من نواة كاملة ويعيد تشغيل العمّال كلما تغيّر ملف سجل. - ترك مهلة Gunicorn على الافتراضي 30 ثانية بينما نقطة معينة تتصل بواجهة خارجية تتأخر أحياناً 45 ثانية، فيُقتل العامل في منتصف الطلب.
- فتح المنفذ 8000 في الجدار الناري «للتجربة السريعة» ونسيانه، فينكشف التطبيق للعالم بلا تشفير ولا حد للطلبات.
الثلاثة تنكشف بأمرين: ss -tlnp يعرض المنافذ المفتوحة فعلاً، وsystemctl cat api يعرض الخيارات اللي يشتغل بها التطبيق الآن، فاجعلهما جزءاً من مراجعة كل نشر.
أرقام فعلية على سيرفر 4 vCPU و8GB
هذا شكل خدمة FastAPI نموذجية مع PostgreSQL وRedis على نفس الجهاز، على خادم سحابي من باقة أعمال بـ4 vCPU و8GB، مقاسة بأمر wrk -t4 -c200 -d60s على نقطة تقرأ صفاً واحداً وترجع JSON:
| العمّال | ذاكرة التطبيق | طلب/ثانية | زمن p95 |
|---|---|---|---|
| 2 | 310MB | نحو 900 | 85ms |
| 4 | 620MB | نحو 1,700 | 48ms |
| 8 | 1.2GB | نحو 2,300 | 36ms |
| 12 | 1.8GB | نحو 2,350 | 41ms |
المنحنى يتسطح بعد 8 عمّال لأن الاختناق ينتقل إلى PostgreSQL، فالعمّال الإضافيون يضيفون ذاكرة وتبديل سياق فقط. مع 1.2GB للتطبيق ونحو 2GB لذاكرة PostgreSQL و300MB لـRedis، يبقى أكثر من 3GB فارغة لذروات الزيارات، ومستخدموك في الرياض يضيفون نحو 8ms فقط من زمن الشبكة. باقة أعمال سعرها 11,500 ريال سنوياً شامل الضريبة، يعني نحو 31.5 ريال في اليوم، وإذا كنت تحتاج الواجهة فقط بدون قاعدة بيانات على نفس الجهاز، فباقة نمو بـ2 vCPU و4GB وسعر 6,900 ريال سنوياً تشيل 4 عمّال براحة.
قائمة نشر تطبّقها اليوم
- أنشئ مستخدماً مخصصاً اسمه
appوبيئة افتراضية في/srv/api/.venvبمتطلبات مثبّتة الإصدار. - اكتب ملف systemd مع Gunicorn وعمّال Uvicorn و
--max-requestsومهلة 60 ثانية. - اربط على
127.0.0.1:8000وضع Nginx قدامه بالتشفير وكتلة مستقلة لمسارات البث. - انقل كل سر إلى ملف بيئة بصلاحيات 600 واقرأه عبر pydantic-settings.
- أضف
/healthو/readyووجّه المراقبة إلى/ready. - اختبر الحمل بـ
wrk، وحدد النقطة اللي يتسطح عندها المنحنى، واضبط عدد العمّال عليها.
بعد هذه الخطوات الست يصير النشر مجرد git pull وتثبيت للمتطلبات ثم systemctl reload. قارن الباقات في صفحة الأسعار واختر الحجم اللي يطابق الأرقام اللي قستها، لا الحجم اللي خمّنته.


