Queue, Scheduler, dan Horizon di VPS: Job Tidak Boleh Mati Diam-Diam

 


Gejalanya selalu mirip: pengguna mendaftar, halaman menampilkan pesan sukses, tapi email verifikasi tidak pernah sampai. Atau laporan harian yang biasanya terkirim pukul tujuh pagi, suatu hari tidak ada kabarnya sama sekali — dan tidak ada yang memberi tahu Anda. Masalahnya jarang terletak pada kode pengiriman email. Seringkali, worker queue yang seharusnya memproses job itu sudah berhenti berhari-hari, tanpa satu pun alarm yang berbunyi.

Ini artikel keempat seri Deploy Laravel ke VPS. Artikel sebelumnya membahas urutan deploy tanpa downtime, termasuk perintah queue:restart sesudah go live. Sekarang kita mengurus sisi yang membuat job tetap berjalan di antara dua deploy: worker yang dijaga process monitor, scheduler yang dipicu cron, dan — bila Anda memakai Redis — Horizon sebagai panel pantau.

Tiga komponen, tiga tanggung jawab berbeda

Banyak kebingungan berawal dari mencampuradukkan tiga hal ini. Mari pisahkan dengan tegas:

  • Queue worker (queue:work) adalah proses yang menunggu job masuk antrean, lalu mengerjakannya satu per satu. Tanpa worker yang hidup, job hanya menumpuk di tabel atau Redis.
  • Scheduler (schedule:run) adalah pemicu. Cron sistem operasi memanggilnya tiap menit, ia memeriksa jadwal yang Anda tulis di routes/console.php, dan menjalankan yang sudah waktunya — atau melempar job ke antrean.
  • Horizon adalah lapisan manajemen di atas queue Redis: dashboard, penyeimbang worker otomatis, dan metrik. Horizon bukan pengganti Supervisor; Horizon tetap butuh Supervisor untuk menjaganya tetap hidup.

Analogi sederhananya: scheduler adalah alarm yang berbunyi tiap menit, worker adalah orang yang bangun dan bekerja, dan Horizon adalah papan tulis tempat Anda melihat siapa sedang mengerjakan apa.

Prasyarat dan pilihan driver antrean

Sebelum menyentuh Supervisor, putuskan driver antrean di shared/.env:

QUEUE_CONNECTION=database

Driver database adalah titik awal paling gampang: tidak butuh layanan tambahan, job tersimpan di tabel jobs, dan tabel gagal di failed_jobs. Buat tabelnya dengan:

php artisan queue:table
php artisan queue:failed-table
php artisan migrate --force

Perintah queue:table dan queue:failed-table menghasilkan file migrasi untuk kedua tabel tersebut; perintah migrate kemudian membuatnya di database. Periksa bahwa tabel jobs serta failed_jobs muncul di database.

Bila trafik sudah lebih besar atau Anda ingin memakai Horizon, pilih Redis:

QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

Redis lebih cepat dan tidak membebani database dengan polling, tapi menambah satu layanan yang harus dijaga tetap hidup.

Satu jebakan awal: mengubah QUEUE_CONNECTION di .env tanpa membangun ulang cache konfigurasi tidak berpengaruh bila config:cache aktif. Setelah mengubah .env, jalankan php artisan config:cache di folder rilis aktif. Dan pastikan nilai ini konsisten antara web dan worker: worker yang membaca konfigurasi lama akan memakai driver lama.

Langkah 1: Pasang Supervisor dan buat konfigurasi worker

Supervisor adalah process monitor yang menjaga proses tetap hidup: ia menjalankan worker saat boot, me-restart saat crash, dan mencatat lognya.

sudo apt update
sudo apt install supervisor -y

Kemudian buat file konfigurasi worker:

sudo nano /etc/supervisor/conf.d/laravel-worker.conf

Isinya:

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/laravel-app/current/artisan queue:work database --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/laravel-app/shared/storage/logs/worker.log
stopwaitsecs=3600

Mari bedah baris yang paling menentukan. command menunjuk ke current/artisan — bukan ke folder rilis tertentu — supaya sesudah deploy, worker baru yang di-restart otomatis memakai kode terbaru. --sleep=3 memberi jeda tiga detik antar polling saat antrean kosong, --tries=3 membatasi percobaan ulang sebuah job sampai tiga kali sebelum dipindah ke tabel gagal, dan --max-time=3600 membuat worker berhenti sendiri setelah satu jam agar memori yang menumpuk dibersihkan oleh proses baru. numprocs=2 menjalankan dua worker paralel; sesuaikan dengan jumlah CPU dan beban job Anda, bukan sekadar meniru contoh. stopwaitsecs=3600 memberi job panjang waktu menyelesaikan pekerjaannya sebelum Supervisor mematikan proses secara paksa.

Aktifkan konfigurasi:

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*

Hasil yang diharapkan: reread melaporkan grup laravel-worker tersedia, update menambahkannya, dan perintah start tidak mengembalikan error. Periksa statusnya:

sudo supervisorctl status laravel-worker:*

Output yang sehat menampilkan kedua proses dalam keadaan RUNNING dengan uptime yang bertambah. Bila statusnya FATAL atau BACKOFF, baca log di shared/storage/logs/worker.log sebelum mengubah apa pun — pesan error pertama di log hampir selalu lebih jujur daripada tebakan.

Kesalahan umum: worker berjalan sebagai user yang salah sehingga tidak bisa menulis log atau membaca file aplikasi. Samakan user di konfigurasi Supervisor dengan pemilik file aplikasi dan user PHP-FPM — dalam contoh ini www-data. Kesalahan kedua: jalur artisan menunjuk ke folder rilis lama yang sudah dihapus saat pembersihan rilis. Itulah alasan memakai current/artisan: symlink selalu mengarah ke rilis aktif.


Langkah 2: Buktikan worker benar-benar memproses job

Worker yang RUNNING belum tentu memproses job. Buktikan dengan job sungguhan:

php artisan make:job SendWelcomeEmail

Lalu kirim job itu dari tinker:

php artisan tinker
dispatch(new App\Jobs\SendWelcomeEmail());

Periksa tiga tempat:

php artisan queue:monitor database:default
sudo supervisorctl status laravel-worker:*
tail -n 50 /var/www/laravel-app/shared/storage/logs/worker.log

Hasil yang diharapkan: job hilang dari tabel jobs, log tidak menunjukkan exception, dan worker tetap RUNNING. Bila job menumpuk, cek tiga hal: koneksi antrean yang dipakai worker, nama antrean yang didengarkan, dan cache konfigurasi yang belum dibangun ulang.

Job yang gagal masuk ke tabel gagal. Lihat daftarnya dan coba ulang satu job:

php artisan queue:failed
php artisan queue:retry <id-job>

Langkah 3: Scheduler — satu baris cron untuk semua jadwal

Tulis jadwal di routes/console.php, bukan di crontab satu per satu:

use Illuminate\Support\Facades\Schedule;

Schedule::command('invoices:send-reminders')->dailyAt('07:00');
Schedule::command('reports:daily-summary')->daily()->withoutOverlapping();
Schedule::job(new CleanupOldUploads)->weekly();

withoutOverlapping() mencegah tugas yang sama berjalan tumpang tindih bila eksekusi sebelumnya belum selesai, dan Schedule::job(...) cocok untuk tugas berat yang lebih baik dijalankan lewat antrean.

Lalu tambahkan satu baris cron ini, lewat crontab -e:

* * * * * cd /var/www/laravel-app/current && php artisan schedule:run >> /dev/null 2>&1

Lima bintang berarti tiap menit. Bagian cd penting: scheduler harus berjalan dari folder rilis aktif supaya memakai kode terbaru. Pengalihan ke /dev/null mencegah cron mengirim email setiap menit.

Verifikasi jadwalnya:

php artisan schedule:list
php artisan schedule:test

Kesalahan umum: baris cron dipasang di crontab root, bukan di crontab user pemilik aplikasi, sehingga file yang ditulisnya dimiliki root. Dan ingat: jadwal baru hanya berlaku setelah rilis baru go live, karena scheduler membaca routes/console.php dari rilis aktif.

Langkah 4: Log, restart otomatis, dan cara cek worker masih hidup

Gunakan tiga lapisan pemeriksaan. Lapisan 1, status dan antrean:

sudo supervisorctl status
php artisan queue:monitor database:default

Lapisan 2, log Laravel dan log worker:

tail -n 100 /var/www/laravel-app/shared/storage/logs/laravel.log
tail -n 100 /var/www/laravel-app/shared/storage/logs/worker.log

Log yang diam total adalah sinyal, bukan ketenangan: worker yang sehat menulis sesuatu secara berkala, entah itu job yang diproses atau polling yang kosong.

Lapisan 3, restart sesudah deploy. Jangan me-restart Supervisor secara paksa kecuali darurat; pakai perintah yang dirancang Laravel:

cd /var/www/laravel-app/current
php artisan queue:restart

Urutan yang benar sesudah mengubah konfigurasi: ubah shared/.env, jalankan php artisan config:cache di folder current, lalu queue:restart supaya worker baru membaca konfigurasi baru.

Langkah 5: Horizon — opsional, tapi mengubah cara Anda melihat antrean

Horizon hanya berlaku bila QUEUE_CONNECTION=redis. Pasang dan migrasikan:

composer require laravel/horizon
php artisan horizon:install
php artisan migrate --force

Atur supervisor Horizon di config/horizon.php, bagian environments untuk production dan supervisor-1:

'connection' => 'redis',
'queue' => ['default', 'notifications'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 10,
'tries' => 3,

Strategi balance menentukan cara worker dibagi: auto menyesuaikan jumlah worker per antrean berdasarkan beban, simple membagi rata, dan false mematikan penyeimbangan otomatis.

Buat file Supervisor untuk Horizon:

sudo nano /etc/supervisor/conf.d/horizon.conf
[program:horizon]
command=php /var/www/laravel-app/current/artisan horizon
user=www-data
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/www/laravel-app/shared/storage/logs/horizon.log
stopwaitsecs=3600
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start horizon

Dashboard Horizon ada di /horizon. Kunci dengan gate di provider Horizon dan jangan pernah membiarkannya terbuka untuk publik — dashboard itu memperlihatkan isi job Anda.

Sesudah setiap deploy, jalankan:

php artisan horizon:terminate

Perintah ini menghentikan proses Horizon lama supaya Supervisor menjalankannya lagi dengan kode terbaru, sama seperti queue:restart untuk worker biasa.

Kapan tidak memakai Horizon

Horizon menambah Redis, satu proses Supervisor, dan satu dashboard yang perlu diamankan. Bila Anda hanya memproses puluhan job per hari lewat driver database dan 1–2 worker sudah cukup, Horizon adalah beban tambahan, bukan kebutuhan. Mulailah dari Supervisor + cron, ukur lewat queue:monitor dan log, dan pindah ke Horizon saat Anda benar-benar butuh visibilitas real-time atau penyeimbangan worker otomatis.

Tabel ringkasan

KomponenKonfigurasiPerintah cekTanda sehat
Queue workerSupervisor (laravel-worker.conf)supervisorctl status laravel-worker:*RUNNING, uptime bertambah
Job yang diprosesWorker + tabel jobsqueue:monitor, queue:failedJob hilang dari antrean, gagal nol atau tertangani
SchedulerCron per menit → schedule:runschedule:listTugas muncul dengan waktu jalan berikutnya
Restart sesudah deployqueue:restart / horizon:terminateLog worker / HorizonWorker baru memakai kode terbaru
Horizon (opsional)Supervisor (horizon.conf)Dashboard /horizon + supervisorctl status horizonSupervisor Horizon aktif, metrik bergerak

Checklist akhir

  • QUEUE_CONNECTION sama antara web dan worker, dan cache dibangun ulang.
  • Tabel jobs dan failed_jobs ada (driver database) atau Redis hidup (driver Redis).
  • Supervisor terpasang, laravel-worker:* berstatus RUNNING.
  • Satu job percobaan diproses sampai habis.
  • Baris cron schedule:run per menit ada di crontab user pemilik aplikasi.
  • schedule:list menampilkan semua tugas dengan waktu berikutnya yang masuk akal.
  • withoutOverlapping() dipakai untuk tugas yang durasinya tidak menentu.
  • queue:restart masuk ke skrip deploy.
  • Log worker dan log Laravel dibaca berkala.
  • Bila memakai Horizon: horizon.conf aktif, dashboard terkunci, dan horizon:terminate masuk ke skrip deploy.

Job yang mati diam-diam adalah bug yang paling lama bertahan, justru karena tidak ada halaman error — hanya ketiadaan. Supervisor, satu baris cron, dan kebiasaan memeriksa status adalah penawarnya.

Pernah mengalami job berhenti tanpa kabar? Apa yang pertama kali Anda periksa — log worker, tabel jobs, atau status Supervisor? Ceritakan di komentar.

Baca juga: artikel seri 03 tentang deploy Laravel tanpa downtime tempat perintah queue:restart dan horizon:terminate masuk ke urutan go live, panduan deploy otomatis ke VPS dengan GitHub Actions untuk mengotomatiskan pemeriksaan ini tiap rilis, dan panduan membaca log server dengan journalctl dan logrotate untuk memeriksa keluaran tugas terjadwal.

Komentar