II — Praktik
Artikel 7 dari 9

Praktik: Horizontal Scaling dengan Docker & Load Balancer

Containerize `task-tracker-api`, menjalankan tiga instance sekaligus di belakang Nginx lewat Docker Compose, membuktikan round-robin dan cache yang konsisten, dan mengukur dampaknya lewat load test paralel.

10 Juli 2026

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.
  • Ketiga instance memakai 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).
  • Instance API tidak langsung expose port ke host — cuma 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 instanceTiga instance (Nginx)
    Throughput gabungan~261 req/detik~365 req/detik
    Latency p95 (rata-rata ketiga klien)~650 ms~525 ms
    Sekarang bedanya jelas: throughput naik ~40%, p95 turun. Bukan peningkatan 3x lipat yang sempurna — dan itu wajar, karena Postgres dan Redis kita masih satu instance tunggal yang dipakai bersama oleh ketiga instance aplikasi. Database dan cache yang dibagi bersama itu sekarang jadi batas berikutnya kalau mau scaling lebih jauh lagi (dibahas sebagai roadmap di Bagian 9). Tapi peningkatan yang kita ukur ini nyata, terukur, dan bisa dijelaskan — bukan cuma "kelihatannya lebih cepat".

    Rangkuman

  • Docker membungkus aplikasi jadi unit yang identik dan bisa dijalankan berkali-kali — syaratnya aplikasinya stateless.
  • Docker Compose dengan YAML anchor memudahkan menjalankan banyak instance identik dengan konfigurasi minim duplikasi.
  • Nginx sebagai load balancer, default round-robin, sudah cukup untuk kebutuhan dasar.
  • Cache dan database yang dibagi bersama (Redis, Postgres) membuat semua instance konsisten — bukti nyata pentingnya prinsip stateless dari Bagian 2.
  • Selalu curigai alat ukur itu sendiri kalau hasilnya tidak masuk akal — cek resource usage di semua pihak yang terlibat, termasuk klien yang menghasilkan beban, bukan cuma server.
  • Topik

    ScalabilityDockerLoad Balancing