Menskalakan aplikasi Symfony: prinsip stateless, memindahkan session & cache ke Redis bersama, database read replicas dengan Doctrine, horizontal scaling di balik load balancer, serta load testing dengan k6 untuk menemukan bottleneck dan tuning.

Deploy berhasil di episode 23 — lalu traffic naik, dan server mulai mengeluh. Episode 24 adalah jawaban produksi untuk pertanyaan itu: scaling & high availability. Bagaimana satu instance menjadi banyak instance tanpa rusak.
Mengapa ini topik inti Symfony? Karena keunggulan arsitektur Symfony yang kita pelajari — stateless services, container, cache terpisah — justru dirancang untuk dijalankan banyak instance. Scaling bukan menambah server secara membabi buta; ia tentang memastikan setiap instance setara dan bisa dihapus kapan saja tanpa kehilangan state.
Hukum scaling horizontal: instance tidak boleh menyimpan state yang tidak bisa dibagi. Jika dua instance aplikasi berjalan, dan user dialihkan ke instance lain — sesi, cache, dan file upload harus tetap tersedia.
Yang harus dibagi antar instance:
| State | Tempat yang benar |
|---|---|
| Session | Redis (shared) |
| Cache (app) | Redis |
| Rate limiter counter | Redis (episode 20) |
| Upload | Object storage (S3) / shared volume |
| Messenger queue | Redis/AMQP (episode 10) |
Yang boleh lokal per instance: opcache, twig cache, log file (tapi lebih baik log terpusat — episode 15).
Default session tersimpan di file — per instance, sehingga pindah instance = kehilangan login. Pindahkan ke Redis:
framework:
session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler
cookie_secure: auto
cookie_httponly: true
cookie_samesite: lax
gc_maxlifetime: 7200
cache:
app: cache.adapter.redis
system: cache.adapter.systemcookie_secure: auto → hanya kirim cookie via HTTPS di production; cookie_httponly: true & cookie_samesite: lax adalah default yang aman (mencegah akses JS dan mitigasi CSRF tambahan).
Beban baca (SELECT) biasanya jauh melebihi tulis. PostgreSQL/MySQL mendukung read replicas; Doctrine bisa memisahkan baca ke replica:
DATABASE_URL=postgresql://app:pass@primary:5432/app
DATABASE_REPLICA_URL=postgresql://app:pass@replica:5432/appdoctrine:
dbal:
connections:
default:
url: '%env(DATABASE_URL)%'
replicas:
replica1:
url: '%env(DATABASE_REPLICA_URL)%'Doctrine otomatis mengarahkan SELECT ke replica dan INSERT/UPDATE/DELETE ke primary. Penting: data yang baru ditulis mungkin belum ada di replica (replication lag) — untuk kasus seperti "halaman setelah submit", paksa primary:
$this->getEntityManager()
->getConnection()
->executeStatement('SET TRANSACTION READ WRITE');
// atau pada kasus tertentu, gunakan TransactionalEntityManager:
$this->em->find(Article::class, $id, lockMode: null, options: ['replica' => false]);Aturan praktis: gunakan primary untuk alur yang langsung menyajikan data yang baru ditulis (misal redirect setelah POST), replica untuk agregasi/laporan yang toleran keterlambatan.
Dengan stateless yang rapi, scaling adalah operasi rutin:
upstream symfony_app {
least_conn;
server app1:8080;
server app2:8080;
server app3:8080;
}
server {
location / {
proxy_pass http://symfony_app;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://symfony_app/health;
}
}X-Forwarded-* penting: Symfony perlu tahu IP asli & protokol asli user (untuk logging & forced HTTPS). Header X-Forwarded-Proto juga berpengaruh ke cookie_secure di atas.
Jangan menebak — ukur. k6 adalah tool load testing yang praktis:
import http from 'k6/http';
import { check } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 50 },
{ duration: '1m', target: 200 },
{ duration: '30s', target: 0 },
],
};
export default function () {
const res = http.get('http://localhost:8080/articles');
check(res, {
'status 200': (r) => r.status === 200,
'p95 < 300ms': (r) => r.timings.duration < 300,
});
}k6 run script/load-test.jsSkenario bertahap (naik → peak → turun) mengungkap:
| Gejala | Kemungkinan bottleneck |
|---|---|
| p95 meledak saat concurrency naik | Database (query) atau opcache cold |
| Latency tinggi tapi CPU rendah | Wait pada I/O (DB, Redis, HTTP keluar) |
| CPU 100% per instance | Logika PHP berat / worker mode belum aktif |
| Timeout saat beban | Pool DB / koneksi habis |
Profil setiap temuan dengan profiler (episode 15): query N+1 (episode 21), cache yang belum dipasang (episode 14), atau model eksekusi (episode 22).
Saat pola sudah stabil, otomasikan penambahan instance berdasarkan metrik:
CPU usage per instance
Error rate (5xx)
p95 latency
Antrian Messenger (episode 10)Aturan autoscale yang sehat: gunakan metrik antrian & latensi, bukan hanya CPU — aplikasi bisa CPU rendah tapi antrian menumpuk (menunggu DB). Kombinasikan: scale berdasarkan p95 latency & antrian; boundary atas error rate.
Tip
Scaling vertikal (menambah RAM/CPU satu server) selalu lebih mahal dan ada batasnya; scaling horizontal (menambah instance) adalah tujuan arsitektur. Tapi jangan scaling horizontal sebelum memastikan bottleneck bukan database — menambah 10 aplikasi di depan 1 database yang lambat hanya memindahkan antrian.
X-Forwarded-* — IP & HTTPS salah terdeteksi di balik proxy.Pada episode 24 ini, kalian telah menskalakan aplikasi dengan benar.
Inti yang harus dibawa pulang:
X-Forwarded-* yang benar.Di episode 25 selanjutnya kita melihat peta besar: Ekosistem & Tren Modern 2026 — Symfony 8.1 & 7.4 LTS, API Platform, UX packages, integrasi FrankenPHP/RoadRunner, dan fitur PHP 8.4/8.5. Sampai jumpa di episode 25!