Lima service toko kini hidup rukun berdampingan — proxy, frontend, backend, db, dan cache — ditambah satu job migrasi sekali jalan yang ikut menunggu giliran, dan semua nama terselesaikan lewat DNS network Compose persis seperti yang kita bedah di chapter Service Discovery. Namun resolusi nama hanya menjawab “di mana”; bab baru masalahnya menyusul di detik-detik pertama docker compose up: siapa yang boleh menyala lebih dulu. Nama hanya bisa ditemukan bila container di baliknya sudah tercipta, dan yang lebih halus — container yang sudah tercipta belum tentu siap melayani. Stack toko kita adalah rantai kebutuhan berlapis: proxy membutuhkan backend yang benar-benar menjawab, backend membutuhkan db yang sehat, cache yang menjawab PONG, dan skema tabel yang sudah tercipta. Chapter ini merangkai seluruh rantai itu menjadi satu sistem urutan hidup yang eksplisit, lalu membuktikannya baris demi baris pada keluaran docker compose up.

Rantai Kebutuhan yang Berlapis #

Mari kita petakan dulu siapa butuh siapa pada stack yang kita miliki sejauh ini. backend sudah tertolong tiga gerbang sejak chapter Application dan Database dan Cache dan Queue: ia menunggu db berstatus sehat, cache berstatus sehat, dan migrate selesai dengan exit code 0. Yang belum tertangani adalah ujung depannya: proxy masih memakai depends_on bentuk daftar dari chapter Reverse Proxy, sehingga proxy hanya tahu bahwa container backend sudah Started — bukan bahwa backend sudah siap melayani.

Perbedaan Started versus siap inilah peringatan yang kita terima di chapter depends_on dan Startup Order: urutan start, berapa pun ketatnya, tidak menjamin kesiapan aplikasi. nginx membaca nama upstream pada proxy_pass tepat saat prosesnya menyala — bila backend belum terdaftar di DNS, proxy langsung tumbang dengan nginx: [emerg] host not found in upstream; bentuk daftar depends_on menutup celah itu dengan memastikan backend tercipta lebih dulu. Tetapi setelah tercipta, tidak ada satu baris pun di compose.yaml yang memastikan backend sudah membuka pool koneksi dan sanggup menjawab permintaan pertama yang masuk lewat proxy. Rantai kebutuhan lengkapnya terbaca seperti ini:

ServiceMenunggu apaSebab
proxyfrontend berjalan, backend sehatnginx menyelesaikan nama upstream saat start, lalu menunggu backend benar-benar menjawab
backenddb sehat, cache sehat, migrate tuntaspool butuh server yang menerima koneksi; pola cache-aside butuh Redis; endpoint butuh skema
migratedb sehatDDL hanya bisa berjalan pada server yang menerima koneksi
frontendtidak adaberkas statis tanpa masa bangun
db, cachetidak adafondasi rantai — tidak bergantung pada siapa pun

Baris frontend sengaja kosong: nginx statis yang menyajikan berkas dari bind mount tidak punya masa inisialisasi, sehingga service_started sudah lebih dari cukup untuknya. Kesiapan yang sejati harus dibuktikan, bukan diasumsikan — dan bahasa pembuktianya sudah kita kenal.

Healthcheck sebagai Gerbang Kesiapan #

Chapter Healthcheck dan Service Readiness menetapkan prinsip probe yang jujur: uji fungsi layanan sebagaimana penggunanya mengujinya, bukan sekadar keberadaan prosesnya. Backend toko belum punya healthcheck — hanya db dan cache yang punya — padahal proxy justru butuh membaca kesiapan backend. Kita tambahkan endpoint /healthz yang mengetuk database sungguhan di dalam server.js, di samping endpoint /produk.json yang sudah ada:

js
  if (req.url === "/healthz") {
    try {
      await pool.query("SELECT 1");
      res.writeHead(200).end("ok");
    } catch {
      res.writeHead(500).end("db tidak siap");
    }
    return;
  }

Mari kita telaah cabang baru di atas:

  1. pool.query("SELECT 1") — permintaan termurah yang tetap menempuh jalur lengkap: dari handler, ke pool, ke network, ke db, lalu kembali. Bila salah satu mata rantai itu putus, baris ini melempar galat sebelum jawaban sempat dikirim.
  2. try ... catch — kueri yang gagal ditangkap dan dijawab 500; tanpa tangkapan, galat tak tertangani justru melumpuhkan seluruh proses Node.
  3. 200 versus 500 — protokol kejujuran probe: exit code wget dipetakan langsung dari status HTTP, sehingga “sehat” hanya diberikan bila rantai backend → pool → db benar-benar berfungsi. Backend yang “hidup tapi buta” tidak bisa berpura-pura sehat.

Probe-nya sendiri menempel pada service backend di compose.yaml, memakai wget milik busybox yang hadir di node:22-alpine:

yaml
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 5s
      start_interval: 5s

Mari kita telaah kunci-kunci probe tersebut:

  1. test bentuk CMD — wget --spider -q mengetuk halaman tanpa mengunduh isinya dan tetap hening; exit code 0 hanya diberikan bila jawabannya 2xx.
  2. interval: 10s dan timeout: 3s — pemeriksaan rutin tiap 10 detik dengan batas 3 detik per percobaan, angka yang jauh lebih sehat daripada nilai bawaan 30 detik.
  3. start_period: 5s dengan start_interval: 5s — masa hangar lima detik memberi ruang bagi Node membaca environment dan membuka pool, sementara probe dipercepat menjadi tiap 5 detik selama masa itu agar status healthy tercapai segera setelah listener terbuka.

Rantai depends_on dari Ujung ke Ujung #

Dengan probe backend terpasang, rantai bisa kita lengkapi sampai ujung depan. Berikut compose.yaml toko utuh dengan urutan hidup yang sehat:

yaml
name: toko

services:
  proxy:
    image: nginx:1.29-alpine
    ports:
      - "8080:80"
    configs:
      - source: nginx-conf
        target: /etc/nginx/conf.d/default.conf
    networks:
      - frontend-net
      - backend-net
    depends_on:
      frontend:
        condition: service_started
      backend:
        condition: service_healthy

  frontend:
    image: nginx:1.29-alpine
    volumes:
      - ./frontend:/usr/share/nginx/html:ro
    networks:
      - frontend-net

  backend:
    build: ./api
    image: toko-api:dev
    environment:
      - DATABASE_URL=postgres://toko:rahasia123@db-utama:5432/toko
      - REDIS_URL=redis://cache:6379
    networks:
      - backend-net
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 5s
      start_interval: 5s

  migrate:
    build: ./api
    image: toko-api:dev
    command: ["node", "migrate.js"]
    environment:
      - DATABASE_URL=postgres://toko:rahasia123@db-utama:5432/toko
    networks:
      - 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
    networks:
      backend-net:
        aliases:
          - db-utama
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U toko -d toko"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"]
    networks:
      - backend-net
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5

networks:
  frontend-net:
  backend-net:

volumes:
  db-data:

configs:
  nginx-conf:
    file: ./nginx.conf

Mari kita telaah keputusan-keputusan penting pada berkas di atas:

  1. proxy menuntut backend sehat, bukan sekadar berjalan — gerbang service_healthy kini bermakna penuh karena backend punya probe; proxy baru diciptakan setelah /healthz menjawab 200, sehingga jendela 502 di menit pertama tertutup sejak awal.
  2. frontend tetap service_started — service statis tanpa masa bangun tidak butuh probe; menuntut service_healthy padahal healthcheck-nya tak ada justru menggantungkan stack selamanya.
  3. backend menunggu tiga gerbang sekaligus — db dan cache harus sehat, migrate harus tuntas; Compose mengevaluasi ketiganya sebelum container backend diciptakan.
  4. Host db-utama pada DATABASE_URL — alias peran yang kita sematkan di chapter *Service Discovery; baik backend maupun migrate memanggil peran "database utama", bukan nama implementasi db`, sehingga suatu hari service pengganti bisa mengambil alih tanpa menyentuh environment keduanya.
  5. Batas dua network tetap terjaga — frontend terisolasi di frontend-net, sementara backend, db, dan cache hidup di backend-net; proxy menjadi satu-satunya penjembatan. command pada cache beserta batas memori 128 MB dari chapter Cache dan Queue tidak boleh hilang hanya karena urutan start berubah.

Urutan hidup yang dihasilkan berkas tersebut bisa dipetakan utuh:

Diagram di atas memperlihatkan pembagian kerja yang tegas antara Compose dan healthcheck: Compose menciptakan container sesuai relasi depends_on, sedangkan probe-lah yang memutuskan kapan sebuah gerbang terbuka. Dua cabang berjalan paralel — frontend tidak menunggu siapa pun, sementara jalur db → migrate → backend → proxy bergerak bertingkat. Perlu dicatat juga cabang kegagalan pada migrate: exit code selain nol membuat up berhenti dan melaporkan galat, bukan melanjutkan dengan backend yang tanpa skema.

Menyaksikan Urutan Hidup pada Keluaran up #

Teori terbaik tetap perlu dibuktikan. Matikan stack lama bila masih menyala (docker compose down), lalu jalankan dari nol pada volume yang segar:

bash
docker compose up -d

Output yang Dihasilkan:

text
[+] Running 9/9
 ✔ Network toko_frontend-net    Created   0.1s
 ✔ Network toko_backend-net     Created   0.1s
 ✔ Volume "toko_db-data"        Created   0.0s
 ✔ Container toko-cache-1       Healthy   4.1s
 ✔ Container toko-db-1          Healthy   12.2s
 ✔ Container toko-migrate-1     Exited    2.4s
 ✔ Container toko-backend-1     Started   0.6s
 ✔ Container toko-frontend-1    Started   0.4s
 ✔ Container toko-proxy-1       Started   6.8s

Sembilan baris itu adalah kronik urutan hidup yang kita rancang. Mari kita telaah peristiwanya satu per satu:

  1. Dua network dan satu volume diciptakan lebih dulu — fondasi tempat para service akan berdiri; pool subnet dan volume kita kenal sejak section jaringan dan penyimpanan data.
  2. cache sehat dalam 4,1 detik, db dalam 12,2 detik — keduanya dinyalakan bersamaan, tetapi PostgreSQL membayar waktu lebih lama untuk initdb pada volume yang masih kosong; angka 12,2 detik itu persis jendela race yang dulu mematikan aplikasi katalog di mesin Siti.
  3. migrate baru berjalan setelah db sehat — bukan sekadar setelah dinyalakan; DDL menunggu server benar-benar menerima koneksi, lalu job selesai dalam 2,4 detik dan keluar.
  4. backend menyala setelah tiga gerbang terbuka — db sehat, cache sehat, dan migrate telah Exited dengan exit code 0; barisnya tercatat Started karena memang begitulah status akhirnya.
  5. proxy paling akhir dengan 6,8 detik — jeda setelah backend Started adalah masa probe /healthz: proxy baru diciptakan setelah backend menjawab 200. Setelah baris terakhir, satu permintaan pertama ke http://localhost:8080/api/produk.json pun langsung dilayani tanpa jendela 502.

Keadaan akhir stack terbaca dari docker compose ps -a:

bash
docker compose ps -a

Output yang Dihasilkan:

text
NAME               IMAGE              COMMAND                  SERVICE   CREATED         STATUS                    PORTS
toko-proxy-1       nginx:1.29-alpine  "/docker-entrypoint.…"   proxy     2 minutes ago   Up 2 minutes              0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
toko-frontend-1    nginx:1.29-alpine  "/docker-entrypoint.…"   frontend  2 minutes ago   Up 2 minutes
toko-backend-1     toko-api:dev       "docker-entrypoint.s…"  backend   2 minutes ago   Up 2 minutes (healthy)
toko-db-1          postgres:18-alpine "docker-entrypoint.s…"  db        2 minutes ago   Up 2 minutes (healthy)
toko-cache-1       redis:7-alpine     "docker-entrypoint.s…"  cache     2 minutes ago   Up 2 minutes (healthy)
toko-migrate-1     toko-api:dev       "docker-entrypoint.s…"  migrate   2 minutes ago   Exited (0) 2 minutes ago

Migrasi One-Shot: Mati yang Sehat #

Baris terakhir tabel itu sering mengejutkan pendatang baru: toko-migrate-1 berstatus Exited (0) di tengah stack yang semuanya Up. Status itu bukan kegagalan — justru itulah definisi sukses bagi service one-shot. Mari kita telaah mengapa “mati” adalah kabar baik untuk job migrasi:

  1. Exit code 0 adalah laporan tugas selesai — migrate.js menjalankan CREATE TABLE IF NOT EXISTS hingga tuntas lalu keluar dengan sengaja; container yang tetap hidup justru patut dicurigai sedang menggantung di tengah DDL.
  2. Compose telah memverifikasi kematiannya — gerbang service_completed_successfully membaca exit code container; hanya angka 0 yang membuka jalan bagi backend. Exited (0) pada ps -a adalah bukti verifikasi yang sama.
  3. Kebijakan restart bawaan no menjaga kehormatan job — daemon tidak membangkitkan ulang container yang selesai dengan sukses, jadi job tidak diulang sia-sia.

Dua Replika, Satu Migrasi #

Sisi paling tajam dari pola one-shot terlihat saat backend direplikasi. Ingat kembali dua strategi migrasi dari chapter Application dan Database: menjalankan migrasi di entrypoint aplikasi, atau menjalankannya sebagai service tersendiri. Strategi pertama tampak sederhana dan cukup untuk satu replika — tetapi coba bayangkan --scale backend=3 dengan migrasi di entrypoint: tiga container menyala hampir bersamaan, dan ketiganya menjalankan migrasi yang sama di jendela waktu yang hampir sama. Mari kita telaah mengapa itu resep bencana:

  1. DDL berlomba tiga arah — dua replika bisa sama-sama melewati pemeriksaan “tabel masih kosong” lalu sama-sama memasukkan data awal; salah satunya menabrak duplicate key value violates unique constraint "produk_pkey" dan gagal start padahal aplikasinya sehat.
  2. Perubahan skema mengunci tabel — ALTER TABLE pada migrator pertama membuat migrator kedua menunggu; pada susunan yang tersarik, keduanya saling menunggu dan PostgreSQL mendeteksi deadlock. Kegagalan semacam ini hanya muncul saat scaling, sulit direproduksi, dan selalu datang pada jam tersibuk.
  3. One-shot memutus persoalan jumlah secara struktural — satu service migrate berarti satu container, satu migrator, titik. Gerbang service_completed_successfully memastikan migrasi tunggal itu tuntas sebelum replika backend pertama menyala; skala backend berapa pun, migrasinya tetap satu.

Bukti terakhirnya bisa kita saksikan langsung pada stack yang sedang berjalan:

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

Output yang Dihasilkan:

text
[+] Running 2/2
 ✔ Container toko-backend-2  Started   0.5s
 ✔ Container toko-backend-3  Started   0.6s

Tidak ada satu baris migrate pada keluaran itu: gerbang replika baru dievaluasi terhadap container migrate yang sudah Exited (0), sehingga kedua replika langsung menyusul tanpa migrasi ulang. Seperti di chapter Application dan Database, proxy perlu membaca ulang daftar alamatnya (docker compose restart proxy) agar nginx mengenal ketiga backend, lalu kembalikan stack ke satu replika dengan docker compose up -d --scale backend=1 sebelum melanjutkan.

Gerbang Bukan Janji Seumur Hidup #

Rantai yang kita bangun menjaga satu momen: up. Selepas momen itu, depends_on tidak lagi bisa berbuat apa-apa — bila db dibuat ulang di siang hari yang ramai, koneksi pool yang terbuka tetap terputus dan backend tetap merasakan kegagalan. Persis di titik inilah retry dengan backoff eksponensial dari chapter Service Discovery mengambil alih: aplikasi mencoba ulang dengan jeda yang membesar, memberi sistem yang sedang bangkit ruang bernapas tanpa menyerah pada ECONNREFUSED pertama. Lapis pemulihan restart: on-failure dari chapter Restart Policy menutup celah terakhir untuk kegagalan yang membuat proses keluar sama sekali. Gerbang, retry, dan restart bukan pilihan salah satu — keduanya bekerja pada waktu yang berbeda dan saling menutup celah.

Tiga Tingkat Pola Dependency #

Seluruh perjalanan urutan start dari section Docker Compose sampai chapter ini kini bisa dirangkum dalam satu tangga pola:

TingkatDeklarasi depends_onYang ditunggu sebelum service menyalaCocok untuk
1daftar, atau condition: service_startedContainer dependensi berjalan — prosesnya hidupService ringan tanpa masa bangun, seperti frontend statis
2condition: service_healthyHealthcheck dependensi lulus — layanannya menjawabdb, cache, dan aplikasi dengan masa inisialisasi
3condition: service_completed_successfullyContainer dependensi keluar dengan exit code 0Job one-shot: migrate hari ini, seed di chapter berikutnya

Tangga ini adalah alat keputusan, bukan aturan kekakuan: naiklah satu tingkat hanya bila tingkat di bawahnya terbukti tidak cukup. Stack toko kini menyala dengan urutan yang teratur dan terbaca — setiap baris keluaran up punya penjelasan, setiap gerbang punya probe yang jujur. Namun siklus hidup punya dua ujung, dan ujung yang satu belum kita rapikan: cara stack berhenti. docker compose stop mengirim SIGTERM ke PID 1 setiap container, dan aplikasi yang tidak menangkapnya akan mati dengan koneksi pool terpotong serta permintaan setengah jadi di tangan pelanggan. Cara berhenti dengan sopan adalah materi chapter Graceful Shutdown.

Referensi #