Kita sudah mentok mengoptimasi satu proses: caching (Bagian 4), database (Bagian 5), dan concurrency di dalam satu proses (Bagian 6). Semua itu penting, tapi ada batas keras yang tidak bisa dilewati hanya dengan tuning — satu proses cuma bisa memakai sejumlah CPU core dan sejumlah thread tertentu. Sekarang kita keluar dari batasan itu sepenuhnya: menjalankan beberapa instance task-tracker-api sekaligus, di belakang load balancer.
Containerize Aplikasi dengan Docker
Langkah pertama, bungkus aplikasi jadi Docker image supaya bisa dijalankan berkali-kali secara identik:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Tidak ada yang eksotis di sini — install dependency, copy kode, jalankan uvicorn. Yang penting: image ini TIDAK mengandung state apa pun (tidak ada file database di dalamnya) — persis prinsip stateless yang dibahas di Bagian 2. Semua state hidup di luar container: Postgres dan Redis.
Docker Compose — Menjalankan 3 Instance + Load Balancer Sekaligus
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: tasks
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
api1: &api
build: .
environment:
DATABASE_URL: postgresql+psycopg2://postgres:postgres@postgres:5432/tasks
REDIS_URL: redis://redis:6379/0
INSTANCE_ID: api-1
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
api2:
<<: *api
environment:
DATABASE_URL: postgresql+psycopg2://postgres:postgres@postgres:5432/tasks
REDIS_URL: redis://redis:6379/0
INSTANCE_ID: api-2
api3:
<<: *api
environment:
DATABASE_URL: postgresql+psycopg2://postgres:postgres@postgres:5432/tasks
REDIS_URL: redis://redis:6379/0
INSTANCE_ID: api-3
nginx:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./docker/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on: [api1, api2, api3]
Beberapa poin penting:
&api dan <<: *api — fitur YAML anchor, supaya kita tidak menulis ulang konfigurasi yang sama tiga kali. api2 dan api3 mewarisi konfigurasi api1, cuma beda INSTANCE_ID.DATABASE_URL dan REDIS_URL yang SAMA — inilah yang membuat mereka benar-benar bisa dianggap satu sistem: apa pun instance yang menangani sebuah request, semua melihat data yang sama, karena caching dan database-nya dibagi bersama (persis konsep di Bagian 2).INSTANCE_ID yang beda — cuma untuk keperluan observability, supaya kita bisa lihat instance mana yang menjawab request (dipakai di header response dan metrics, dibahas lebih lanjut di Bagian 8).nginx yang punya port 8080 terbuka. Semua trafik WAJIB lewat load balancer, tidak ada jalan pintas langsung ke satu instance tertentu.Nginx sebagai Load Balancer
http {
upstream task_tracker_api {
server api1:8000;
server api2:8000;
server api3:8000;
}
server {
listen 80;
location / {
proxy_pass http://task_tracker_api;
}
}
}
Blok upstream mendaftarkan tiga instance sebagai satu grup. Default Nginx: round-robin — request pertama ke api1, kedua ke api2, ketiga ke api3, lalu berulang. Tidak perlu konfigurasi tambahan untuk strategi paling dasar ini.
Menjalankan dan Membuktikan Round-Robin
docker compose up --build -d
Endpoint /health di task-tracker-api sengaja mengembalikan instance_id, supaya kita bisa lihat siapa yang menjawab:
for i in 1 2 3 4 5 6; do curl -s http://localhost:8080/health; echo; done
{"status":"ok","instance_id":"api-1"}
{"status":"ok","instance_id":"api-2"}
{"status":"ok","instance_id":"api-3"}
{"status":"ok","instance_id":"api-1"}
{"status":"ok","instance_id":"api-2"}
{"status":"ok","instance_id":"api-3"}
Persis seperti dijanjikan — bergiliran sempurna. Sekarang bagian yang lebih penting: buktikan data tetap konsisten walau dijawab instance berbeda-beda:
curl -X POST localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"Task dari load balancer"}'
curl -X PATCH localhost:8080/api/tasks/1 -H "Content-Type: application/json" -d '{"done":true}'
for i in 1 2 3; do curl -s localhost:8080/api/tasks/1; echo; done
Ketiga request terakhir — yang bisa saja dijawab tiga instance berbeda — semuanya menunjukkan "done":true. Ini membuktikan cache invalidation yang kita bangun di Bagian 4 bekerja LINTAS instance, karena Redis-nya dibagi bersama — begitu satu instance meng-update task dan menghapus cache-nya, SEMUA instance langsung melihat data segar, bukan cuma instance yang melakukan update.
Mengukur Dampaknya — dan Pelajaran Tentang Alat Ukur Itu Sendiri
Sekarang saatnya membuktikan dengan angka. Percobaan pertama kita jalankan seperti biasa: load_test.py dengan concurrency 100, membandingkan satu instance (akses langsung, bypass load balancer) vs tiga instance (lewat Nginx). Hasilnya, sekilas, mengejutkan — hampir tidak ada beda sama sekali, keduanya sama-sama menunjukkan p95 sekitar 5 detik.
Sebelum menyimpulkan "horizontal scaling tidak membantu", ingat prinsip dari Bagian 3: baca datanya, jangan cuma percaya angka akhir. Kita cek docker stats saat load test berjalan:
CONTAINER CPU %
bench-client 101.97% ← generator load testnya sendiri!
demo-app-api1-1 4.75%
demo-app-api3-1 4.01%
demo-app-api2-1 4.22%
demo-app-postgres-1 1.08%
demo-app-redis-1 1.84%
Skrip load test kita sendiri yang jadi bottleneck — CPU-nya mentok 100%, sementara SEMUA instance server (dan database) masih santai di bawah 5%. Karena Python (dan asyncio di satu proses) dibatasi satu CPU core untuk bagian yang CPU-bound, generator beban kita tidak sanggup mengirim request secepat yang sebenarnya sanggup ditangani server — jadi baik satu maupun tiga instance sama-sama terlihat "penuh", padahal yang penuh itu klien-nya, bukan server-nya.
Ini pelajaran penting yang sering menjebak: alat ukur itu sendiri bisa jadi bottleneck, dan kalau tidak dicek, itu bisa membuat kita salah menyimpulkan sesuatu "tidak membantu" padahal sebenarnya belum benar-benar teruji.
Perbaikannya: jalankan generator beban dari BEBERAPA proses paralel, supaya beban CPU-nya tidak menumpuk di satu proses. Dengan tiga proses klien paralel (masing-masing concurrency 34, total ~100 concurrent, digabung):
| Satu instance | Tiga instance (Nginx) | |
|---|---|---|
| Throughput gabungan | ~261 req/detik | ~365 req/detik |
| Latency p95 (rata-rata ketiga klien) | ~650 ms | ~525 ms |