Toko kini punya pintu yang rapi, tetapi isi rumahnya masih hiasan: /api/produk.json dijawab berkas statis yang tidak berubah sejak Budi menulisnya. Pelanggan Siti yang mencoba memesan langsung mengetahui bedanya — tidak ada keranjang, tidak ada pesanan, tidak ada data yang tersimpan. Chapter ini mengganti backend sementara dengan aplikasi Node.js sungguhan yang membaca data dari PostgreSQL, dan bersamanya kita hadapi tiga keterampilan klasik pemilik stack: menyambungkan aplikasi stateless ke database stateful lewat nama service, menjalankan migrasi skema di dalam siklus up, dan mengelola connection pool agar banyak replika aplikasi tidak mencekik satu database. Di ujungnya, backend toko akan diskalakan menjadi tiga replika tanpa satu konflik port pun — kebalikan dari kegagalan --scale yang kita alami di Chapter Service.

Kontrak Antar Stateless dan Stateful #

Pola aplikasi-dan-database adalah kontrak pembagian kerja paling tua di dunia server, dan container membuat kontrak itu terlihat fisik. Pihak pertama — aplikasi stateless — berjanji tidak menyimpan kebenaran apa pun di dalam dirinya: boleh dibuang, dibuat ulang, direplikasi, dipindah ke mesin lain, tanpa ada data yang ikut lenyap. Pihak kedua — database stateful — menampung seluruh kebenaran itu dan menuntut perlakuan berbeda: volume untuk bertahan dari down, healthcheck untuk membuktikan kesiapannya, dan kehati-hatian sebelum direplikasi. Komunikasi keduanya berjalan satu arah lewat nama service di network — db — bukan lewat port host yang diterbitkan.

Di compose.yaml toko, kontrak itu berarti tiga perubahan sekaligus: backend berganti dari image nginx menjadi image hasil build, db masuk sebagai penghuni baru backend-net, dan keduanya dihubungkan satu variabel lingkungan yang berisi alamat koneksi. Kita rakit dari dalam ke luar: aplikasi dulu, baru rumahnya.

Aplikasi yang Sungguhan: Folder api #

Backend meninggalkan dua folder html dan pindah ke folder api di samping compose.yaml:

text
toko/
├── compose.yaml
├── nginx.conf
├── frontend-html/
│   └── index.html
└── api/
    ├── Dockerfile
    ├── package.json
    └── server.js

Manifest dependensinya memuat dua nama yang sudah akrab dari Section Docker Compose:

json
{
  "name": "toko-api",
  "version": "1.0.0",
  "dependencies": {
    "express": "^4.19.2",
    "pg": "^8.12.0"
  }
}

Aplikasinya sendiri ringkas tapi lengkap — satu koneksi pool ke PostgreSQL dan satu rute yang membaca tabel produk:

js
const express = require("express");
const { Pool } = require("pg");

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
});

const app = express();

app.get("/produk.json", async (_req, res) => {
  try {
    const hasil = await pool.query(
      "SELECT id, nama, harga FROM produk ORDER BY id"
    );
    res.json(hasil.rows);
  } catch (galat) {
    res.status(500).json({ error: galat.message });
  }
});

app.listen(3000, () => {
  console.log("toko-api mendengarkan di port 3000");
});

Mari kita telaah kode di atas baris demi baris:

  1. new Pool({ connectionString: ..., max: 10 }) — pustaka pg membaca seluruh parameter koneksi dari satu string DATABASE_URL — host, port, user, kata sandi, nama database — dan max: 10 membatasi jumlah koneksi yang disiapkan pool; angka ini kita bahas tuntas di bagian connection pool.
  2. Rute /produk.json, bukan /api/produk.json — jalur publik tetap /api/produk.json, tetapi proxy toko membuang awalan /api/ sebelum meneruskan (trailing slash proxy_pass dari chapter sebelumnya), sehingga aplikasi menerima /produk.json; kontrak 3000 dan kontrak jalur sama-sama tidak menyentuh proxy.
  3. SELECT id, nama, harga FROM produk ORDER BY id — kueri pembacaan yang mengandaikan tabel produk sudah ada; andaian inilah yang akan memotivasi migrasi beberapa bagian ke bawah.
  4. catch (galat) yang menjawab 500 — galat koneksi dan galat skema dikembalikan sebagai JSON, bukan proses yang mati; keputusan kecil ini membuat kegagalan kita bisa lihat dari curl, bukan hanya dari log.

Dockerfile-nya meniru disiplin yang sama dengan katalog pada Chapter Image, Build, dan Pull:

dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY server.js ./
USER node
EXPOSE 3000
CMD ["node", "server.js"]

Delapan baris itu membawa seluruh kebiasaan baik section sebelumnya: manifest dependensi disalin lebih dulu agar layer npm install ter-cache, kode masuk paling akhir, proses turun ke user node, dan CMD memakai exec form sehingga sinyal SIGTERM sampai ke proses. Satu-satunya yang benar-benar baru adalah konteksnya — server.js kini benar-benar membaca database.

PostgreSQL Masuk ke Belakang Panggung #

Service db bergabung sebagai penghuni pertama backend-net, dan backend berganti total:

yaml
services:
  backend:
    build: ./api
    image: toko-api:dev
    environment:
      DATABASE_URL: postgres://toko:rahasia123@db:5432/toko
    networks:
      - frontend-net
      - backend-net
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:18-alpine
    environment:
      - POSTGRES_USER=toko
      - POSTGRES_PASSWORD=rahasia123
      - POSTGRES_DB=toko
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U toko -d toko"]
      interval: 10s
      timeout: 3s
      retries: 3
    networks:
      - backend-net

volumes:
  db-data:

Berkas backend.conf, folder backend-html, dan deklarasi config backend-conf dari chapter sebelumnya ikut dicabut — backend tidak lagi membutuhkannya. Mari kita telaah perubahan yang tersisa:

  1. build: ./api dan image: toko-api:dev — pasangan kunci dari Chapter Image, Build, dan Pull: image dibangun dari Dockerfile di folder api dan hasilnya bernama stabil toko-api:dev, sehingga bisa diperiksa di docker images atau dijalankan manual untuk men-debug.
  2. DATABASE_URL memakai host db — nama service di network backend-net; string koneksi lengkap berisi user toko, kata sandi fiktif rahasia123, port internal 5432, dan database toko.
  3. Trio POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB — dibaca entry point postgres saat direktori data masih kosong: superuser toko, basis data awal toko; nilai-nilai ini hanya berlaku pada initdb pertama, perilaku yang sudah kita catat di Section Docker Compose.
  4. Volume db-data — cluster postgres pindah dari writable layer ke volume bernama, sehingga docker compose down tidak lagi memusnahkan data; persis tragedi yang kita cegah lewat Chapter Volume dan Bind Mount.
  5. healthcheck dengan pg_isready — recap resep dari Chapter Healthcheck dan Service Readiness: probe menanyakan kesiapan server sungguhan, bukan sekadar napas proses, dan gerbang service_healthy pada depends_on backend menunggu jawabannya.

Menyala untuk Pertama Kali #

Seluruh deklarasi siap, dan satu flag --build memastikan image baru benar-benar dibangun — kebiasaan yang kita paku di Chapter Image, Build, dan Pull karena up sendiri tidak membangun ulang image yang sudah ada:

bash
$ docker compose up -d --build

Output yang Dihasilkan (dipotong):

text
[+] Building 18.6s (14/14) FINISHED
 => [1/7] FROM docker.io/library/node:22-alpine                         0.0s
 ...
 => [4/7] RUN npm install                                               8.7s
 ...
 => => naming to docker.io/library/toko-api:dev                          0.0s
[+] Running 9/9
 ✔ Network toko_frontend-net    Running    0.0s
 ✔ Network toko_backend-net     Running    0.0s
 ✔ Volume toko_db-data          Created    0.2s
 ✔ Container toko-db-1          Created    0.1s
 ✔ Container toko-db-1          Healthy    4.9s
 ✔ Container toko-backend-1     Recreated  0.3s
 ✔ Container toko-backend-1     Started    0.6s
 ✔ Container toko-frontend-1    Running    0.0s
 ✔ Container toko-proxy-1       Running    0.0s

Mari kita telaah momen-momen penting pada output tersebut: volume toko_db-data diciptakan pertama kali; container db baru menempuh masa initdb sebelum mencapai status Healthy setelah hampir lima detik — gerbang yang dijaga pg_isready; dan backend mengalami Recreated karena cetakbirunya berganti dari nginx menjadi image hasil build. Status stack terbaca dalam satu tabel:

bash
$ docker compose ps

Output yang Dihasilkan:

text
NAME             IMAGE               COMMAND                  SERVICE   CREATED             STATUS                    PORTS
toko-proxy-1     nginx:1.29-alpine   "/docker-entrypoint.…"  proxy     About an hour ago   Up About an hour          0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
toko-frontend-1  nginx:1.29-alpine   "/docker-entrypoint.…"  frontend  About an hour ago   Up About an hour          80/tcp
toko-backend-1   toko-api:dev        "docker-entrypoint.s…"  backend   About a minute ago   Up About a minute         3000/tcp
toko-db-1        postgres:18-alpine  "docker-entrypoint.s…"  db        About a minute ago   Up About a minute (healthy)   5432/tcp

Kolom PORTS memperlihatkan kebijakan arsitektur kita bekerja: hanya proxy yang memiliki pemetaan ke host; 80/tcp, 3000/tcp, dan 5432/tcp pada baris lain sekadar metadata port internal. Kini saatnya kebenaran yang tidak manis:

bash
$ curl localhost:8080/api/produk.json

Output yang Dihasilkan:

text
{"error":"relation \"produk\" does not exist"}

Aplikasi menyala, koneksi ke db berhasil, kueri dijalankan — dan PostgreSQL menjawab bahwa tabel produk tidak ada. Inilah dorongan yang tepat: database siap, aplikasi siap, tetapi skema belum diciptakan siapa pun. Stack kita butuh migrasi.

Migrasi Skema di Dalam Stack #

Dua strategi berlaku umum untuk menjalankan migrasi pada dunia container, dan keduanya layak dipahami sebelum memilih.

Strategi Pertama: Migrasi di Entry Point Aplikasi #

Cara termudah: aplikasi menjalankan migrasi sendiri sebelum menyala — cukup menimpa perintah startnya, pola Chapter Command Override:

yaml
  backend:
    command: sh -c "node migrate.js && node server.js"

Container backend kini menjalankan migrate.js lebih dulu; bila migrasi sukses, && melanjutkan ke server.js; bila gagal, container mati dan restart policy yang memutuskan langkah berikutnya. Strategi ini cocok untuk satu replika dan migrasi kecil. Tetapi tiga kelemahannya muncul begitu replika bertambah: setiap replika menjalankan migrasi yang sama hampir bersamaan dan saling berlomba mengubah skema yang sama; tiap restart container mengulang seluruh pemeriksaan migrasi walau tidak ada yang berubah; dan lama startup aplikasi kini digandakan oleh lama migrasi.

Strategi Kedua: Service Migrasi Sekali Jalan #

Strategi kedua memisahkan migrasi menjadi service sendiri yang berjalan sekali lalu selesai. Berkas migrate.js masuk ke folder api:

js
const { Pool } = require("pg");

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function jalankan() {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS produk (
      id SERIAL PRIMARY KEY,
      nama TEXT NOT NULL,
      harga INTEGER NOT NULL
    )`);

  const cek = await pool.query("SELECT COUNT(*) FROM produk");
  if (parseInt(cek.rows[0].count, 10) === 0) {
    await pool.query(`
      INSERT INTO produk (nama, harga) VALUES
        ('Kopi Arabika Gayo', 85000),
        ('Teh Melati Putih', 120000),
        ('Gula Aren Cair', 45000)`);
  }

  console.log("migrasi selesai");
  await pool.end();
}

jalankan();

Dockerfile mendapat satu baris baru setelah COPY server.js ./:

dockerfile
COPY migrate.js ./

Lalu compose.yaml menampung service keempat dan mengganjal backend di belakangnya:

yaml
  migrate:
    build: ./api
    image: toko-api:dev
    command: node migrate.js
    environment:
      DATABASE_URL: postgres://toko:rahasia123@db:5432/toko
    networks:
      - backend-net
    depends_on:
      db:
        condition: service_healthy

  backend:
    depends_on:
      db:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully

Mari kita telaah mengapa bentuk ini lebih sehat untuk banyak replika:

  1. command: node migrate.js — menimpa CMD image yang menjalankan server.js; container yang sama dipakai untuk dua peran berbeda, berkat satu image bernama sama toko-api:dev.
  2. service_completed_successfully — kondisi depends_on yang kita kenal dari Chapter depends_on dan Startup Order: backend baru dinyalakan setelah container migrate selesai dan keluar dengan exit code 0; bila migrasi gagal, backend tidak pernah lahir — kegagalan berhenti di tempat yang tepat.
  3. Migrasi berjalan tepat sekali per up — tiga replika backend tetap hanya melihat satu pelari migrasi; perlombaan antar-replika lenyap karena hanya ada satu pelari.
  4. Skema dan aplikasi berumur bersama — keduanya lahir dari folder dan image yang sama; versi aplikasi yang mengharapkan kolom baru pasti diiringi migrasi yang menciptakannya.

Sekarang stack menyala ulang:

bash
$ docker compose up -d

Output yang Dihasilkan (dipotong):

text
[+] Running 8/8
 ✔ Network toko_frontend-net    Running    0.0s
 ✔ Network toko_backend-net     Running    0.0s
 ✔ Container toko-db-1          Healthy    0.0s
 ✔ Container toko-migrate-1     Exited (0) 1.2s
 ✔ Container toko-backend-1     Recreated  0.3s
 ✔ Container toko-backend-1     Started    0.6s
 ...

Baris kuncinya pendek: toko-migrate-1 berlari, selesai dengan exit code 0 — ditulis Exited (0) — dan backend menyusul. Mari kita buktikan hasilnya langsung di database:

bash
$ docker compose exec db psql -U toko -d toko -c "SELECT id, nama, harga FROM produk;"

Output yang Dihasilkan:

text
 id |       nama        | harga
----+-------------------+--------
  1 | Kopi Arabika Gayo |  85000
  2 | Teh Melati Putih  | 120000
  3 | Gula Aren Cair    |  45000
(3 rows)

Tabel produk hidup dengan tiga barisnya. Dan dari pintu depan, jawaban yang semula galat kini menjadi buah:

bash
$ curl localhost:8080/api/produk.json

Output yang Dihasilkan:

text
[{"id":1,"nama":"Kopi Arabika Gayo","harga":85000},{"id":2,"nama":"Teh Melati Putih","harga":120000},{"id":3,"nama":"Gula Aren Cair","harga":45000}]

Data kini benar-benar mengalir: browser — proxy — backend — db, dan pulang dengan format yang sama seperti saat backend masih statis. Bedanya, mengubah harga di database akan langsung terlihat pada permintaan berikutnya.

Connection Pool dan Batas PostgreSQL #

Membuka satu koneksi PostgreSQL tidak murah. Sistem operasi harus menuntaskan jabat tangan TCP tiga arah, PostgreSQL harus mem-fork proses backend baru, dan autentikasi dengan pencarian kata sandi harus selesai — beberapa milidetik sampai belasan milidetik untuk setiap koneksi baru, di atas jaringan yang sehat sekalipun. Connection pool menghapus biaya itu dari jalur panas: aplikasi membuka sejumlah koneksi sekali di awal, menyimpannya hangat, dan meminjamkan-memutar koneksi-koneksi itu untuk tiap kueri — perilaku bawaan pustaka pg lewat Pool dengan max: 10.

Kelas yang mengikat: setiap koneksi pool menempati satu slot max_connections di sisi PostgreSQL, dan nilai bawaan max_connections adalah 100. Aritmetikanya menuntut disiplin lintas service:

text
jumlah replika backend × max pool per replika < max_connections

Tiga replika backend dengan pool 10 menghabiskan 30 slot; service migrate dan alat bantu mengambil beberapa slot lagi; sisanya menjadi ruang napas untuk docker compose exec, koneksi admin, dan service berikutnya. Rumus ini yang membuat max: 10 pada aplikasi kita bukan angka dekoratif — ia adalah anggaran.

Menskalakan backend Tanpa Konflik Port #

Inilah momen pembalikan kisah Chapter Service: --scale web=3 dulu gagal karena tiga container memperebutkan satu port host 8080. Backend toko tidak menerbitkan port host sama sekali — port 3000 adalah port internal container yang boleh dipegang serentak oleh seratus replika:

bash
$ docker compose up -d --scale backend=3

Output yang Dihasilkan (dipotong):

text
[+] Running 7/7
 ✔ Container toko-db-1         Healthy    0.0s
 ✔ Container toko-backend-1    Running    0.0s
 ✔ Container toko-backend-2    Started    0.4s
 ✔ Container toko-backend-3    Started    0.3s
 ...

Tidak ada Bind for 0.0.0.0:8080 failed — karena tidak ada satu pun pintu host yang baru diterbitkan. Lalu kita ingat NOTE chapter sebelumnya: proxy masih memegang alamat backend lama hasil resolusi awal. Satu perintah menyegarkannya:

bash
$ docker compose restart proxy

Output yang Dihasilkan:

text
[+] Restarting 1/1
 ✔ Container toko-proxy-1  Started   1.1s

Bukti bahwa proxy kini melihat ketiga replika terbaca dari dalam container-nya sendiri:

bash
$ docker compose exec proxy nslookup backend

Output yang Dihasilkan:

text
Server:   127.0.0.11
Address:  127.0.0.11:53

Non-authoritative answer:
Name:     backend
Address:  172.18.0.4

Non-authoritative answer:
Name:     backend
Address:  172.18.0.5

Non-authoritative answer:
Name:     backend
Address:  172.18.0.6

Satu nama, tiga alamat — embedded DNS mengembalikan seluruh replika, dan nginx yang baru di-restart membagi permintaan /api/ secara bergiliran ke antaranya. Perhatikan pula bahwa ketiga alamat berada di jaringan frontend-net: DNS hanya menjawab dengan alamat pada network yang dimiliki si penanya, detail yang menjadi panggung utama Chapter Service Discovery. Kembali ke satu replika cukup satu perintah, dengan restart proxy menyusul agar nginx berhenti mengingat dua alamat yang telah kosong:

bash
$ docker compose up -d --scale backend=1

Output yang Dihasilkan:

text
[+] Running 3/3
 ✔ Container toko-backend-3  Removed   0.2s
 ✔ Container toko-backend-2  Removed   0.3s
 ✔ Container toko-backend-1  Running   0.0s

Toko kini memiliki tulang punggung yang lengkap dan hidup: aplikasi stateless yang bisa direplikasi kapan saja, database stateful yang datanya bertahan, dan proxy yang menyeimbangkan keduanya. Tetapi setiap kunjungan pelanggan masih menembus database untuk data yang sama persis — daftar produk yang jarang berubah itu dikueri ulang setiap kali. Chapter Cache dan Queue memasang Redis di antara backend dan db untuk menjawab pembacaan berulang dalam hitungan sepersekian milidetik, sekaligus memperkenalkan saudara kembarnya: antrian untuk pekerjaan yang boleh menunggu.

Referensi #