چند سایت را روی یک سرور نگه میدارم؛ هر کدام یک پروژهی Django با Celery و Redis خودش، و همه پشت یک CDN: Cloudflare یا ابرآروان. این یادداشت چیدمان آن است و چند نکتهی ریز که هر کدام دستکم یک بار چیزی را خراب کردهاند.
چیدمان سرور
همهچیز در یک پوشهی services/ با یک docker-compose.yml است و خود سرور را اسکریپت setup-server.sh آماده میکند. شبکههای Docker و پوشهی دادهی هر سایت هم جداست.
| مشترک | برای هر سایت |
|---|---|
nginx (یک فایل در conf.d برای هر دامنه) | app با gunicorn |
| Postgres (یک دیتابیس برای هر سایت) | Redis: cache، session و broker |
| pgAdmin | Celery 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) |
| فقط nginx | 1 |
| CDN و nginx | 2 |
عدد درست را فقط خود استقرار میداند، برای همین با 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.
بالا آوردن یک سایت تازه
- چهار سرویس یک سایت را در
docker-compose.ymlبا نام تازه کپی کنید، با شبکهها و خطهای volume آن زیر nginx. - فایل nginx را برای دامنهی تازه کپی کنید؛ پشت Cloudflare گواهی Origin آن را هم بگذارید.
- پروژه را در
projects/کلون کنید و.envش را پر کنید. - دیتابیس را دستی بسازید؛
POSTGRES_EXTRA_DATABASESفقط در اولین اجرای Postgres خوانده میشود. - سرویسهای همان سایت را بالا بیاورید،
nginx -tرا اجرا کنید و nginx را دوباره بسازید. 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