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:
toko/
├── compose.yaml
├── nginx.conf
├── frontend-html/
│ └── index.html
└── api/
├── Dockerfile
├── package.json
└── server.jsManifest dependensinya memuat dua nama yang sudah akrab dari Section Docker Compose:
{
"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:
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:
new Pool({ connectionString: ..., max: 10 })— pustakapgmembaca seluruh parameter koneksi dari satu stringDATABASE_URL— host, port, user, kata sandi, nama database — danmax: 10membatasi jumlah koneksi yang disiapkan pool; angka ini kita bahas tuntas di bagian connection pool.- Rute
/produk.json, bukan/api/produk.json— jalur publik tetap/api/produk.json, tetapi proxy toko membuang awalan/api/sebelum meneruskan (trailing slashproxy_passdari chapter sebelumnya), sehingga aplikasi menerima/produk.json; kontrak 3000 dan kontrak jalur sama-sama tidak menyentuh proxy. SELECT id, nama, harga FROM produk ORDER BY id— kueri pembacaan yang mengandaikan tabelproduksudah ada; andaian inilah yang akan memotivasi migrasi beberapa bagian ke bawah.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 daricurl, bukan hanya dari log.
Dockerfile-nya meniru disiplin yang sama dengan katalog pada Chapter Image, Build, dan Pull:
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:
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:
build: ./apidanimage: toko-api:dev— pasangan kunci dari Chapter Image, Build, dan Pull: image dibangun dari Dockerfile di folderapidan hasilnya bernama stabiltoko-api:dev, sehingga bisa diperiksa didocker imagesatau dijalankan manual untuk men-debug.DATABASE_URLmemakai hostdb— nama service di networkbackend-net; string koneksi lengkap berisi usertoko, kata sandi fiktifrahasia123, port internal 5432, dan databasetoko.- Trio
POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB— dibaca entry point postgres saat direktori data masih kosong: superusertoko, basis data awaltoko; nilai-nilai ini hanya berlaku padainitdbpertama, perilaku yang sudah kita catat di Section Docker Compose. - Volume
db-data— cluster postgres pindah dari writable layer ke volume bernama, sehinggadocker compose downtidak lagi memusnahkan data; persis tragedi yang kita cegah lewat Chapter Volume dan Bind Mount. healthcheckdenganpg_isready— recap resep dari Chapter Healthcheck dan Service Readiness: probe menanyakan kesiapan server sungguhan, bukan sekadar napas proses, dan gerbangservice_healthypadadepends_onbackend 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:
$ docker compose up -d --buildOutput yang Dihasilkan (dipotong):
[+] 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.0sMari 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:
$ docker compose psOutput yang Dihasilkan:
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/tcpKolom 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:
$ curl localhost:8080/api/produk.jsonOutput yang Dihasilkan:
{"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:
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:
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 ./:
COPY migrate.js ./Lalu compose.yaml menampung service keempat dan mengganjal backend di belakangnya:
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_successfullyMari kita telaah mengapa bentuk ini lebih sehat untuk banyak replika:
command: node migrate.js— menimpaCMDimage yang menjalankanserver.js; container yang sama dipakai untuk dua peran berbeda, berkat satu image bernama samatoko-api:dev.service_completed_successfully— kondisidepends_onyang 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.- Migrasi berjalan tepat sekali per
up— tiga replika backend tetap hanya melihat satu pelari migrasi; perlombaan antar-replika lenyap karena hanya ada satu pelari. - 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:
$ docker compose up -dOutput yang Dihasilkan (dipotong):
[+] 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:
$ docker compose exec db psql -U toko -d toko -c "SELECT id, nama, harga FROM produk;"Output yang Dihasilkan:
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:
$ curl localhost:8080/api/produk.jsonOutput yang Dihasilkan:
[{"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:
jumlah replika backend × max pool per replika < max_connectionsTiga 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:
$ docker compose up -d --scale backend=3Output yang Dihasilkan (dipotong):
[+] 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:
$ docker compose restart proxyOutput yang Dihasilkan:
[+] Restarting 1/1
✔ Container toko-proxy-1 Started 1.1sBukti bahwa proxy kini melihat ketiga replika terbaca dari dalam container-nya sendiri:
$ docker compose exec proxy nslookup backendOutput yang Dihasilkan:
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.6Satu 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:
$ docker compose up -d --scale backend=1Output yang Dihasilkan:
[+] Running 3/3
✔ Container toko-backend-3 Removed 0.2s
✔ Container toko-backend-2 Removed 0.3s
✔ Container toko-backend-1 Running 0.0sToko 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 #
- PostgreSQL — Docker Hub
— trio variabel
POSTGRES_*dan utilitaspg_isreadyuntuk probe kesiapan. - Compose File Reference — Services
— kunci
build,environment,depends_on, danhealthcheckpada service. - Node.js — Docker Hub
— image
node:22-alpineyang dipakai backend toko. - PostgreSQL Documentation
— batas
max_connectionsdan perilaku koneksi dari sisi client.