Ritual kebersihan pada chapter penutup Section Docker Image meninggalkan penyimpanan lokal kita dalam kondisi terkendali: docker system df mengukur ruang yang dipakai image, container, dan build cache, lalu perintah prune memangkas semuanya yang menganggur. Namun ada ketimpangan yang mulai terasa: sepanjang dua section terakhir kita berperan sebagai konsumen image — menarik, mengevaluasi, menandai, bahkan membersihkan image buatan orang lain — tanpa pernah sekali pun membuat image sendiri. Padahal kekuatan terbesar Docker justru ada pada kemampuan mengemas aplikasi kita sendiri menjadi image yang bisa dijalankan di mesin mana pun. Chapter ini memindahkan kursi kita dari konsumen ke produsen image, dan kendaraannya adalah satu berkas teks kecil bernama Dockerfile.

Dari Konsumen Image Menjadi Produsen #

Budi memiliki aplikasi Node.js kecil bernama myapp: sebuah server HTTP sederhana yang menyapa setiap pengunjung dengan respons JSON. Selama ini Budi menjalankan myapp di laptopnya dengan perintah node server.js, dan aplikasi itu berjalan baik karena laptop Budi sudah terpasang Node.js. Masalahnya muncul begitu myapp harus dijalankan di tempat lain — laptop Siti, server uji kantor, atau mesin CI — karena setiap mesin harus dipasangi Node.js versi yang sama lebih dulu. Inilah titik di mana image publik yang selama ini kita pakai tidak lagi cukup: node:22-alpine hanya memuat runtime Node.js tanpa satu baris kode Budi pun, sementara nginx memuat web server lengkap yang tidak tahu menahu tentang myapp. Tidak ada image publik di Docker Hub yang berisi kode milik Budi, dan tidak akan pernah ada.

Satu-satunya jalan adalah membangun image sendiri yang memuat kebutuhan lengkap myapp:

  • Runtime — Node.js 22 yang konsisten di mesin mana pun.
  • Berkas aplikasi — server.js dan package.json milik Budi.
  • Perintah start — cara container menyalakan aplikasi.
  • Deklarasi port — dokumentasi port yang didengarkan aplikasi.

Jawaban Docker untuk keempat kebutuhan itu adalah Dockerfile: berkas teks bernama Dockerfile yang berisi daftar instruksi build, ditulis dari atas ke bawah, lalu dieksekusi oleh builder untuk merakit sebuah image. Setiap instruksi menyumbang satu layer berisi berkas atau satu catatan metadata — persis struktur tumpukan yang sudah kita bedah pada Chapter Konsep Image Layer — dan perintah yang membaca resep tersebut adalah docker build, digerakkan BuildKit sebagai engine build bawaan sejak Docker Engine 23.0.

Menyiapkan Folder Aplikasi myapp #

Budi memulai dengan membuat folder khusus untuk proyeknya, lalu mengisinya dengan dua berkas aplikasi dan satu Dockerfile. Struktur foldernya terlihat seperti ini:

bash
$ tree myapp

Output yang Dihasilkan:

text
myapp
├── Dockerfile
├── package.json
└── server.js

Struktur ini sengaja dibuat minimal supaya kita bisa fokus pada Dockerfile-nya; proyek nyata tentu punya lebih banyak berkas, dan justru di situ nanti muncul pertanyaan menarik tentang berkas mana yang layak masuk image. Bila perintah tree belum tersedia di mesin latihan, padanan ls -R myapp menampilkan struktur yang sama. Mari kita isi kedua berkas aplikasinya terlebih dahulu.

Berkas server.js adalah seluruh logika aplikasi myapp, dan isinya cukup ringkas:

js
const http = require("http");

const server = http.createServer((req, res) => {
  if (req.url === "/") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ app: "myapp", pesan: "Halo dari container" }));
    return;
  }
  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "Rute tidak ditemukan" }));
});

server.listen(8000, () => {
  console.log("myapp mendengarkan permintaan di port 8000");
});

Mari kita telaah isi server.js di atas:

  1. require("http") — memuat modul http bawaan Node.js; myapp tidak memakai satu pun paket eksternal, sehingga tidak ada daftar dependencies yang harus dipasang.
  2. http.createServer — membuat server HTTP dengan fungsi callback yang dipanggil untuk setiap permintaan masuk.
  3. Rute / — permintaan ke path / dijawab dengan status 200 dan JSON berisi nama aplikasi serta sapaan; path lain dijawab 404 dengan JSON pesan kesalahan.
  4. server.listen(8000) — server mulai mendengarkan port 8000 dan mencetak satu baris log sebagai penanda bahwa aplikasi siap menerima permintaan.

Berkas package.json mendeskripsikan aplikasi bagi ekosistem Node.js:

json
{
  "name": "myapp",
  "version": "1.0.0",
  "private": true,
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  }
}

Mari kita telaah berkas package.json di atas:

  1. name dan version — identitas aplikasi; versi 1.0.0 akan kita pakai juga sebagai tag image sebentar lagi.
  2. private: true — menandai myapp sebagai proyek internal yang tidak boleh dipublikasikan ke registry npm.
  3. scripts.start — menyediakan pintasan npm start yang menjalankan node server.js; Dockerfile kita akan memakai bentuk perintah langsung agar jalur prosesnya lebih bersih.

Karena myapp tidak punya dependensi eksternal, Dockerfile kita tidak perlu memasang apa pun — cukup menyalin dua berkas ini. Aplikasi nyata yang memakai framework seperti Express tentu membawa node_modules, dan urutan penyalinan berkasnya menjadi urusan yang cukup penting untuk dibedah tersendiri pada Chapter COPY dan ADD.

Dockerfile Pertama Budi #

Sekarang saatnya berkas yang menjadi inti section ini. Dockerfile pertama Budi hanya berisi lima instruksi — sengaja paling sederhana yang bisa dipakai, tanpa satu pun teknik optimasi; urusan mengoptimalkan build adalah wilayah Section Image Build dan Optimisasi:

dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package.json server.js ./
EXPOSE 8000
CMD ["node", "server.js"]

Mari kita telaah kelima instruksi di atas:

  1. FROM node:22-alpine — menentukan image dasar tempat myapp dibangun; seluruh layer node:22-alpine (sekitar 130MB) menjadi fondasi tumpukan, dan Node.js 22 ikut tersedia di dalam image final. Varian alpine dipilih karena jauh lebih ramping daripada basis Debian.
  2. WORKDIR /app — menetapkan /app sebagai direktori kerja untuk semua instruksi setelahnya; direktori ini dibuat otomatis bila belum ada, dan seluruh path relatif di Dockerfile mengacu ke sana.
  3. COPY package.json server.js ./ — menyalin dua berkas dari build context ke direktori kerja /app; ./ berarti “direktori kerja saat ini”, jadi keduanya mendarat di /app/package.json dan /app/server.js.
  4. EXPOSE 8000 — mendokumentasikan bahwa aplikasi mendengarkan port 8000; instruksi ini murni catatan metadata, ia tidak menerbitkan port apa pun ke host — penerbitan tetap tugas flag -p saat docker run, dan kita bedah tuntas hubungan keduanya pada Chapter EXPOSE dan Port.
  5. CMD ["node", "server.js"] — perintah bawaan yang dijalankan container saat start; bentuk kurung siku ini disebut exec form, dan alasan kuat mengapa ia lebih baik dari bentuk shell akan kita bedah pada Chapter CMD dan ENTRYPOINT.

Membangun Image dengan docker build #

Dengan ketiga berkas siap, Budi masuk ke dalam folder myapp lalu menjalankan build pertamanya:

bash
$ cd myapp
$ docker build -t myapp:1.0 .

Mari kita telaah argumen perintah di atas:

  1. -t myapp:1.0 — nama dan tag untuk image hasil build; polanya sama seperti image yang kita tarik dari Docker Hub, yaitu repositori:tag.
  2. . (satu titik) — path build context, yaitu folder yang isinya dikirim ke builder sebagai bahan mentah build; titik berarti “folder saat ini”, sehingga seluruh isi folder myapp ikut tersedia untuk instruksi COPY. Mekanisme context ini punya konsekuensi menarik yang kita bedah mendalam pada Chapter docker build dan Build Context di Section Image Build dan Optimisasi.

Build Pertama #

Output yang Dihasilkan:

text
[+] Building 9.2s (11/11) FINISHED
 => [internal] load build definition from Dockerfile                    0.1s
 => => transferring dockerfile: 102B                                    0.0s
 => [internal] load metadata for docker.io/library/node:22-alpine       2.1s
 => [1/4] FROM docker.io/library/node:22-alpine                         4.3s
 => => resolve docker.io/library/node:22-alpine                         2.1s
 => [2/4] WORKDIR /app                                                  0.0s
 => [3/4] COPY package.json server.js ./                                0.1s
 => => transferring context: 722B                                       0.0s
 => [4/4] EXPOSE 8000                                                   0.0s
 => exporting to image                                                  0.3s
 => => exporting layers                                                 0.2s
 => => writing image sha256:05303e530e6f71e59072f3b3a7693c74dcc0d22fea8b9403c056c86b3f83bdec 0.1s
 => => naming to docker.io/library/myapp:1.0                            0.0s

Mari kita telaah baris-baris output BuildKit di atas:

  1. load build definition + transferring dockerfile: 102B — BuildKit membaca berkas Dockerfile sepanjang 102 byte sebagai definisi build.
  2. load metadata — BuildKit bertanya ke Docker Hub: digest mana yang saat ini ditunjuk tag node:22-alpine; langkah ini penting karena tag bisa berpindah, jebakan yang sudah kita bahas pada Chapter Memahami Image Tag.
  3. [1/4] FROM — langkah termahal: 4,3 detik mencakup pengunduhan seluruh layer node:22-alpine sekitar 130MB yang belum tersimpan di lokal; progres unduhannya diringkas oleh BuildKit di balik satu angka durasi.
  4. [2/4] WORKDIR — eksekusi nyaris instan karena hanya membuat direktori dan mencatat metadata.
  5. [3/4] COPY + transferring context: 722B — build context sebesar 722 byte (gabungan Dockerfile, package.json, dan server.js) dikirim ke builder, lalu dua berkas aplikasi disalin menjadi satu layer baru seukuran 620 byte di atas layer basis.
  6. [4/4] EXPOSE — mencatat metadata port, tanpa isi layer.
  7. exporting to image + writing image sha256:0530... — seluruh layer dirakit menjadi image final dengan ID sha256:05303e53...; perhatikan bahwa CMD tidak tampil sebagai langkah bernomor karena perintah itu dicatat pada konfigurasi image saat ekspor.
  8. naming to docker.io/library/myapp:1.0 — tag myapp:1.0 ditempelkan pada image yang barusan lahir.

Build Kedua dan Build Cache #

Budi penasaran apa yang terjadi bila build dijalankan ulang tanpa perubahan apa pun. Kali ini kita menambahkan flag --progress=plain supaya setiap langkah terbaca baris per baris:

bash
$ docker build --progress=plain -t myapp:1.0 .

Output yang Dihasilkan:

text
#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 102B done
#1 DONE 0.1s
#2 [internal] load metadata for docker.io/library/node:22-alpine
#2 DONE 0.8s
#3 [1/4] FROM docker.io/library/node:22-alpine
#3 resolve docker.io/library/node:22-alpine 0.8s done
#3 CACHED
#4 [2/4] WORKDIR /app
#4 CACHED
#5 [3/4] COPY package.json server.js ./
#5 CACHED
#6 [4/4] EXPOSE 8000
#6 CACHED
#7 exporting to image
#7 exporting layers done
#7 writing image sha256:05303e530e6f71e59072f3b3a7693c74dcc0d22fea8b9403c056c86b3f83bdec done
#7 naming to docker.io/library/myapp:1.0 done

Mari kita telaah perbedaan penting pada output kedua ini:

  1. Baris DONE dan CACHED — setiap langkah yang selesai ditandai DONE, dan langkah yang isinya sama persis dengan build sebelumnya dilayani dari build cache dengan tanda CACHED, tanpa dieksekusi ulang.
  2. Durasi total yang menyusut drastis — build pertama memakan 9,2 detik karena didominasi unduhan basis, sedangkan build kedua selesai di bawah satu detik.
  3. sha256 yang identik — image yang ditulis bernilai sha256:05303e53... persis seperti build pertama; input yang identik menghasilkan image yang identik, wujud nyata sifat content-addressable dari Chapter Konsep Image Layer.
  4. Konsekuensi praktis — build ulang menjadi murah, sehingga siklus sunting kode → build → uji terasa cepat sepanjang pengembangan.

Image Buatan Sendiri di Penyimpanan Lokal #

Image baru kini tinggal di penyimpanan lokal bersama image lain yang kita kenal:

bash
$ docker images myapp

Output yang Dihasilkan:

text
REPOSITORY   TAG   IMAGE ID       CREATED              SIZE
myapp        1.0   05303e530e6f   About a minute ago   130MB

Mari kita telaah baris di atas:

  1. IMAGE ID 05303e530e6f — 12 karakter pertama dari sha256:05303e53... yang tampil pada output build; inilah bukti bahwa output BuildKit dan tabel docker images menunjuk image yang sama.
  2. SIZE 130MB — ukuran yang tampak sama dengan node:22-alpine karena layer aplikasi hanya menambah 620 byte, terlalu kecil untuk menggeser pembulatan ke atas; kolom SIZE memang menjumlahkan seluruh layer tanpa membedakan milik siapa, kebiasaan yang sudah kita bedah pada Chapter Melihat Image Lokal.
  3. CREATED About a minute ago — waktu image dibangun, bukan waktu basisnya dibuat di Docker Hub.

Menjalankan dan Menguji Container #

Saatnya momen yang dinanti Budi: menjalankan image buatan sendiri sebagai container:

bash
$ docker run -d --name myapp -p 8000:8000 myapp:1.0

Output yang Dihasilkan:

text
2776f838d0568a72540f38a764133003dba9d712c98f0f5150b1b80643bff83c

Mari kita telaah perintah di atas:

  1. -d dan --name myapp — container berjalan di belakang layar dengan nama myapp, mengikuti kebiasaan penamaan eksplisit yang kita pakai sejak Section Dasar Docker CLI.
  2. -p 8000:8000 — menerbitkan port 8000 milik container ke port 8000 di host; inilah yang benar-benar membuka akses, sementara EXPOSE di Dockerfile hanya dokumentasi.
  3. 64 karakter hex pada output — ID container yang baru dibuat; nilainya berbeda dari ID image karena container dan image adalah objek yang berbeda, meski saling terkait.

Budi lalu menguji aplikasinya dari host dengan curl:

bash
$ curl localhost:8000

Output yang Dihasilkan:

text
{"app":"myapp","pesan":"Halo dari container"}

Mari kita telaah hasil di atas:

  1. JSON sapaan myapp — aplikasi di dalam container merespons persis seperti saat dijalankan langsung di laptop Budi; runtime, berkas, dan perintah start semuanya bekerja dari dalam image.
  2. Kode yang berjalan adalah salinan di dalam image — server.js yang dikerjakan container adalah salinan yang dibekukan saat build; Budi bisa mengubah server.js di foldernya sebanyak apa pun, container yang berjalan tidak akan berubah sedikit pun sampai image dibangun ulang dan container dijalankan dari image baru. Inilah siklus kerja inti pemilik image: sunting kode, build ulang, jalankan ulang.

Dengan lima instruksi dan dua perintah, Budi kini resmi menjadi produsen image: myapp bisa dikirim ke laptop Siti atau server uji kantor tanpa memasang Node.js di sana. Namun Dockerfile yang kita tulis masih terasa seperti teka-teki singkat — baru lima dari tujuh belas instruksi yang dikenal Dockerfile, belum ada komentar, belum ada aturan penulisan yang rapi. Sebelum melanjutkan ke instruksi-instruksi berikutnya, kita perlu peta lengkap anatomi Dockerfile: apa saja instruksinya, mana yang menghasilkan layer, dan bagaimana menyusunnya agar tetap terbaca — semuanya materi Chapter Struktur Dockerfile.

Referensi #