معماری بک‌اند

چند سایت Django روی یک سرور پشت CDN: آی‌پی واقعی بازدیدکننده، static هش‌شده و تنظیمات استقرار

یک nginx و یک Postgres، و برای هر سایت app، Redis، Celery worker و beat جدا، پشت Cloudflare یا ابرآروان. همراه با جزئیاتی که دردسر می‌سازند: آی‌پی بازدیدکننده پشت دو پراکسی، فایل‌های static هش‌شده برای CDN و کلیدهایی که استقرار باید داشته باشد.

nginx, Cloudflare, Django and Docker

چند سایت را روی یک سرور نگه می‌دارم؛ هر کدام یک پروژه‌ی Django با Celery و Redis خودش، و همه پشت یک CDN: Cloudflare یا ابرآروان. این یادداشت چیدمان آن است و چند نکته‌ی ریز که هر کدام دست‌کم یک بار چیزی را خراب کرده‌اند.

چیدمان سرور

همه‌چیز در یک پوشه‌ی services/ با یک docker-compose.yml است و خود سرور را اسکریپت setup-server.sh آماده می‌کند. شبکه‌های Docker و پوشه‌ی داده‌ی هر سایت هم جداست.

مشترکبرای هر سایت
nginx (یک فایل در conf.d برای هر دامنه)app با gunicorn
Postgres (یک دیتابیس برای هر سایت)Redis: cache، session و broker
pgAdminCelery worker و Celery beat

چرا هر سایت Redis و Celery خودش را دارد

در کد، از Redis فقط نام کانتینرش (REDIS_CONTAINER) تنظیم‌شدنی است و شماره‌ی دیتابیس‌ها ثابت است: broker روی /1، cache روی /2 و session روی /3. دو سایت روی یک Redis یعنی یک صف و یک cache مشترک. worker و beat هم با image و .env همان سایت اجرا می‌شوند: worker فقط taskهای کد خودش را می‌شناسد و beat زمان‌بندی‌ها را با DatabaseScheduler از دیتابیس همان سایت می‌خواند. دردسر هر سایت هم مال خودش می‌ماند؛ هر سایت با نام بردن سرویس‌هایش جدا ساخته می‌شود، و بدون نام، خطای ساخت یک سایت جلوی همه را می‌گیرد:

docker compose up -d --build site_one site_one_celery_worker site_one_celery_beat

nginx جلوی سایت‌ها، CDN جلوی nginx

مسیر هر درخواست: بازدیدکننده ← CDN ← nginx ← gunicorn. nginx از شبکه‌ی Docker به gunicorn می‌رسد، پس دو سایت هر دو می‌توانند روی 8000 باشند. مهم‌ترین تصمیم این است که nginx به هیچ سایتی وابسته نباشد: نام کانتینر از راه یک متغیر به proxy_pass می‌رسد، پس DNS داخلی Docker (resolver 127.0.0.11) آن را موقع درخواست پیدا می‌کند، نه موقع بالا آمدن nginx. سایتی که خراب است یا هنوز ساخته نشده فقط روی دامنه‌ی خودش 502 می‌دهد.

server {
    listen 443 ssl;
    server_name example.com www.example.com;
    ssl_certificate     /etc/nginx/ssl/example.com/origin.pem;
    ssl_certificate_key /etc/nginx/ssl/example.com/origin-key.pem;

    location /static/ { alias /projects/volumes/site_one/static/; }

    location / {
        set $upstream site_one:8000;     # looked up per request
        proxy_pass http://$upstream;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

نامی که هیچ فایلی مال آن نیست، مثل IP خالی سرور، با return 444 به Django نمی‌رسد. و قبل از هر reload، nginx -t: با تنظیمات خراب، nginx بالا نمی‌آید و همه‌ی سایت‌ها با هم می‌خوابند.

Cloudflare و ابرآروان یکی نیستند

  • Cloudflare با HTTPS به سرور وصل می‌شود. گواهی سرور Origin Certificate است که خود Cloudflare می‌سازد (15 ساله، بدون تمدید) و حالت SSL باید Full (strict) باشد. با Flexible روی پورت 80 می‌آید، nginx آن را به https برمی‌گرداند و سایت در حلقه‌ی ریدایرکت می‌افتد.
  • ابرآروان گواهی را خودش نگه می‌دارد و تمدید می‌کند، و با HTTP به سرور وصل می‌شود. پس $scheme در nginx همیشه http است و اگر همان به‌عنوان X-Forwarded-Proto به Django برسد، هر درخواست https دوباره به https فرستاده می‌شود. nginx باید پروتکلی را جلو بفرستد که بازدیدکننده با آن آمده است:
# 00-http.conf
map $http_x_forwarded_proto $visitor_proto {
    https   https;
    default http;
}

# the site's file, in location /
proxy_set_header X-Forwarded-Proto $visitor_proto;

آی‌پی واقعی بازدیدکننده: TRUSTED_PROXY_COUNT

هر پراکسی آدرسی را که از آن صدا زده شده به ته X-Forwarded-For اضافه می‌کند. اگر بازدیدکننده خودش هم هدری جعلی بفرستد، پشت CDN و nginx چنین چیزی به Django می‌رسد:

X-Forwarded-For: 198.51.100.7, 203.0.113.10, 192.0.2.44
# 198.51.100.7   written by the visitor: anything at all
# 203.0.113.10   the visitor, appended by the CDN
# 192.0.2.44     the CDN edge, appended by nginx

بازدیدکننده TRUSTED_PROXY_COUNT خانه از سمت راست است و هرچه چپ‌تر باشد نوشته‌ی خود اوست. کد قبلاً چپ‌ترین خانه را برمی‌داشت: با یک هدر جعلی می‌شد محدودیت هر آدرس در پازل کشویی ورود را دور زد، همان محدودیت را روی آدرس کس دیگری انداخت تا قفل شود، و آدرسی ساختگی در اعلان ورود نشاند. حالا get_client_ip() این است:

address = request.META.get("REMOTE_ADDR")
proxies = getattr(settings, "TRUSTED_PROXY_COUNT", 0)
if proxies > 0:
    forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
    hops = [hop.strip() for hop in forwarded.split(",") if hop.strip()]
    if hops:
        address = hops[-proxies] if len(hops) >= proxies else hops[0]
return _valid_ip(address) or _valid_ip(request.META.get("REMOTE_ADDR"))
استقرارTRUSTED_PROXY_COUNT
runserver بدون پراکسی0 (همان REMOTE_ADDR)
فقط nginx1
CDN و nginx2

عدد درست را فقط خود استقرار می‌داند، برای همین با DEPLOYED روشن پیش‌فرض ندارد. کم باشد، همه‌ی بازدیدکننده‌ها خود پراکسی‌اند و یک rate limit مشترک دارند؛ زیاد باشد، آدرس را بازدیدکننده انتخاب می‌کند. دامنه‌ای که رکورد DNSش از CDN رد نمی‌شود (ابر خاکستری در Cloudflare) 1 است.

فایل‌های static هش‌شده

لبه‌ی CDN فایل static را cache می‌کند و همان را می‌دهد. راه‌حل پاک کردن cache نیست؛ این است که هر نسخه‌ی فایل آدرس تازه‌ای داشته باشد. روی سرور، collectstatic هر فایل را یک بار دیگر با هشی از محتوایش در نامش می‌نویسد (app.css ← app.3f2a1b9c0d4e.css)، جفت نام‌ها را در staticfiles.json ثبت و url()های داخل CSS را بازنویسی می‌کند، و {% static %} نام هش‌دار را می‌دهد. (تنظیمش STORAGES است؛ STATICFILES_STORAGE از Django 5.1 بی‌صدا نادیده گرفته می‌شود.)

چرا یک زیرکلاس «بخشنده»، ForgivingManifestStaticFilesStorage؟ کلاس خود Django روی ارجاع به فایلی که وجود ندارد خطا می‌دهد. collectstatic سر اولین ارجاع گم‌شده می‌ایستد (CSSهای vendor صدها تا از این‌ها دارند: SVGهای flag-icons، فونت‌های KaTeX، فایل‌های .map) و چون اسکریپت شروع زیر set -e اجرا می‌شود، کانتینر بالا نمی‌آید. با DEBUG=False هم یک {% static %} به فایل گم‌شده برای کل صفحه 500 است. زیرکلاس چنین ارجاعی را همان‌طور که نوشته شده رها می‌کند، پس فقط همان یک فایل 404 می‌شود؛ manifest_strict = False به‌تنهایی به این‌جا نمی‌رسید.

این storage با DEBUG روشن هم نام هش‌دار می‌دهد. با کلاس خود Django، استقراری که برای عیب‌یابی روی DEBUG مانده بود، stylesheet ویرایش‌شده را با آدرس قبلی می‌داد و hard reload هم کمکی نمی‌کرد.

collectstatic در هر بار بالا آمدن

entrypoint خود image، یعنی _0_deploy/script.sh، این کارها را در هر start انجام می‌دهد. کانتینرهای Celery یک command می‌گیرند که اسکریپت مستقیم exec می‌کند:

if [ "$#" -gt 0 ]; then exec "$@"; fi        # celery worker / beat

while ! nc -z ${DATABASES_HOST:-postgres_service} ${DATABASES_PORT:-5432}; do
    sleep 1
done
python manage.py makemigrations --noinput
python manage.py migrate --noinput
python manage.py collectstatic --noinput
python manage.py translate_sync
exec gunicorn _0_config.wsgi -b 0.0.0.0:"$GUNICORN_BIND_PORT" \
    --workers "${GUNICORN_WORKERS:-3}" --timeout "${GUNICORN_TIMEOUT:-60}"

collectstatic نسخه‌ی بی‌هش و همه‌ی هش‌های قبلی را هم نگه می‌دارد، پس صفحه‌ای که با آدرس قدیمی cache شده هنوز بار می‌شود. translate_sync لازم است چون فایل‌های .mo در git نیستند.

آنچه .env استقرار باید بگوید

هر سایت یک .env دارد که compose با env_file به app، worker و beat می‌دهد و خودش در image نیست. DEPLOYED="True" پیش‌فرض‌های توسعه را خاموش می‌کند، به‌جای اعتماد به این‌که فایل تک‌تکشان را عوض کرده باشد:

  • SECRET_KEY، ALLOWED_HOSTS، CSRF_TRUSTED_ORIGINS و TRUSTED_PROXY_COUNT پیش‌فرض ندارند و نبودن هر کدام start-up را با نام همان کلید متوقف می‌کند، به‌جای این‌که سایت با کلید عمومی "Test" بالا بیاید و به هر hostی جواب بدهد. DEBUG پیش‌فرض خاموش است.
  • بدون USE_STATIC_ROOT="True" و STATIC_ROOT، start-up با ImproperlyConfigured می‌ایستد؛ manifest هش‌ها همان‌جا نوشته و خوانده می‌شود.
  • USE_SSL_CONFIG پیش‌فرض همان DEPLOYED است: cookieهای secure، HSTS، ریدایرکت به https و SECURE_PROXY_SSL_HEADER. /health/ از ریدایرکت معاف است، چون healthcheck کانتینر با HTTP ساده می‌پرسد و Celery منتظر healthy شدن app می‌ماند.
  • USE_SQLITE_DB="False"؛ وگرنه app یک SQLite را migrate می‌کند و beat دیگری را می‌خواند.
  • SALT_PASSWORD یک بار و قبل از اولین حساب تعیین می‌شود؛ عوض کردنش بعداً همه‌ی حساب‌ها را قفل می‌کند.
DEBUG="False"
DEPLOYED="True"
SECRET_KEY='-----'
ALLOWED_HOSTS="example.com,www.example.com"
CSRF_TRUSTED_ORIGINS="https://example.com,https://www.example.com"
TRUSTED_PROXY_COUNT="2"
USE_SSL_CONFIG="True"
USE_SQLITE_DB="False"
DATABASES_HOST="postgres_service"
REDIS_CONTAINER="site_one_redis"
USE_STATIC_ROOT="True"
STATIC_ROOT="/projects/volumes/static/"
SERVE_MEDIA_STATIC="False"

هر تغییر در .env با docker compose up -d به کانتینرها می‌رسد، نه با restart.

Celery روی سرور کوچک

Celery به‌طور پیش‌فرض برای هر CPU یک پروسه می‌سازد و هر کدام کل Django را بار می‌کند (حدود 115 مگابایت)؛ روی یک لپ‌تاپ 22 هسته‌ای، worker بیکار یک پروژه 1٫7 گیگابایت گرفته بود. پس سه حد گذاشته‌ام:

  • CELERY_WORKER_CONCURRENCY با پیش‌فرض 2، نه تعداد هسته‌ها.
  • CELERY_WORKER_MAX_MEMORY_PER_CHILD با پیش‌فرض 300000 کیلوبایت؛ پروسه‌ای که از این بزرگ‌تر شود بعد از task جایگزین می‌شود.
  • mem_limit: 1g روی کانتینر worker در compose.

بالا آوردن یک سایت تازه

  1. چهار سرویس یک سایت را در docker-compose.yml با نام تازه کپی کنید، با شبکه‌ها و خط‌های volume آن زیر nginx.
  2. فایل nginx را برای دامنه‌ی تازه کپی کنید؛ پشت Cloudflare گواهی Origin آن را هم بگذارید.
  3. پروژه را در projects/ کلون کنید و .envش را پر کنید.
  4. دیتابیس را دستی بسازید؛ POSTGRES_EXTRA_DATABASES فقط در اولین اجرای Postgres خوانده می‌شود.
  5. سرویس‌های همان سایت را بالا بیاورید، nginx -t را اجرا کنید و nginx را دوباره بسازید.
  6. init و بعد createsuperuser؛ روی سرور، init ادمینی با رمز 123 نمی‌سازد.
docker compose exec postgres_service createdb -U <POSTGRES_USER> site_three_db
docker compose up -d --build site_three site_three_celery_worker site_three_celery_beat
docker compose exec nginx nginx -t
docker compose up -d nginx
docker compose exec site_three python manage.py init
docker compose exec site_three python manage.py createsuperuser