Chapter Development Dependencies menutup dengan satu kemajuan yang mengubah wajah Dockerfile katalog-web: target dev kini berdiri sebagai stage tersendiri, membawa devDependencies lengkap sementara volume node_modules menjaga dependensi container tetap utuh dari hari ke hari. Pertanyaan yang menyertainya belum selesai dijawab: image yang manakah yang kita bangun, kapan membangunnya, dan dengan kebutuhan seperti apa? Development dan produksi ternyata menuntut hal-hal yang saling berlawanan — image yang nyaman untuk mengedit kode jarang sekali layak berdiri di server. Chapter ini merakit jawaban finalnya: satu Dockerfile dengan empat target yang melayani kedua dunia, dua image dengan tag yang tegas berbeda, dan satu disiplin tentang kapan masing-masing image dibangun ulang.

Kebutuhan yang Saling Berlawanan #

Biaya dari seluruh kemudahan section ini baru terasa ketika kita membayangkan image yang sama berdiri di server produksi. Tool pemantau berkas, dependensi pengembangan, dan pintu debugger adalah muatan yang tidak pernah dipakai server; sebaliknya, healthcheck tertanam dan ukuran yang ramping adalah tuntutan yang hanya muncul di sana. Tabel berikut memetakan enam perbedaan yang paling menentukan:

AspekImage developmentImage produksi
Dependensiseluruhnya, termasuk devDependencies seperti nodemonhanya dependencies aplikasi lewat --omit=dev
Kode sumberditimpa bind mount dari host selama sesi developmenttertanam permanen lewat COPY
Perintah bawaannodemon dengan watch modenode src/server.js dalam exec form
Ukuran imagebebas gemuk demi kenyamananseramping mungkin demi tarik-unduh cepat
Port debugger9229 dibuka untuk node --inspecttidak ada pintu tambahan sama sekali
HEALTHCHECKtidak dibutuhkan — log selalu kita awasi sendiritertanam agar kolom STATUS jujur di server

Mari kita telaah keenam baris di atas sebelum merakit solusinya:

  1. Setiap baris adalah tarik-ulur antara dua pemakai image yang berbeda. nodemon menghabiskan ukuran untuk membeli siklus edit yang instan; healthcheck memakan ruang metadata untuk membeli kejujuran status bagi operator yang tidak mengawasi log.
  2. Satu image tidak bisa menjadi yang terbaik di keduanya sekaligus. Image yang membawa devDependencies dan test suite akan gemuk di server; image yang telanjang akan menyulitkan setiap sesi development yang mengandalkan tool.
  3. Solusinya bukan dua Dockerfile. Dua resep yang menduplikasi manifest dependensi akan berbeda diam-diam dalam hitungan minggu — satu berganti dependensi, satunya tertinggal. Satu Dockerfile multi-target, sebagaimana pola yang kita bangun pada Chapter Multi-Stage Build dan dilanjutkan Chapter Development Dependencies, menjaga kedua wajah tetap lahir dari satu sumber kebenaran.
  4. Rantai target yang kita tuju berbentuk base → dev → test → runtime — fondasi bersama, wajah development, gerbang uji, lalu wajah produksi yang hanya mewarisi apa yang benar-benar dibutuhkannya.

Dockerfile Empat Target #

Sebelum merakit, Budi merapikan dua berkas kecil agar seluruh kebutuhan di atas punya tempat yang jelas. Struktur folder yang relevan bagi build kini berbentuk:

text
katalog/
├── Dockerfile
├── package.json
├── src/
│   ├── rupiah.js
│   └── server.js
└── test/
    └── unit/
        └── harga.test.js

Manifest package.json memisahkan dua kelas dependensi sejak awal, dan skrip test menunjuk folder unit test yang akan kita pakai penuh pada Chapter Menjalankan Test di Container:

json
{
  "name": "katalog-web",
  "version": "2.0.0",
  "scripts": {
    "dev": "nodemon src/server.js",
    "test": "node --test test/unit/"
  },
  "dependencies": {
    "express": "^4.19.2",
    "pg": "^8.12.0"
  },
  "devDependencies": {
    "nodemon": "^3.1.0"
  }
}

Inilah Dockerfile final yang menggantikan seluruh versi sebelumnya — empat target, dua puluh dua baris:

dockerfile
# syntax=docker/dockerfile:1

FROM node:22-alpine AS base
WORKDIR /app
COPY package.json ./

FROM base AS dev
RUN npm install
COPY src/ ./src/
COPY test/ ./test/
USER node
EXPOSE 3000 9229
CMD ["npx", "nodemon", "src/server.js"]

FROM dev AS test
RUN npm test

FROM base AS runtime
ENV NODE_ENV=production
RUN npm install --omit=dev
COPY --from=test /app/src ./src
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget -qO- localhost:3000/healthz || exit 1
CMD ["node", "src/server.js"]

Diagram berikut memetakan hubungan keempat target — perhatikan bahwa runtime berakar pada base, bukan pada dev:

Mari kita telaah diagram di atas: satu akar bernama base menurunkan dua cabang; cabang dev berlanjut ke test, dan runtime tidak mewarisi apa pun dari dev melainkan hanya menerima hasil uji lewat satu jembatan COPY --from. Seluruh keputusan yang kita ambil sepanjang section berpendar pada struktur ini. Bedah baris demi baris:

  1. FROM node:22-alpine AS base — fondasi bersama ketiga target lainnya. Isinya sengaja minimal: direktori kerja dan manifest dependensi. Selama package.json tidak berubah, layer COPY di sini ter-cache untuk semua build — aturan emas Chapter Build Cache kini bekerja untuk dua image sekaligus.
  2. COPY package.json ./ diletakkan sebelum kode apa pun — perubahan pada src/server.js tidak pernah membatalkan layer npm install, baik pada target dev maupun runtime.
  3. FROM base AS dev — wajah development mewarisi fondasi lalu menjalankan npm install penuh: seluruh dependencies dan devDependencies terpasang, nodemon tersedia, pohon test/ ikut tersalin. Bind mount source code dan volume node_modules dari dua chapter sebelumnya tetap bekerja di atas image ini tanpa satu perubahan pun.
  4. USER node pada target dev — proses pengembangan tetap turun dari root, kebiasaan yang kita paku sejak Chapter WORKDIR dan USER; user node bernilai UID 1000, cocok dengan kepemilikan berkas di mesin Budi sehingga bind mount tetap bisa ditulis.
  5. EXPOSE 3000 9229 — dua port dalam satu instruksi: 3000 untuk aplikasi, 9229 untuk debugger Node yang akan kita nyalakan lewat node --inspect pada Chapter Debugging Container. EXPOSE hanya metadata — penerbitan port tetap urusan compose.
  6. CMD ["npx", "nodemon", "src/server.js"] — perintah bawaan target dev adalah watcher, sehingga container development otomatis memulai ulang aplikasi saat kode berubah; kebiasaan command: node --watch yang selama ini menempel di compose.override.yaml Budi kini boleh dibuang karena image-nya sendiri yang membawanya.
  7. FROM dev AS test + RUN npm test — stage uji mewarisi seluruh isi dev lalu menjalankan unit test saat build berlangsang. Satu test gagal berarti build gagal — image produksi tidak pernah lahir dari kode yang tidak lulus uji.
  8. FROM base AS runtime — bukan FROM dev — inilah baris terpenting di seluruh berkas. Image produksi kembali ke fondasi bersama dan memasang dependensi sendiri lewat npm install --omit=dev: nodemon, pohon devDependencies, dan berkas test tidak pernah menyusup ke runtime.
  9. COPY --from=test /app/src ./src — satu jembatan dengan dua tugas. Pertama, memaksa stage test benar-benar dieksekusi: BuildKit hanya membangun stage yang menjadi dependensi image final, jebakan yang kita buktikan pada NOTE Chapter Multi-Stage Build — tanpa baris ini, RUN npm test akan diam-diam dilewati. Kedua, kode yang berlayar ke runtime adalah salinan yang persis sama dengan yang barusan lulus uji.
  10. HEALTHCHECK tertanam di image — probe mengetuk endpoint /healthz yang kita bangun pada Chapter Healthcheck dan Service Readiness, endpoint yang ikut memeriksa koneksi database. Produksi tidak selalu punya compose.yaml di sampingnya; dengan probe tertanam, setiap container dari image ini melaporkan kesehatannya di mana pun ia berjalan.
  11. CMD ["node", "src/server.js"] dalam exec form — tanpa shell perantara, tanpa watcher; sinyal SIGTERM dari docker stop sampai langsung ke proses Node, persis perilaku yang kita andalkan pada pembahasan Graceful Shutdown.

Membangun Kedua Image #

Dua perintah berikut membangun kedua wajah secara eksplisit — kebiasaan yang kelak juga dipakai mesin CI. Perintah pertama berhenti pada target dev:

bash
$ docker build --target dev -t katalog-web:dev .

Output yang Dihasilkan (dipotong):

text
[+] Building 7.2s (8/8) FINISHED
 => [internal] load build definition from Dockerfile                    0.1s
 => [internal] load metadata for docker.io/library/node:22-alpine       0.7s
 => [1/8] FROM docker.io/library/node:22-alpine                         0.0s
 => [2/8] WORKDIR /app                                                  0.0s
 => [3/8] COPY package.json ./                                          0.1s
 => [4/8] RUN npm install                                               5.1s
 => [5/8] COPY src/ ./src/                                              0.1s
 => [6/8] COPY test/ ./test/                                            0.1s
 => [7/8] USER node                                                     0.0s
 => [8/8] EXPOSE 3000 9229                                              0.0s
 => exporting to image                                                  0.3s
 => => exporting layers                                                 0.2s
 => => writing image sha256:8f3a2c9e5b1d4f6a8c0e2b4d6f8a1c3e5b7d9f2a4c6e8b0d2f4a6c8e0b2d4f6a 0.1s
 => => naming to docker.io/library/katalog-web:dev                      0.0s

Perintah kedua tidak menulis --target sama sekali — build berhenti pada stage terakhir, dan stage terakhir kita adalah runtime:

bash
$ docker build -t katalog-web:2.0 .

Output yang Dihasilkan (dipotong):

text
[+] Building 13.8s (15/15) FINISHED
 => [1/8] FROM docker.io/library/node:22-alpine                         0.0s
 => CACHED [2/8] WORKDIR /app                                           0.0s
 => CACHED [3/8] COPY package.json ./                                   0.0s
 => CACHED [4/8] RUN npm install                                        0.0s
 => CACHED [5/8] COPY src/ ./src/                                       0.0s
 => CACHED [6/8] COPY test/ ./test/                                     0.0s
 => CACHED [7/8] USER node                                              0.0s
 => CACHED [8/8] EXPOSE 3000 9229                                       0.0s
 => [9/15] RUN npm test                                                 3.4s
 => [10/15] ENV NODE_ENV=production                                     0.0s
 => [11/15] RUN npm install --omit=dev                                  5.3s
 => [12/15] COPY --from=test /app/src ./src                             0.1s
 => [13/15] USER node                                                   0.0s
 => [14/15] EXPOSE 3000                                                 0.0s
 => [15/15] HEALTHCHECK &{["CMD-SHELL" "wget -qO- localhost:3000/healthz || exit 1"] 30s 3s 5s 0s 3 false} 0.0s
 => exporting to image                                                  0.4s
 => => exporting layers                                                 0.2s
 => => writing image sha256:4d8b2e7f1a6c9e3b5d7f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b 0.1s
 => => naming to docker.io/library/katalog-web:2.0                      0.0s

Kedua image kini berdiri berdampingan di penyimpanan lokal:

bash
$ docker images katalog-web

Output yang Dihasilkan:

text
REPOSITORY     TAG      IMAGE ID       CREATED          SIZE
katalog-web    2.0      4d8b2e7f1a6c   40 seconds ago   142MB
katalog-web    dev      8f3a2c9e5b1d   4 minutes ago    189MB

Mari kita telaah seluruh rangkaian build di atas:

  1. Stage terakhir adalah target bawaan. Tanpa --target, BuildKit membangun hingga runtime — itulah alasan perintah produksi lebih pendek daripada perintah development.
  2. Kedelapan langkah pertama dilayani CACHED pada build kedua. Fondasi dan wajah dev sudah dibangunkan oleh perintah pertama; build produksi hanya mengerjakan lima langkah baru — uji, ENV, instalasi dependensi ramping, penyalinan kode, dan metadata.
  3. [9/15] RUN npm test benar-benar berjalan. Ini bukti jembatan COPY --from=test bekerja: tiga detik itu adalah harga gerbang yang memastikan tidak ada image produksi dari kode yang gagal uji.
  4. npm install --omit=dev terjadi di cabang yang berbeda. Langkah [11/15] tidak meng-cache RUN npm install milik dev — keduanya adalah vertex berbeda pada DAG BuildKit, masing-masing dengan cache sendiri.
  5. Selisih 47MB adalah seluruh isi world development. Pohon devDependencies nodemon, berkas test, dan dua layer COPY tambahan hanya hidup di katalog-web:dev. Angka 189MB sendiri adalah kelanjutan yang wajar: image dev katalog-web masih 158MB pada Chapter Image, Build, dan Pull — sebelum devDependencies hadir.
  6. 142MB adalah aritmetika yang jujur. Basis node:22-alpine sekitar 130MB ditambah dependensi produksi express dan pg; dibandingkan standar Section Image Build dan Optimisasi, image ini memang bukan yang teramping — Node.js butuh runtime-nya — tetapi setiap megabait yang tidak dibawa adalah megabait yang tidak ditarik setiap server dan tidak menjadi permukaan serangan, argumen yang kita bangun tuntas pada Chapter Mengurangi Ukuran Image.

Dua Wajah di Dua Berkas Compose #

Disiplin dua image menuntut kejelasan di sisi compose juga: berkas mana yang memakai image yang mana. Service web pada compose.yaml kini menulis bentuk panjang build dengan sub-kunci target — bentuk yang kita kenal dari Chapter Image, Build, dan Pull:

yaml
  web:
    build:
      context: .
      target: dev
    image: katalog-web:dev

Kunci lainnya — ports, environment, env_file, depends_on, healthcheck service, bind mount source code, volume node_modules — tidak berubah sedikit pun. Yang berubah hanya satu hal: setiap docker compose up -d --build kini membangun target dev, sehingga stack development tidak pernah menjalankan image runtime secara tidak sengaja. Aturan develop.watch di compose.override.yaml Budi tetap bekerja di atasnya: suntingan package.json dan Dockerfile memicu rebuild, dan karena watcher nodemon kini dibawa CMD image, override tersebut cukup menyimpan aturan pemantauan.

Varian produksi sudah kita miliki sejak Chapter Compose Override dan Environment: berkas compose.prod.yaml yang membuang kunci build dan menggantinya dengan image dari registry:

yaml
services:
  web:
    build: !reset null
    image: budi/katalog-web:${IMAGE_TAG:-latest}
    ports: !override
      - "${BIND_IP:-0.0.0.0}:80:3000"
    restart: unless-stopped

Tag !reset null menghapus kunci build dari hasil penggabungan sehingga server produksi murni bergantung pada image yang ditarik dari registry, dan !override menjadikan pemetaan port 80 sebagai satu-satunya yang diterbitkan. Image budi/katalog-web:2.0 yang baru saja kita bangun adalah kandidat tepat untuk ditempelkan pada variabel IMAGE_TAG — perjalanan naiknya ke registry adalah urusan Section Registry dan Distribusi, dan seluruh upacara deployment-nya dibedah pada Section Deployment dan Production.

Kapan Rebuild Image Mana #

Ritual dua image menjadi lengkap dengan tabel keputusan berikut — kapan sebuah perubahan menuntut image mana dibangun ulang:

Perubahan yang terjadiSelama sesi developmentSaat menyiapkan rilis
Suntingan berkas di src/tanpa rebuild — bind mount dan nodemon yang bekerjadocker build -t katalog-web:2.1 .
Berkas di test/docker build --target dev -t katalog-web:dev . agar test baru masuk image devrebuild runtime — stage test membaca salinan test terbaru
package.json berubahaturan rebuild pada develop.watch mengerjakannyarebuild runtime — dependensi produksi dihitung ulang
Dockerfile berubahaturan rebuild pada develop.watch mengerjakannyarebuild runtime
compose.yaml dan kawan-kawannyatanpa rebuild — cukup docker compose up -dtanpa rebuild — konfigurasi bukan isi image

Mari kita telaah dua kebiasaan yang dituntut tabel di atas. Pertama, kolom development hampir seluruhnya bertuliskan “tanpa rebuild” — seluruh perangkat section ini memang dirancang agar hari-hari pengeditan tidak menyentuh builder; hanya kelas berkas yang menuntut resep baru yang bergerak. Kedua, angka versi pada rilis sebaiknya naik setiap kali image produksi dibangun — 2.0, 2.1, 2.2 — sehingga rollback selalu punya alamat pasti untuk kembali; strategi penamaannya kita bedah tuntas pada Chapter Strategi Versioning Image.

Dua image kini tertata: yang gemuk dan bersenjata lengkap untuk meja kerja, yang ramping dan berhealthcheck untuk server. Namun disiplin image tidak pernah menjamin aplikasi bebas galat — container tetap akan mati, berkas tetap akan hilang, memori tetap akan habis, dan bug logika tetap akan bersembunyi di jalur yang jarang dilalui. Saat-saat itu tiba, kita butuh kemampuan yang berbeda: membuka kotak dari luar tanpa membongkarnya. docker compose logs, exec, inspect, dan debugger yang menempel dari jauh adalah jendela-jendela itu — dan cara memakainya pada empat kecelakaan klasik adalah materi Chapter Debugging Container.

Referensi #