این یکی از آن باگ‌هایی است که آدم را دیوانه می‌کند، چون دستور را کپی می‌کنید، در ترمینال می‌زنید، بی‌عیب کار می‌کند — و همان دستور در کرون‌جاب هیچ کاری نمی‌کند.

روی سرور ما این ماجرا دو بار تکرار شد. اولی سر یک کرون آمار و دومی سر تولید نقشه‌ی سایت. هر دو بار همان علت.

علامت#

در ترمینال سی‌پنل این کار می‌کند:

Bash
php84 artisan schedule:run

همین را در کرون می‌گذارید:

Code
* * * * * cd /home/USER/app && php84 artisan schedule:run

و در ایمیل کرون یا لاگ این را می‌گیرید:

Code
/bin/sh: php84: command not found

علت: php84 یک باینری نیست#

نکته‌ی اصلی این‌جاست و برای خیلی‌ها غافلگیرکننده است: php84 روی سی‌پنل معمولاً یک برنامه در مسیر جستجو نیست، یک نام مستعار است.

ببینید:

Bash
type php84

اگر جوابی شبیه این گرفتید، ماجرا همین است:

Code
php84 is aliased to `/opt/cpanel/ea-php84/root/usr/bin/php'

نام‌های مستعار در فایل ~/.bashrc تعریف می‌شوند. و این فایل فقط در شل تعاملی خوانده می‌شود — یعنی وقتی شما پشت یک ترمینال نشسته‌اید.

کرون شل تعاملی نیست. کرون معمولاً دستور را با /bin/sh اجرا می‌کند، محیط تقریباً خالی است و ~/.bashrc هرگز خوانده نمی‌شود. پس آن نام مستعار در دنیای کرون اصلاً وجود ندارد.

همین منطق برای این‌ها هم صادق است:

  • توابع تعریف‌شده در ~/.bashrc
  • متغیرهای محیطی که در ~/.bash_profile ست می‌کنید
  • تغییرات PATH که در فایل‌های پروفایل نوشته‌اید
  • محیط nvm و مدیر نسخه‌ی مشابه برای Node

راه‌حل: مسیر مطلق#

هیچ ترفندی لازم نیست. فقط مسیر کامل باینری را بنویسید.

اول پیدایش کنید:

Bash
type php84
# یا
ls -d /opt/cpanel/ea-php*/root/usr/bin/php

بعد در کرون از همان استفاده کنید:

Code
* * * * * cd /home/USER/app && /opt/cpanel/ea-php84/root/usr/bin/php artisan schedule:run >> /dev/null 2>&1

تست کنید که واقعاً کار می‌کند، ولی نه در ترمینال معمولی — در محیطی شبیه کرون:

Bash
env -i /bin/sh -c '/opt/cpanel/ea-php84/root/usr/bin/php -v'

سوییچ -i محیط را خالی می‌کند، پس اگر این‌جا جواب داد در کرون هم جواب می‌دهد. این یک تست واقعی است، برخلاف زدن همان دستور در ترمینال که چیزی را ثابت نمی‌کند.

دام دوم: هیچ خروجی‌ای نمی‌بینید#

الگوی رایج کرون این است:

Code
>> /dev/null 2>&1

که یعنی «هم خروجی معمولی و هم خطا را دور بریز». برای کرونی که درست کار می‌کند مناسب است، ولی موقع عیب‌یابی فاجعه است: اگر کرون شکست بخورد هیچ ردی نمی‌ماند.

موقع راه‌اندازی، خروجی را به یک فایل بفرستید:

Code
* * * * * cd /home/USER/app && /opt/cpanel/ea-php84/root/usr/bin/php artisan schedule:run >> /home/USER/cron.log 2>&1

و بعد ببینیدش:

Bash
tail -50 /home/USER/cron.log

نکته‌ی ظریف: 2>&1 باید بعد از هدایت خروجی اصلی بیاید. اگر جایشان را عوض کنید، خطاها به مقصد قبلی می‌روند و در فایل نمی‌نشینند.

وقتی مطمئن شدید همه چیز درست است، فایل لاگ را حذف کنید یا چرخش برایش بگذارید؛ وگرنه یک کرون دقیقه‌ای در چند ماه فایل بزرگی می‌سازد.

دام سوم: پوشه‌ی جاری#

کرون از پوشه‌ی خانه شروع می‌کند، نه از ریشه‌ی پروژه. اگر cd را جا بیندازید، لاراول فایل .env را پیدا نمی‌کند و خطای پیکربندی می‌گیرید که ربطش به کرون اصلاً معلوم نیست.

دو راه:

Code
# راه اول: تغییر پوشه
* * * * * cd /home/USER/app && /opt/.../php artisan schedule:run

# راه دوم: مسیر کامل فایل artisan
* * * * * /opt/.../php /home/USER/app/artisan schedule:run

راه دوم تمیزتر است چون به شل وابسته نیست.

دام چهارم: نسخه‌ی PHP کرون با وب یکی نیست#

سی‌پنل بخش «Cron Jobs» دارد که ممکن است دستور را با مفسر پیش‌فرض سیستم اجرا کند — همان نسخه‌ی قدیمی. اگر پروژه‌تان روی نسخه‌ی جدید نوشته شده، کرون با خطای نحوی می‌شکند در حالی که سایت سالم کار می‌کند.

این یکی از نشانه‌های همان مشکل بزرگ‌تری است که در چک‌لیست هاست اشتراکی توضیح دادیم: روی سی‌پنل، نسخه‌ی وب و نسخه‌ی خط فرمان دو چیز جدا هستند.

زمان‌بند لاراول و فاصله‌ی مجاز کرون#

لاراول طوری طراحی شده که یک کرون هر دقیقه‌ای داشته باشید و بقیه‌ی زمان‌بندی داخل کد انجام شود:

PHP
Schedule::command('stats:rollup')->dailyAt('03:10');
Schedule::command('sitemap:build')->dailyAt('04:00');

اگر هاست اجازه‌ی کرون دقیقه‌ای نمی‌دهد و حداقل را پنج دقیقه گذاشته، باید بدانید چه اتفاقی می‌افتد: کارهایی که دقیقاً روی دقیقه‌ای تنظیم شده‌اند که کرون در آن اجرا نمی‌شود، هیچ‌وقت اجرا نمی‌شوند. کار روزانه‌ی ساعت ۳:۱۰ فقط وقتی اجرا می‌شود که کرون دقیقاً در ۳:۱۰ بیدار شود.

دو راه‌حل عملی:

  • زمان کارها را با فاصله‌ی کرون هماهنگ کنید (مثلاً همه روی مضارب پنج).
  • یا از ->everyFiveMinutes() و بازه‌های بزرگ‌تر استفاده کنید که با هر بیدارشدن همخوانی دارند.

دام پنجم: علامت درصد در کرون#

این یکی از آن چیزهایی است که فقط یک بار به آن می‌خورید و برای همیشه یادتان می‌ماند.

در فایل کرون، علامت % معنای خاص دارد: کرون آن را به خط جدید تبدیل می‌کند و بقیه‌ی خط را به‌عنوان ورودی استاندارد به دستور می‌دهد. یعنی این خط:

Code
0 3 * * * mysqldump -u USER -pPASS DB | gzip > /home/USER/backup-$(date +%F).sql.gz

در عمل به این تبدیل می‌شود که دستور در + تمام می‌شود و بقیه‌اش اصلاً اجرا نمی‌شود. نتیجه یک فایل بک‌آپ با نام عجیب یا یک شکست بی‌صداست.

راه‌حل: هر % را خنثی کنید.

Code
0 3 * * * mysqldump -u USER -pPASS DB | gzip > /home/USER/backup-$(date +\%F).sql.gz

یا بهتر از آن، دستور را در یک اسکریپت بگذارید و کرون فقط اسکریپت را صدا بزند. آن‌وقت هیچ کاراکتری تفسیر نمی‌شود:

Code
0 3 * * * /bin/bash /home/USER/scripts/backup.sh

این الگو را برای هر کرونی که بیش از یک دستور دارد توصیه می‌کنیم. عیب‌یابی اسکریپت بی‌نهایت ساده‌تر از عیب‌یابی یک خط طولانی در فرم کرون سی‌پنل است.

دام ششم: اجرای هم‌زمان#

اگر کاری بیشتر از فاصله‌ی کرون طول بکشد، نسخه‌ی بعدی شروع می‌شود در حالی که قبلی هنوز تمام نشده. برای کاری که در دیتابیس می‌نویسد، این می‌تواند داده‌ی تکراری بسازد.

زمان‌بند لاراول برای همین withoutOverlapping دارد:

PHP
Schedule::command('stats:rollup')->hourly()->withoutOverlapping();

و اگر اسکریپت شل خودتان را دارید، از قفل فایل استفاده کنید:

Bash
#!/bin/bash
exec 9>/home/USER/locks/backup.lock
flock -n 9 || exit 0

# ادامه‌ی کار

سوییچ -n یعنی «اگر قفل گرفته است، صبر نکن و خارج شو». دقیقاً همان چیزی که برای کرون می‌خواهید.

متغیرهای محیطی در کرون#

اگر اسکریپتتان به متغیر محیطی نیاز دارد، کرون آن را ندارد. سه راه:

۱. در همان خط کرون تعریفش کنید:

Code
0 3 * * * APP_ENV=production /opt/.../php /home/USER/app/artisan backup:run

۲. در بالای فایل کرون بگذارید (روی همه‌ی خطوط اثر می‌گذارد):

Code
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=you@example.com

مورد دوم مفید است: با تعریف MAILTO، خروجی هر کرون به ایمیل شما می‌رود. برای دوره‌ی راه‌اندازی عالی است، ولی بعدش حذفش کنید وگرنه یک کرون دقیقه‌ای صندوق شما را پر می‌کند.

۳. اسکریپت خودش فایل محیط را بخواند — که تمیزترین راه است، ولی حواستان به مشکل پایان‌خط باشد.

چک‌لیست عیب‌یابی کرون#

وقتی کرونی اجرا نمی‌شود، به همین ترتیب پیش بروید:

  1. آیا کرون اصلاً اجرا می‌شود؟ یک خط ساده بگذارید که فقط تاریخ را در فایلی بنویسد و پنج دقیقه صبر کنید.
  2. آیا مفسر پیدا می‌شود؟ با env -i تست کنید.
  3. آیا پوشه‌ی جاری درست است؟ مسیر مطلق artisan را بدهید.
  4. آیا خروجی را دور نریخته‌اید؟ موقتاً به فایل هدایتش کنید.
  5. آیا خطا در لاگ خود لاراول هست؟ فایل‌های storage/logs را ببینید.

مورد اول را جدی بگیرید. چند بار دیده‌ایم که تمام وقت صرف عیب‌یابی دستور شده در حالی که کرون اصلاً ثبت نشده بود.

الگویی که در نهایت انتخاب کردیم#

بعد از دو بار خوردن به همین مشکل، کرون‌ها را این‌طور سازمان دادیم:

یک اسکریپت واحد در ~/scripts/cron.sh که همه چیز را داخل خودش دارد:

Bash
#!/bin/bash
set -euo pipefail

PHP=/opt/cpanel/ea-php84/root/usr/bin/php
APP=/home/USER/app
LOG=/home/USER/logs/cron.log

cd "$APP"

echo "[$(date -Is)] شروع" >> "$LOG"
"$PHP" artisan schedule:run >> "$LOG" 2>&1
echo "[$(date -Is)] پایان" >> "$LOG"

و در فرم کرون سی‌پنل فقط یک خط:

Code
* * * * * /bin/bash /home/USER/scripts/cron.sh

مزیت‌هایش:

  • مسیر مفسر یک جا تعریف شده. ارتقای نسخه‌ی PHP یعنی تغییر یک خط، نه گشتن در فرم کرون.
  • خط set -euo pipefail باعث می‌شود اسکریپت روی اولین خطا متوقف شود به‌جای اینکه بی‌صدا ادامه دهد.
  • زمان شروع و پایان ثبت می‌شود، پس می‌فهمید کرون اصلاً اجرا شده یا نه — که سؤال اول هر عیب‌یابی است.
  • هیچ کاراکتری در فرم کرون تفسیر نمی‌شود.

فقط یادتان باشد اسکریپت باید مجوز اجرا داشته باشد و پایان‌خط یونیکسی. اگر روی ویندوز نوشتید و آپلود کردید، حتماً این نکته را ببینید.

Bash
chmod +x ~/scripts/cron.sh

چرخش لاگ، چون کسی یادش نمی‌ماند#

یک کرون دقیقه‌ای که دو خط در لاگ می‌نویسد، در ماه حدود نود هزار خط تولید می‌کند. اگر رهایش کنید، بالاخره فضای حساب را می‌خورد.

ساده‌ترین راه بدون نیاز به ابزار خاص، یک کرون هفتگی است که فایل را کوتاه می‌کند:

Code
0 4 * * 0 tail -n 5000 /home/USER/logs/cron.log > /home/USER/logs/cron.tmp && mv /home/USER/logs/cron.tmp /home/USER/logs/cron.log

پنج هزار خط آخر برای عیب‌یابی همیشه کافی است و فایل هیچ‌وقت از چند مگابایت بزرگ‌تر نمی‌شود.

قاعده‌ای که برای همیشه نگه می‌داریم: در کرون‌جاب و هر اسکریپت خودکار، همیشه مسیر مطلق باینری — نه نام مستعار، نه اتکا به مسیر جستجو.