Files
clinicpro/docs/ops-restarts.md
T

176 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Runbook — تشخیص «ری‌استارت» سرور: recycle عادی یا خرابی واقعی؟
این سند برای وقتی است که در لاگ‌های production (Coolify) خطوطی مثل `exited: worker-...` دیده می‌شود و به‌نظر می‌رسد «سرور مدام ری‌استارت می‌شود».
## خلاصه یک‌خطی
خروج ساعتیِ workerها با `exit status 0; expected` **طراحی‌شده و بی‌ضرر** است. خرابی واقعی یعنی exit غیرصفر، `FATAL`، یا قطع شدن پاسخ `/health`.
---
## ۱. چرا workerها هر ساعت restart می‌شوند؟
هر دو consumer پیام (async و scheduler) با این فلگ‌ها اجرا می‌شوند:
```
messenger:consume async --time-limit=3600 --memory-limit=128M
messenger:consume scheduler_default --time-limit=3600
```
- `--time-limit=3600` → worker بعد از ۱ ساعت **خودش با exit code 0 خارج می‌شود**.
- supervisord (یا `restart: unless-stopped` در compose) بلافاصله دوباره آن را بالا می‌آورد (زیر ۱ ثانیه).
- هدف: جلوگیری از نشت حافظه PHP در پروسه‌های طولانی و تازه‌کردن اتصال‌های کهنه به MariaDB/Redis. الگوی استاندارد و توصیه‌شده Symfony Messenger است.
به همین دلیل این لاگ **عادی** است و هیچ اقدامی لازم ندارد:
```
INFO exited: worker-scheduler (exit status 0; expected)
INFO spawned: 'worker-scheduler' with pid 1464
INFO success: worker-scheduler entered RUNNING state, process has stayed up for > than 1 seconds
```
کلیدواژه `expected` یعنی supervisor از قبل می‌دانست این exit برنامه‌ریزی‌شده است.
## ۲. نشانه‌های خرابی واقعی
اگر هر یک از این‌ها را دیدید، مشکل واقعی است:
| نشانه در لاگ | معنی |
|---|---|
| `exited: ... (exit status 1)` یا هر عدد غیرصفر | worker با خطا مرده |
| `entered FATAL state, too many start retries too quickly` | worker بالا نمی‌آید (مثلاً DB/Redis در دسترس نیست) |
| گپ چند دقیقه‌ای در لاگ‌های `GET /health ... 200` | وب‌سرور واقعاً پاسخ نمی‌داده |
| `GET /health` با status غیر از 200 | اپ boot نمی‌شود |
## ۳. دستورهای تشخیص روی سرور
سؤال اصلی: آیا **خود کانتینر** ری‌استارت شده یا فقط پروسه worker داخل آن recycle شده؟
```bash
# وضعیت و uptime کانتینرها — Status باید «Up X hours (healthy)» باشد
docker ps --format 'table {{.Names}}\t{{.Status}}'
# تعداد ری‌استارت واقعی و زمان آخرین start کانتینر
docker inspect --format '{{.RestartCount}} {{.State.StartedAt}}' <container-name>
# رویدادهای restart/die در ۲۴ ساعت گذشته
docker events --since 24h --filter event=restart --filter event=die
```
تفسیر:
- `RestartCount` = 0 و `StartedAt` قدیمی (مثلاً چند روز پیش) → **هیچ ری‌استارت واقعی‌ای رخ نداده**؛ فقط recycle داخلی worker است.
- `RestartCount` بالا یا `StartedAt` تازه بدون deploy → کانتینر واقعاً crash/restart می‌شود؛ لاگ‌های قبل از مرگ را ببینید: `docker logs --tail 200 <container>`.
## ۴. اطلاع‌رسانی خودکار در Coolify
برای اینکه down شدن واقعی بلافاصله خبر داده شود (به‌جای پایش دستی لاگ):
1. در Coolify → **Notifications**، یک کانال (Telegram / Email / Discord) فعال کنید.
2. رویدادهای «container stopped / unhealthy» و «deployment failed» را روشن کنید.
مستندات:
- <https://coolify.io/docs/knowledge-base/notifications>
- <https://coolify.io/docs/knowledge-base/health-checks>
## ۵. تمدید گواهی TLS (ACME) — مهم‌ترین ریسک down شدن واقعی
### علامت مشکل
در access log کانتینر app:
```
GET /.well-known/acme-challenge/xxxx HTTP/1.1" 404 ... "Let's Encrypt validation server"
```
چالش HTTP-01 لتس‌انکریپت باید توسط **Traefik** (پراکسی Coolify) پاسخ داده شود و اصلاً نباید به کانتینر app برسد. رسیدن آن به app و گرفتن 404 یعنی صدور/تمدید گواهی برای آن دامنه دارد **شکست می‌خورد**. اگر رها شود، بعد از انقضای گواهی فعلی، سایت با خطای TLS از دسترس خارج می‌شود — و این همان «سرور down شد» است.
### چک‌لیست رفع
1. در Coolify → resource اپ → تب **Domains**: دامنه `clinic-pro.ir``www.clinic-pro.ir` اگر DNS دارد) باید دقیقاً با `https://` روی سرویس `app` ثبت باشد. دامنه‌ای که DNS آن به سرور اشاره می‌کند ولی در Coolify ثبت نیست، دقیقاً همین الگوی 404 را می‌سازد.
2. تاریخ انقضای گواهی فعلی را چک کنید:
```bash
echo | openssl s_client -connect clinic-pro.ir:443 -servername clinic-pro.ir 2>/dev/null \
| openssl x509 -noout -dates
```
3. لاگ پراکسی را برای خطاهای acme ببینید: Coolify → Servers → Proxy → Logs (جستجوی `acme`).
مستندات: <https://coolify.io/docs/knowledge-base/proxy/traefik/overview>
## ۶. Healthcheckها — رفتار انتظاری
- **کانتینر app**: `docker/healthcheck.sh` مسیر واقعی `/health` را از داخل می‌زند. پارامترها در `Dockerfile` و `docker-compose.yml` یکسان‌اند: `interval=15s, timeout=5s, retries=5, start_period=60s`. یعنی برای unhealthy شدن باید ۵ بار پیاپی (~۷۵ ثانیه) fail شود.
- **کانتینرهای worker** (حالت compose): healthcheck فقط وجود پروسه `messenger:consume` را با `ps | grep` چک می‌کند (`interval=30s, retries=3`). در لحظه exit ساعتیِ worker ممکن است **یک** چک fail شود — بی‌اهمیت است؛ برای unhealthy شدن ۳ شکست پیاپی (~۹۰ ثانیه) لازم است و worker در همان چند ثانیه اول برمی‌گردد. unhealthy شدن worker فقط وقتی رخ می‌دهد که consumer واقعاً بالا نیاید (مثلاً Redis در دسترس نباشد).
## ۷. down شدن بعد از چند ساعت — کمبود حافظه (OOM)
الگو: workerها چند بار recycle عادی می‌شوند و بعد از چند ساعت کل سرویس down می‌شود. مظنون اول: پر شدن حافظه سرور و کشته شدن پروسه‌ها توسط OOM-killer.
### تأیید روی سرور — اسکریپت آماده
فایل [`docs/collect-diagnostics.sh`](collect-diagnostics.sh) را روی **هاست** کپی و اجرا کنید (همه شواهد را یک‌جا جمع می‌کند):
```bash
scp docs/collect-diagnostics.sh root@SERVER:/tmp/ && ssh root@SERVER sh /tmp/collect-diagnostics.sh
```
یا دستورهای کلیدی به‌صورت دستی:
```bash
# آیا کانتینر با OOM کشته شده؟
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.FinishedAt}}' <container>
# ردپای OOM-killer در کرنل (مهم‌ترین دستور)
journalctl -k --since "24 hours ago" | grep -i -E "oom|killed process"
# مصرف لحظه‌ای هر کانتینر
docker stats --no-stream
# حافظه و swap کل سرور
free -h
```
### جدول تفسیر خروجی اسکریپت
| شاهد در خروجی | تشخیص | اقدام |
|---|---|---|
| بخش ۱: `Out of memory: Killed process ... php-fpm` | نشت حافظه fpm | فیکس‌های `pm.max_requests`/`memory_limit` این repo + redeploy کافی است |
| بخش ۱: `Killed process ... mariadbd` یا `redis` | فشار کل RAM سرور | Redis `maxmemory` + Memory Limit در Coolify + swap |
| بخش ۴: `OOMKilled=true` | سقف حافظه خود کانتینر | سقف را بالاتر ببرید یا مصرف را کم کنید |
| بخش ۳: دیسک ≥ ۹۰٪ یا `*-json.log` چند GB | دیسک پر | log rotation در `daemon.json` + پاکسازی |
| بخش ۷: `maxmemory: 0` و `used_memory` بزرگ/رشد‌کننده | Redis بی‌سقف | `maxmemory 256mb` + `maxmemory-policy volatile-lru` |
| بخش ۶: `memory_limit` هنوز `1024M` | image قدیمی در حال اجراست | Redeploy نشده — دوباره deploy کنید |
| لاگ worker: `[critical] ... "socket error on read socket"` (Connection.php) | اتصال Redis وسط کار قطع شده — تقریباً همیشه یعنی خود Redis مرده/ری‌استارت شده (OOM) | بخش ۷ اسکریپت: `uptime_in_seconds` کوچک Redis = ری‌استارت شده؛ `maxmemory 256mb` + `volatile-lru` بگذارید و `docker logs <redis>` را ببینید |
| هیچ‌کدام | فرضیه حافظه/دیسک رد شد | خروجی کامل اسکریپت + ۵۰ خط آخر لاگ قبل از down را برای تحلیل بفرستید |
### چرا این اتفاق می‌افتاد (و فیکس اعمال‌شده)
- `memory_limit` هر request برابر **1024M** بود → به 256M کاهش یافت (`docker/php/php.ini`).
- pool فقط `clear_env=no` داشت؛ **`pm.max_requests` تنظیم نشده بود** → پروسه‌های php-fpm هرگز recycle نمی‌شدند و نشت‌های کوچک حافظه در طول ساعت‌ها جمع می‌شد. حالا `pm.max_requests=500` + سقف `pm.max_children=8` (`docker/php/zz-pool.conf`).
- workerهای messenger از قبل با `--time-limit=3600` و `--memory-limit=128M` محافظت می‌شدند — مشکل از آن‌ها نبود.
### اقدامات تکمیلی روی سرور (خارج از repo)
0. **اول Redeploy** — همه فیکس‌های این repo (php.ini، zz-pool.conf، supervisord.conf) فقط با build/deploy جدید اعمال می‌شوند؛ و در env واقعی Coolify به `MESSENGER_TRANSPORT_DSN` پارامتر `?stream_max_entries=20000` را اضافه کنید (نمونه در `.env.coolify.example`).
1. در Coolify برای resource اپ **Memory Limit** بگذارید (مثلاً 1G) تا در بدترین حالت فقط همان کانتینر ری‌استارت شود، نه کل سرور.
2. اگر سرور swap ندارد، 1-2G swap اضافه کنید: `fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile`.
3. اسکن‌های بات (درخواست‌های `GET /xxx.php → 404` پشت‌سرهم) توسط nginx مستقیم 404 می‌شوند و به Symfony نمی‌رسند — عامل مرگ نیستند، فقط نویز لاگ.
4. **Redis بدون سقف حافظه**: کش اپ (`cache.adapter.redis`) و صف messenger هر دو روی یک Redis resource هستند و Redis پیش‌فرض `maxmemory=0` (رشد بی‌نهایت) دارد — یکی از عوامل خورده شدن تدریجی RAM. روی Redis resource در Coolify ست کنید:
```
maxmemory 256mb
maxmemory-policy volatile-lru
```
⚠️ `allkeys-lru` ممنوع — پیام‌های صف messenger (بدون TTL) را حذف می‌کند؛ `volatile-lru` فقط کلیدهای کش (TTL‌دار) را evict می‌کند. در DSN صف هم `?stream_max_entries=20000` بگذارید (نمونه در `.env.coolify.example`).
5. **چرخش لاگ Docker**: اگر daemon محدودیت ندارد، فایل‌های `*-json.log` تا پر شدن دیسک رشد می‌کنند. چک: `df -h` و `du -sh /var/lib/docker/containers/*/*-json.log | sort -h | tail`. فیکس در `/etc/docker/daemon.json`:
```json
{ "log-driver": "json-file", "log-opts": { "max-size": "20m", "max-file": "3" } }
```
(بعدش `systemctl restart docker` — کانتینرها باید recreate شوند تا اعمال شود.)
6. **جدول `messenger_messages` (failed)**: پیام‌های شکست‌خورده (مثلاً SMS بعد از ۳ retry) برای همیشه در DB می‌مانند. هر چند وقت: `php bin/console messenger:failed:show` و پاکسازی با `messenger:failed:remove`.
## ۸. اعمال تغییرات config
فایل‌های `docker/supervisord.conf`، `docker-compose.yml` و `supervisor.conf` باید آینه هم بمانند (workerهای یکسان، فلگ‌های یکسان). هر تغییری در آن‌ها فقط بعد از **Redeploy در Coolify** روی سرور اثر می‌کند.