Ternyata Menjaga Server Tidak Selalu Soal Menambah Resource

504 gateway timeout wordpress cache nginx


Siang itu aku lagi setup tracking untuk sebuah domain WordPress pakai Google Tag Manager. Tahap yang paling sering bikin deg-degan: mode debug. Aku klik Preview, Tag Assistant membuka situsnya, lalu aku mulai mengecek tag satu per satu.

Masalahnya, halaman yang harusnya muncul dalam hitungan detik malah berputar-putar. Aku coba lagi. Muter lagi. Coba lagi dari awal, dan hasilnya sama: berkali-kali kena error ini:

504 Gateway Timeout

Debug mode yang gagal itu menyebalkan karena aku jadi nggak bisa memastikan satu hal pun. Tag-nya nggak muncul, tapi aku nggak tahu apakah konfigurasi GTM-nya salah atau servernya yang nggak sanggup menjawab. Selama situsnya sendiri nggak bisa dibuka, tracking sebagus apa pun nggak akan terbaca.

Reflek pertamaku: “Waduh, diserang nih.” Tapi kebiasaan lama kalau ngoprek server: jangan asal blok, jangan asal restart, lihat dulu buktinya. Jadinya sore itu berubah jadi sesi detektif-detektifan di terminal HP, lewat Termux, dari rumah.

Servernya Kecil, Tanggung Jawabnya Banyak

VPS yang lagi bermasalah ini cuma punya 1 vCPU dan RAM sekitar 2 GB. Isinya WordPress, WooCommerce untuk toko online, MariaDB, dan panel HestiaCP. Uptime-nya sudah 91 hari tanpa reboot, jadi servernya stabil. Kapasitasnya saja yang pas-pasan.

Petunjuk Pertama: Error Log yang Seragam

Aku mulai dari error log Apache. Isinya satu jenis baris yang diulang ratusan kali:

[proxy_fcgi:error] (70007)The timeout specified has expired: AH01075: Error dispatching request to : (polling)

Artinya, Apache sudah menitipkan request ke PHP-FPM, tapi nggak ada yang membalas sampai waktunya habis. Itulah yang muncul di browser sebagai 504. Apache dan nginx-nya sehat, yang kewalahan adalah “dapur” PHP.

Lalu aku buka htop, dan gambarnya langsung jelas:

CPU            100%
Load average   3.67 → 6.86 → 8.05   (dan masih naik)
php-fpm        8 proses, semuanya status R (running), masing-masing ~12,5% CPU
MariaDB        0,7% CPU
Swap           375 MB terpakai

Delapan worker PHP, satu core. 8 × 12,5% persis 100%. Mereka berebut CPU yang sama, jadi semua request melambat bareng-bareng. MariaDB nyaris nganggur, sehingga query database tidak jadi tersangka. Beban beratnya ada di eksekusi PHP.

Salah Nuduh, Tiga Kali

Bagian ini yang paling jujur, soalnya dugaanku meleset terus.

Tuduhan pertama: satu IP yang menembaki server. Di error log ada satu IP yang muncul belasan kali dalam 30 detik. Range-nya milik DigitalOcean, jadi kukira bot. Ternyata itu servernya sendiri: cron sistem yang memanggil wp-cron.php tiap 5 menit lewat Wget, plus Wordfence yang sinkronisasi ke dirinya sendiri. Untung belum sempat kublok. Bisa-bisa aku mengunci diri sendiri di luar, hehe.

Tuduhan kedua: bot crawler. TikTokSpider, PetalBot, dan GeedoShopProductFinder ramai di log. Setelah dicek, hampir semua request mereka menuju satu file: /favicon.ico (sekitar 2.000 hit).

Tuduhan ketiga: favicon sebagai biang kerok. Dugaan ini bertahan paling lama, sampai aku melihat sebaran per menitnya. Request favicon stabil di maksimal 4 per menit, tanpa lonjakan. Pemborosannya nyata, tapi grafik yang rata begitu tidak menjelaskan kenapa server tumbang.

Yang Akhirnya Menjawab: Pola Waktu

Total request di log sekitar 27 ribu dalam 2,5 hari, rata-rata 0,1 request per detik. Angka sekecil itu seharusnya nggak bikin server ngos-ngosan. Lalu aku lihat sebaran status code-nya:

200   18.335
504    1.761
503    1.602

Sekitar 12% request berujung 504 atau 503. Begitu kuurutkan per menit, semuanya mengelompok:

226 kali 504 dalam satu menit (06 Okt, 14:57)
~100 kali 504 per menit selama 04 Okt 17:56–18:02
108 kali 504 pada 04 Okt 01:33

Di luar menit-menit itu, server sehat. Trafiknya datang sebagai lonjakan sesaat, jadi persoalannya ada di kapasitas menghadapi lonjakan itu.

Pelakunya: Seorang Scraper

Burst pukul 14:54–14:58 hari itu datang dari satu IP. Dia mengirim 568 request dalam 4 menit, puncaknya 252 per menit, semuanya ke halaman produk /shop/, dengan hitungan seragam: tiap produk persis 10 kali. Pembeli sungguhan nggak belanja dengan pola begitu. Itu skrip. Hasil whois menunjukkan IP-nya milik layanan VPN. Burst tanggal 4 Oktober dini hari berpola sama, dari IP hosting luar negeri. Burst sore tanggal 4 punya cerita lain: tidak ada satu IP yang mendominasi, kemungkinan rombongan crawler.

Aku sempat bertanya-tanya: ini serangan atau bukan? Di log nggak ada tanda eksploitasi. Nggak ada brute force wp-login, nggak ada xmlrpc, nggak ada yang mengintip .env atau wp-config. Isinya GET halaman produk biasa dalam jumlah yang terlalu banyak untuk server sekecil ini. Niat mereka tidak terbaca dari log, sementara dampaknya mirip DoS kecil-kecilan.

Aku memutuskan nggak ngeblok. Alasannya sederhana: resource-nya memang segini, dan IP VPN gampang ganti. Lebih masuk akal membuat servernya tahan daripada menolak tamu satu per satu.

Solusi: Biar nginx yang Menerima Tamu

Akar masalahnya: tiap request ke halaman produk membangunkan WordPress penuh. Jadi aku butuh cache di depan PHP. HestiaCP sebenarnya punya template caching bawaan, tapi nggak kupakai karena dua hal:

  • daftar pengecualiannya menyebut /store, sementara tokoku memakai /shop
  • dia meng-cache hampir semua halaman dan hanya mengecualikan daftar tertentu. Untuk WooCommerce, pendekatan yang lebih aman adalah daftar putih: hanya halaman tertentu yang di-cache

Kalau salah di bagian ini, akibatnya fatal: pengunjung bisa melihat isi keranjang orang lain. Jadi aku membuat template sendiri, wc-shop-cache. Inti aturannya:

location ^~ /shop/ {
    ...
    set $skip_cache 0;
    if ($http_cookie ~* "wordpress_logged_in|woocommerce_items_in_cart|woocommerce_cart_hash|wp_woocommerce_session_|comment_author|wp-postpass") {
        set $skip_cache 1;
    }
    if ($args ~* "add-to-cart|wc-ajax|remove_item|_wpnonce") {
        set $skip_cache 1;
    }

    proxy_cache cache;
    proxy_cache_valid 200 5m;
    proxy_cache_lock on;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_bypass $skip_cache;
    proxy_no_cache $skip_cache;
    add_header X-Cache $upstream_cache_status;
}

Bahasa manusianya:

  • hanya /shop/ (daftar toko dan halaman produk) yang di-cache, selama 5 menit
  • pengunjung yang sudah login atau punya isi keranjang otomatis melewati cache
  • proxy_cache_lock: kalau 250 request menanyakan URL yang sama, cuma 1 yang diteruskan ke PHP
  • proxy_cache_use_stale: kalau backend sedang 504, pengunjung tetap dilayani salinan lama

Pelajaran Kecil: Jangan Paste Konfigurasi Panjang di Terminal HP

Pertama kali aku membuat file template-nya dengan cat > file <<'EOF' lalu paste. Hasilnya berantakan. Karakter tab di hasil paste dianggap autocomplete oleh terminal, file-nya terpotong separuh, dan di layar muncul baris sampah. Seandainya template itu nekat dipasang, nginx bisa gagal reload dan situs ikut mati.

Untung ada kebiasaan lama: verifikasi dulu sebelum pasang. Jumlah baris dicek pakai wc -l, ujung file pakai tail, baris-baris penting pakai grep. Ketahuan rusak, file dihapus, lalu ditulis ulang pakai nano dengan spasi sebagai indentasi. Setelah itu baru dipasang:

v-change-web-domain-proxy-tpl ekafarm ekafarm.com wc-shop-cache "$EXT"
nginx -t

Variabel $EXT itu daftar ekstensi proxy yang kusimpan sebelum mengganti template, supaya daftarnya tidak hilang. Rollback-nya cuma satu baris, tinggal kembali ke template default.

Tesnya: MISS, HIT, BYPASS

curl -s -o /dev/null -D - https://ekafarm.com/shop/ | grep -iE "^HTTP|x-cache"

Dijalankan tiga kali. Hasilnya persis seperti yang kuharapkan:

x-cache: MISS      (request pertama, diteruskan ke PHP)
x-cache: HIT       (request kedua, dilayani nginx dari cache)
x-cache: BYPASS    (dengan cookie keranjang, melewati cache)

Halaman produk satuan juga sama: MISS lalu HIT. Hasil yang paling penting ada di baris terakhir: request dengan cookie keranjang di-BYPASS, jadi isi keranjang aman.

Bonus: Favicon yang Membangunkan WordPress

Ada satu temuan sampingan. File favicon.ico ternyata tidak ada fisiknya di server. Tiap permintaan ikon diteruskan ke WordPress, yang boot penuh hanya untuk menjawab satu gambar kecil. Waktu kucoba lewat curl, balasannya content-type: text/html, padahal seharusnya gambar. Kalikan dengan ribuan request dari crawler, lumayan juga bebannya.

Solusinya nggak ribet. Ikon situsnya sudah ada di folder uploads, tinggal disalin:

cp .../uploads/2018/04/cropped-Icon-pabrik-vco-32x32.png .../public_html/favicon.ico

Dites lagi: sekarang 200, content-type: image/x-icon, ukurannya 1019 byte, persis sama dengan file sumber. Nginx melayaninya langsung dari disk dan WordPress tidak dibangunkan lagi. Isinya PNG yang dinamai .ico, dan browser modern tetap menampilkannya dengan benar.

Jadi, 504-nya Sudah Hilang?

Jujur, belum bisa kubilang begitu. Cache sudah terpasang dan lolos tes. Sampai artikel ini ditulis belum ada burst baru yang menguji. Hitungan 504 baru sejak pukul 16.00 hari itu memang nol, namun angka itu belum membuktikan apa-apa karena memang tidak ada lonjakan yang lewat.

Cache ini juga punya batas. Dia hanya menolong tamu yang membuka /shop/. Request ke wp-admin, admin-ajax.php, dan wp-cron.php tetap harus lewat PHP, dan memang tidak boleh di-cache. Halaman arsip dan tag juga belum ikut. Sebagian error log datang dari sana, jadi kalau lonjakan berikutnya berasal dari situ, 504 masih mungkin muncul.

Jumlah worker PHP kubiarkan di 8. Menurunkannya ke 3–4 masuk akal secara teori untuk satu core, dan opsi itu kusimpan untuk nanti. Prinsipnya satu perubahan dulu, lihat efeknya, baru lanjut.

Penutup

Segitu ceritanya. Nggak ada serangan dramatis dan nggak ada hacker. Yang ada server kecil kedatangan tamu yang terlalu rakus, plus beberapa kali aku salah nuduh sebelum polanya ketemu. Aku pulang membawa beberapa pelajaran: lihat pola waktu sebelum menuduh, jangan asal blok IP (salah satu “tersangka”-ku ternyata server sendiri), delapan worker di satu core menghasilkan antrean panjang, dan untuk WooCommerce, cache sedikit yang aman lebih berharga daripada cache banyak yang bocor.

Checklist yang sekarang kupakai kalau ketemu 504 lagi:

  1. htop: cek CPU, load average, dan berapa worker PHP yang berstatus R
  2. nproc: pastikan jumlah core, supaya tahu 8 worker itu kebanyakan atau pas
  3. Error log: cari upstream timed out atau AH01075
  4. Hitung 504 per menit pakai awk, lihat mengelompok atau merata
  5. Cek siapa yang masuk di menit burst, dengan jendela waktu dilebarkan karena 504 tercatat setelah timeout
  6. whois untuk IP mencurigakan, dan pastikan dulu itu bukan server sendiri
  7. Cache halaman publik di nginx, dengan bypass untuk cookie login dan keranjang
  8. Verifikasi file konfigurasi sebelum dipasang, dan siapkan rollback
  9. Tunggu burst berikutnya sebelum menyatakan masalahnya selesai

Tidak ada komentar