168 lines
13 KiB
Markdown
168 lines
13 KiB
Markdown
# استقرار Production روی Coolify
|
|
|
|
این پروژه با سه سرویس اجرا میشود:
|
|
|
|
- `nginx`: فقط سرویسدهنده فایلهای `/static/` روی پورت 80
|
|
- `web`: اجرای Django با Gunicorn روی پورت داخلی 8000
|
|
- `notification-worker`: اجرای دائمی `python manage.py process_notifications` برای SMS و Bale
|
|
|
|
PostgreSQL جزو این Compose نیست و از دیتابیس مدیریتشده موجود استفاده میشود. Supervisor لازم نیست؛ هر process یک container مستقل دارد و `restart: unless-stopped` بازیابی آن را انجام میدهد.
|
|
|
|
## 1. Volumeهای Coolify
|
|
|
|
پیش از اولین deploy سه Docker volume با همین نامها ایجاد کنید:
|
|
|
|
```text
|
|
customer_ticket_static
|
|
customer_ticket_media
|
|
customer_ticket_seed
|
|
```
|
|
|
|
در صورت انتخاب نام دیگر، متغیرهای `STATIC_VOLUME_NAME`، `MEDIA_VOLUME_NAME` و `SEED_VOLUME_NAME` را مطابق آن تنظیم کنید. media حاوی آپلودهای ماندگار است. static در هر startup دوباره با `collectstatic` تکمیل میشود اما برای اشتراک بین web و Nginx volume لازم دارد. سه فایل تولیدشده در `production-seed/` باید پیش از اولین startup داخل ریشه volume سوم قرار گیرند.
|
|
|
|
این Compose هر سه سرویس را به شبکه خارجی `coolify` متصل میکند تا hostname داخلی PostgreSQL نصبشده در Coolify از داخل `web` و `notification-worker` قابل resolve باشد. شبکه باید از قبل روی سرور Coolify موجود باشد؛ Compose به دلیل `external: true` آن را ایجاد نمیکند.
|
|
|
|
## 2. دامنهها و Traefik خود Coolify
|
|
|
|
این راهنما برای حالت عادی **Git-based Docker Compose Application** است، نه گزینه `Raw Compose Deployment`. در این حالت Coolify باید routeهای Traefik را از فیلدهای Domains بسازد؛ routerهای کامل Traefik را بهصورت دستی داخل Compose تعریف نکنید.
|
|
|
|
در `Configuration > General` مقدارها را دقیقاً به شکل زیر وارد کنید:
|
|
|
|
- `Domains for web`: `https://connect.loolegostar.com:8000`
|
|
- `Domains for notification-worker`: خالی
|
|
- `Domains for nginx`: `https://connect.loolegostar.com/static,https://connect.loolegostar.com/media/branding,https://connect.loolegostar.com/protected-media`
|
|
|
|
در `Configuration > Advanced` گزینه `Strip Prefixes` را **خاموش** کنید. در غیر این صورت Coolify پیشوند `/static` یا `/media/branding` را قبل از تحویل درخواست حذف میکند، در حالی که `docker/nginx.conf` عمداً همان مسیرهای کامل را match میکند. بعد از Save، گزینه `Reload Compose File` و سپس Redeploy را اجرا کنید.
|
|
|
|
Traefik خود Coolify ruleهای Host/PathPrefix، TLS و reverse proxy را تولید میکند. بهدلیل باگ رقابت route مسیرمحور با route عمومی در برخی نسخههای Coolify، Compose روی routerهای تولیدشدهی nginx صراحتاً `priority=10000`، یک service روی پورت 80 و شبکهی resource-specific را تنظیم میکند. ترتیب سه Domain بالا را تغییر ندهید، چون شماره routerهای `0` تا `2` از همین ترتیب ساخته میشود. Nginx جلوی Django قرار نمیگیرد و فقط فایلهای static و media را سرو میکند.
|
|
|
|
کانفیگ `docker/nginx.conf` هنگام build توسط `Dockerfile.nginx` داخل image قرار میگیرد. بنابراین در Git-based deployment هیچ bind mountای از فایل کانفیگ روی میزبان Coolify وجود ندارد و هر redeploy دقیقاً کانفیگ همان commit را اجرا میکند. entrypoint رسمی image مقدار `MEDIA_SIGNING_SECRET` را در template جایگزین و فایل نهایی را در `/etc/nginx/conf.d/default.conf` ایجاد میکند.
|
|
|
|
فایلهای آپلودی تیکت عمومی نیستند: Django ابتدا مجوز را بررسی میکند و سپس کاربر را با یک URL امضاشده پنجدقیقهای به `/protected-media/` روی Nginx میفرستد. branding عمومی زیر `/media/branding/` مستقیماً از Nginx سرو میشود.
|
|
|
|
## 3. متغیرهای ضروری
|
|
|
|
مقادیر زیر را در Environment Variables برنامه Coolify بسازید:
|
|
|
|
```dotenv
|
|
DJANGO_SECRET_KEY=<random-secret-at-least-50-characters>
|
|
DJANGO_ALLOWED_HOSTS=support.example.com
|
|
CSRF_TRUSTED_ORIGINS=https://support.example.com
|
|
PUBLIC_BASE_URL=https://support.example.com
|
|
WEBOTP_HOST=support.example.com
|
|
TIME_ZONE=Asia/Tehran
|
|
|
|
DATABASE_URL=postgresql://postgres:<URL_ENCODED_PASSWORD>@<INTERNAL_HOST>:5432/customerTicket
|
|
DB_CONN_MAX_AGE=60
|
|
DB_CONNECT_TIMEOUT=10
|
|
DB_SSLMODE=prefer
|
|
DATABASE_WAIT_TIMEOUT=60
|
|
|
|
STATIC_VOLUME_NAME=customer_ticket_static
|
|
MEDIA_VOLUME_NAME=customer_ticket_media
|
|
SEED_VOLUME_NAME=customer_ticket_seed
|
|
MEDIA_SIGNING_SECRET=<separate-random-secret-at-least-32-characters>
|
|
REQUIRE_PRODUCTION_SEED=1
|
|
```
|
|
|
|
نام دیتابیس انتهای URL باید نام واقعی دیتابیس باشد. اگر دیتابیس ساختهشده `customerTicket` است، URL باید به `/customerTicket` ختم شود؛ URLی که به `/postgres` ختم میشود به دیتابیس پیشفرض `postgres` متصل خواهد شد. اگر password شامل `@`، `:`، `/`، `#` یا `%` است آن را URL-encode کنید.
|
|
|
|
میتوانید بهجای `DATABASE_URL` آن را خالی بگذارید و `POSTGRES_DB`، `POSTGRES_USER`، `POSTGRES_PASSWORD`، `POSTGRES_HOST` و `POSTGRES_PORT` را جداگانه تعریف کنید. یکی از این دو روش را انتخاب کنید.
|
|
|
|
## 4. تنظیم SMS
|
|
|
|
فقط provider مورد استفاده را انتخاب و secretهای همان provider را پر کنید.
|
|
|
|
### 0098SMS
|
|
|
|
```dotenv
|
|
SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider
|
|
SMS0098_USERNAME=<username>
|
|
SMS0098_PASSWORD=<password>
|
|
SMS0098_FROM=<sender-number>
|
|
SMS0098_WSDL_URL=https://webservice.0098sms.com/service.asmx?WSDL
|
|
```
|
|
|
|
### Behin SMS
|
|
|
|
```dotenv
|
|
SMS_PROVIDER_CLASS=apps.integrations.sms.behin.BehinSMSProvider
|
|
BEHIN_SMS_USERNAME=<username>
|
|
BEHIN_SMS_PASSWORD=<password>
|
|
BEHIN_SMS_SPECIAL_NUMBER=<sender-number>
|
|
BEHIN_SMS_WSDL_URL=http://panel.behinsms.com/SMSWS/SOAP.asmx?wsdl
|
|
```
|
|
|
|
### HTTP JSON provider
|
|
|
|
```dotenv
|
|
SMS_PROVIDER_CLASS=apps.integrations.sms.http.HTTPSMSProvider
|
|
SMS_API_URL=https://provider.example/api/messages
|
|
SMS_API_TOKEN=<token>
|
|
SMS_SENDER=<sender>
|
|
```
|
|
|
|
## 5. تنظیم Bale و worker
|
|
|
|
```dotenv
|
|
BALE_API_BASE=https://tapi.bale.ai
|
|
BALE_BOT_TOKEN=<bot-token>
|
|
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
|
|
MAX_NOTIFICATION_ATTEMPTS=5
|
|
SMS_MAX_RETRIES=3
|
|
SMS_RETRY_DELAY_SECONDS=180
|
|
SMS_DELIVERY_CHECK_DELAY_SECONDS=180
|
|
SMS_DELIVERY_MAX_CHECKS=15
|
|
```
|
|
|
|
در production نبودن `BALE_BOT_TOKEN` خطای قابلمشاهده ایجاد میکند و job را بیصدا SENT علامت نمیزند. شناسه گروه دیگر یک env سراسری نیست: برای هر واحد از مسیر `x5admin → واحدهای سازمانی`، فیلد «شناسه گروه بله» را جداگانه وارد کنید. خالی بودن این فیلد یعنی اعلانهای گروهی تیکت جدید و پاسخ مشتری برای همان واحد ارسال نمیشود؛ سامانه هرگز به گروه واحد دیگر fallback نمیکند. سه شماره اختیاری سرپرست نیز در همین صفحه تنظیم میشوند و فقط هنگام ایجاد تیکت جدید، پیامک کوتاه دریافت میکنند.
|
|
|
|
worker هر پنج ثانیه outbox دیتابیس را بررسی میکند و با قفل `skip_locked` از پردازش همزمان جلوگیری میکند. هر پیامک پس از شکست اولیه، دقیقاً `SMS_MAX_RETRIES=3` بار با فاصله ثابت `SMS_RETRY_DELAY_SECONDS=180` ثانیه دوباره امتحان میشود. `MAX_NOTIFICATION_ATTEMPTS` همچنان سقف تلاش کانالهای دیگر مانند بله است. پیامک دارای شناسه رهگیری پس از `SMS_DELIVERY_CHECK_DELAY_SECONDS` استعلام میشود؛ پاسخهای موقت 0098SMS (کدهای 6 و 8) هر سه دقیقه تا 15 نوبت، یعنی 45 دقیقه، دوباره بررسی میشوند.
|
|
|
|
سیاست تأخیر پیامک پاسخ کارشناس و اعلان پاسخ مشتری در Bale از مدل `NotificationPolicy` در پنل مدیریت دیتابیس کنترل میشود؛ متغیر قدیمی `STAFF_REPLY_SMS_THROTTLE_SECONDS` مرجع اصلی این رفتار نیست.
|
|
|
|
## 6. تنظیمات امنیتی
|
|
|
|
```dotenv
|
|
SECURE_SSL_REDIRECT=true
|
|
SECURE_HSTS_SECONDS=31536000
|
|
SECURE_HSTS_INCLUDE_SUBDOMAINS=true
|
|
SECURE_HSTS_PRELOAD=false
|
|
```
|
|
|
|
`SECURE_HSTS_PRELOAD` را فقط وقتی true کنید که تمام subdomainها همیشه HTTPS هستند. `DJANGO_ALLOWED_HOSTS` فقط hostname و `CSRF_TRUSTED_ORIGINS` origin کامل همراه `https://` است. برای چند مقدار از کاما استفاده کنید.
|
|
|
|
## 7. ترتیب اولین deploy
|
|
|
|
1. volumeها و database خالی را ایجاد کنید.
|
|
2. طبق `docs/PRODUCTION_SEED.md` سه فایل seed را داخل volume `customer_ticket_seed` قرار دهید.
|
|
3. Environment Variables را در Coolify وارد کنید.
|
|
4. Domain سرویس web و سه Domain مسیرمحور nginx را مطابق بخش ۲ وارد، `Strip Prefixes` را خاموش و Domain سرویس worker را خالی نگه دارید؛ سپس Compose را deploy کنید.
|
|
5. log سرویس `web` را برای موفقیت migrate، import production seed، collectstatic و Gunicorn بررسی کنید. وقتی seed موجود است، bootstrap عمداً اجرا نمیشود تا snapshot تغییر نکند.
|
|
6. log سرویس `notification-worker` را بررسی کنید؛ container باید Running بماند.
|
|
7. با superuser منتقلشده از SQLite وارد شوید و با گیرنده آزمایشی مجاز یک SMS و یک پیام Bale واقعی بفرستید.
|
|
|
|
Migration شماره `tickets.0016_scope_legacy_public_tickets_to_units` فقط یکبار اجرا میشود و تیکتهای قدیمی بدون واحد را از روی واحد قطعی دستهبندی به `owning_unit` و `assigned_unit` صحیح متصل میکند. این migration همراه deploy عادی و دستور `migrate` خودکار اجرا میشود و اجرای دستی SQL لازم نیست. برای کنترل نتیجه در Terminal سرویس `web`:
|
|
|
|
```bash
|
|
python manage.py showmigrations tickets
|
|
python manage.py shell -c "from apps.tickets.models import Ticket; print(Ticket.objects.filter(owning_unit__isnull=True).count(), Ticket.objects.filter(assigned_unit__isnull=True).count())"
|
|
```
|
|
|
|
هر دو عدد باید صفر باشند.
|
|
|
|
## 8. کنترل سلامت و عیبیابی
|
|
|
|
- `GET /healthz/` هم web server و هم اتصال دیتابیس را بررسی میکند.
|
|
- `docker compose ps` باید `web` و `nginx` را healthy و worker را Running نشان دهد.
|
|
- `docker compose logs --tail=200 web notification-worker nginx` خطاهای startup و provider را نمایش میدهد.
|
|
- داخل کانتینر Nginx، `nginx -T` باید `location = /healthz/` را نشان دهد و `wget -S -O - http://127.0.0.1/healthz/` باید پاسخ `200` برگرداند. پاسخ `404` در این تست یعنی image یا commit جدید deploy نشده است.
|
|
- وضعیت outbox را میتوان در Django admin یا shell از مدل `NotificationJob` بررسی کرد؛ FAILED و `last_error` دلیل خطا را نگه میدارند.
|
|
- برای پردازش کنترلشده یک job مشخص: `python manage.py process_notifications --once --job-id <ID>`.
|
|
|
|
ارسال اعلان ماهیت at-least-once دارد: اگر provider پیام را بپذیرد ولی process پیش از ثبت نتیجه در PostgreSQL قطع شود، retry ممکن است پیام تکراری بسازد. `dedupe_key` جلوی ساخت job تکراری در برنامه را میگیرد، اما تضمین exactly-once به پشتیبانی idempotency از طرف provider نیاز دارد.
|
|
|
|
## 9. Mirrorهای ایرانی
|
|
|
|
Dockerfile بهطور پیشفرض PyPI لیارا را از آدرس `https://package-mirror.liara.ir/repository/pypi/simple` و mirror Debian لیارا را استفاده میکند. اگر mirror Debian در محیط شما متفاوت است، build argument با نام `DEBIAN_MIRROR_HOST` را تغییر دهید. برای image مربوط به Nginx نیز میتوانید `NGINX_IMAGE` را به مسیر registry mirror مورد اعتماد خود تغییر دهید.
|