Настройка планировщика задач (Cron-like scheduler) для фоновых процессов
Системный cron — стандартный инструмент для периодических задач, но у него есть ограничения: сложность управления, нет истории выполнения, нет обработки ошибок, не работает в контейнерах без дополнительной настройки. Фреймворковые планировщики решают эти проблемы, добавляя управление через код.
Laravel Task Scheduler
Принцип работы: одна запись в системном cron вызывает планировщик каждую минуту, а он сам решает, какие задачи запустить:
# /etc/cron.d/laravel
* * * * * www-data php /var/www/artisan schedule:run >> /dev/null 2>&1
Все расписания определяются в routes/console.php (Laravel 9+) или app/Console/Kernel.php:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
// Артисан-команды
Schedule::command('reports:daily')->dailyAt('02:00');
Schedule::command('sitemap:generate')->hourly();
Schedule::command('cache:clear-expired')->everyFifteenMinutes();
// Диспатч Queue Job
Schedule::job(new CleanupOldUploadsJob())->weekly()->sundays()->at('03:00');
Schedule::job(new SyncExchangeRatesJob(), 'high')->everyThirtyMinutes();
// Произвольный код
Schedule::call(function () {
DB::table('sessions')->where('last_activity', '<', now()->subDays(30))->delete();
})->daily()->name('cleanup-sessions')->withoutOverlapping();
// Shell-команда
Schedule::exec('node scripts/process-queue.js')->everyFiveMinutes();
Важные модификаторы
withoutOverlapping() — не запускать задачу, если предыдущий запуск ещё не завершился. Критично для длительных задач:
Schedule::command('import:products')
->hourly()
->withoutOverlapping(10); // lock на 10 минут
runInBackground() — не ждать завершения команды перед следующей. Scheduler продолжает работу, пока задача выполняется в отдельном процессе:
Schedule::command('reports:generate')->daily()->runInBackground();
onOneServer() — при нескольких серверах выполнять задачу только на одном. Требует cache-драйвер с поддержкой атомарных блокировок (Redis, Memcached):
Schedule::command('newsletter:send')
->dailyAt('09:00')
->onOneServer()
->withoutOverlapping();
between() — ограничить временно́й диапазон:
Schedule::command('process:orders')
->everyMinute()
->between('08:00', '22:00'); // только в рабочие часы
when() / skip() — условный запуск:
Schedule::command('sync:users')
->hourly()
->skip(fn() => app()->isDownForMaintenance());
Хранение истории выполнения
По умолчанию Laravel не хранит историю задач. Добавляем через хук onSuccess/onFailure:
Schedule::command('reports:daily')
->dailyAt('02:00')
->before(function () {
ScheduleLog::create([
'command' => 'reports:daily',
'status' => 'started',
'started_at'=> now(),
]);
})
->onSuccess(function (\Illuminate\Foundation\Bus\PendingDispatch $pending) {
ScheduleLog::where('command', 'reports:daily')
->latest()
->first()
?->update(['status' => 'success', 'finished_at' => now()]);
})
->onFailure(function () {
ScheduleLog::where('command', 'reports:daily')
->latest()
->first()
?->update(['status' => 'failed', 'finished_at' => now()]);
Http::post(config('services.slack.webhooks.alerts'), [
'text' => ":x: Scheduled task `reports:daily` failed",
]);
});
Либо использовать пакет spatie/laravel-schedule-monitor, который делает это автоматически для всех задач и интегрируется с Oh Dear для внешнего мониторинга.
Мониторинг через Healthcheck URL
Паттерн heartbeat: при успешном выполнении задача пингует внешний сервис (Healthchecks.io, Better Uptime, Dead Man's Snitch). Если пинг не пришёл — сервис отправляет алерт:
Schedule::command('backup:run')
->daily()
->onSuccess(function () {
Http::get('https://hc-ping.com/' . config('services.healthchecks.backup_uuid'));
})
->onFailure(function () {
Http::get('https://hc-ping.com/' . config('services.healthchecks.backup_uuid') . '/fail');
});
Динамические расписания из базы данных
Расписания из конфига — это статика. Если нужно управлять расписаниями через интерфейс (например, у каждого клиента своё время отправки отчёта):
// routes/console.php
use App\Models\ScheduledTask;
ScheduledTask::where('is_active', true)->each(function (ScheduledTask $task) {
$event = Schedule::call(function () use ($task) {
dispatch(new DynamicScheduledJob($task->id));
})
->cron($task->cron_expression)
->name("dynamic-task-{$task->id}")
->withoutOverlapping();
if ($task->only_on_weekdays) {
$event->weekdays();
}
});
Таблица scheduled_tasks:
CREATE TABLE scheduled_tasks (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
cron_expression VARCHAR(100) NOT NULL, -- '0 9 * * 1-5'
job_class VARCHAR(500) NOT NULL,
payload JSONB,
is_active BOOLEAN DEFAULT true,
last_run_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
Node.js: node-cron / agenda
Для Node.js-сервисов — node-cron (простые задачи) или agenda (с персистентностью в MongoDB):
// node-cron
import cron from 'node-cron';
cron.schedule('0 */2 * * *', async () => {
console.log('Running every 2 hours');
await syncExchangeRates();
}, {
scheduled: true,
timezone: 'Europe/Kiev',
});
// agenda с MongoDB — история выполнения из коробки
import Agenda from 'agenda';
const agenda = new Agenda({ db: { address: process.env.MONGODB_URI } });
agenda.define('send daily digest', async (job) => {
await sendDailyDigest(job.attrs.data.userId);
});
await agenda.start();
await agenda.every('24 hours', 'send daily digest', { userId: 123 });
Supervisor для планировщика
В контейнерной среде (Docker) системный cron может быть недоступен или нежелателен. Альтернатива — запускать schedule:work (появился в Laravel 8):
php artisan schedule:work
Это процесс, который сам следит за расписанием без системного cron. В Dockerfile:
CMD ["php", "artisan", "schedule:work"]
Или в Supervisor рядом с queue worker:
[program:scheduler]
command=php /var/www/artisan schedule:work
autostart=true
autorestart=true
user=www-data
stdout_logfile=/var/log/scheduler.log
Сроки
Перевод существующих cron-задач на Laravel Scheduler, базовые модификаторы — 2–3 часа. Хранение истории, алертинг, healthcheck-интеграция — ещё 3–4 часа. Динамические расписания из БД — отдельно, 5–7 часов.







