Perintah container kini lengkap kita pegang: CMD memberi nilai bawaan yang mudah ditimpa argumen docker run, ENTRYPOINT menambatkan perintah utama container, dan exec form memastikan sinyal docker stop sampai langsung ke proses node tanpa perantara shell. Dengan bekal itu, Dockerfile myapp sudah tahu cara menyala dengan benar. Namun satu baris kecil di dalamnya masih menyimpan miskonsepsi yang paling sering menjebak pemula Docker: EXPOSE 8000. Budi sempat yakin baris itulah yang membuka aplikasi ke dunia luar — sampai ia menyadari container pertamanya bisa diakses justru karena flag -p 8000:8000 pada docker run, bukan karena EXPOSE. Chapter ini membedah instruksi tersebut sampai ke akarnya: apa yang benar-benar dilakukan EXPOSE, di mana jejaknya tersimpan pada metadata image, dan bagaimana penerbitan port yang sesungguhnya bekerja saat runtime.

EXPOSE Adalah Dokumentasi Bukan Pintu Terbuka #

Instruksi EXPOSE hanya punya satu tugas: menuliskan pada konfigurasi image bahwa aplikasi di dalamnya mendengarkan port tertentu. Baris EXPOSE 8000 tidak membuka socket, tidak mengatur firewall, dan tidak membuat port apa pun dapat dihubungi dari luar container — semua itu bukan wilayah kerjanya. EXPOSE adalah metadata murni, satu keluarga dengan LABEL dan ENV: instruksi tanpa isi layer yang hanya menulis catatan pada konfigurasi image, persis pembagian yang kita pelajari pada Chapter Struktur Dockerfile. Anggaplah EXPOSE sebagai stiker pada kotak kardus bertuliskan “aplikasi ini mendengarkan port 8000”: siapa pun yang memegang image itu tahu port mana yang perlu diterbitkan, tetapi stiker tidak pernah menghasilkan kabel yang benar-benar tersambung.

Membaca Jejak EXPOSE pada Konfigurasi Image #

Karena murni metadata, jejak EXPOSE dapat kita baca langsung dari image hasil build, memakai docker image inspect dengan flag --format yang sudah akrab dari Chapter Image Inspect dan History:

bash
$ docker image inspect myapp:1.1 --format '{{.Config.ExposedPorts}}'

Output yang Dihasilkan:

text
map[8000/tcp:{}]

Mari kita telaah output satu baris di atas:

  1. .Config.ExposedPorts — field konfigurasi image tempat seluruh deklarasi EXPOSE disimpan; nilainya berupa map dengan satu kunci per port.
  2. 8000/tcp — pasangan port dan protokol; sufiks /tcp adalah protokol bawaan yang ditambahkan otomatis karena Dockerfile kita tidak menyebut protokol secara eksplisit.
  3. {} setelah titik dua — nilai kunci kosong: tidak ada konfigurasi jaringan apa pun yang menempel pada deklarasi ini. EXPOSE benar-benar hanya catatan.

Jejak yang sama juga tampil pada riwayat build. Perintah docker history membaca image dari langkah termuda, dan baris EXPOSE muncul di antara instruksi Dockerfile kita:

bash
$ docker history myapp:1.1

Output yang Dihasilkan:

text
IMAGE          CREATED       CREATED BY                                     SIZE    COMMENT
c63616cb491b   2 hours ago   CMD ["node" "server.js"]                       0B
<missing>      2 hours ago   EXPOSE 8000                                    0B
<missing>      2 hours ago   COPY package.json server.js ./                 620B
<missing>      2 hours ago   WORKDIR /app                                   0B
<missing>      2 hours ago   LABEL versi=1.1 deskripsi=API kecil milik Budi   0B
...

Output di atas terpotong — baris-baris milik node:22-alpine di bawahnya dilewati karena sudah kita bedah pada Chapter Struktur Dockerfile. Mari kita telaah bagian yang tampil:

  1. EXPOSE 8000 bernilai 0B — instruksi ini tidak menyumbang satu byte berkas pun; ia metadata murni, sama seperti CMD, WORKDIR, dan LABEL yang semuanya juga 0B.
  2. Penanda (nop) pada image lawas — image lama yang dibangun builder generasi sebelum BuildKit menampilkan baris serupa sebagai /bin/sh -c #(nop) EXPOSE 8000; penanda #(nop) adalah cara builder lama berkata “instruksi ini tidak melakukan operasi filesystem”. Baris paling bawah riwayat node:22-alpine pada Chapter Struktur Dockerfile memperlihatkan pola #(nop) itu, sedangkan BuildKit menampilkan instruksi apa adanya.
  3. Hanya COPY yang berisi — 620B berkas aplikasi; pembagian “tiga instruksi berisi layer, sisanya metadata” tetap berlaku pada setiap image yang kita periksa.

Membuktikan EXPOSE Tidak Menerbitkan Port #

Bukti paling meyakinkan datang dari percobaan sederhana. Budi memulai dengan membersihkan container latihan dari chapter sebelumnya lewat docker rm -f — kebiasaan yang kita bawa sejak Section Dasar Docker CLI — lalu menjalankan myapp tanpa satu pun flag port:

bash
$ docker run -d --name myapp-sunyi myapp:1.1

Output yang Dihasilkan:

text
4c9a7e2b1d8f3a6c5e0b7d2f4a6c8e1b3d5f7a9c2e4b6d8f0a2c4e6b8d0f2a4c

Container hidup, aplikasi di dalamnya mendengarkan port 8000, dan EXPOSE 8000 tercatat rapi pada konfigurasi image. Sekarang Budi mencoba menghubunginya dari host:

bash
$ curl localhost:8000

Output yang Dihasilkan:

text
curl: (7) Failed to connect to localhost port 8000 after 0 ms: Couldn't connect to server

Koneksi ditolak: tidak ada apa pun yang mendengarkan port 8000 di sisi host. Kolom PORTS pada docker ps mempertegas keadaan itu:

bash
$ docker ps

Output yang Dihasilkan:

text
CONTAINER ID   IMAGE       COMMAND          CREATED              STATUS              PORTS      NAMES
4c9a7e2b1d8f   myapp:1.1   "node server.js" About a minute ago   Up About a minute   8000/tcp   myapp-sunyi

Mari kita telaah bukti-bukti di atas:

  1. 8000/tcp tanpa tanda panah — port tercatat terekspos karena deklarasi EXPOSE, tetapi tidak ada pemetaan ke host. Bandingkan dengan baris cache milik Redis pada Chapter Melihat Container yang menampilkan 6379/tcp dengan wajah persis sama.
  2. curl gagal — port 8000 milik container hidup di network namespace tersendiri; host tidak memiliki pintu menuju ke sana selama tidak ada pemetaan yang dibuat.
  3. EXPOSE tidak berbuat apa pun di sini — deklarasinya ada di image, container-nya jalan, tetapi akses tetap tertutup rapat. Inilah bukti bahwa EXPOSE bukan penerbit port.

Menerbitkan Port dengan -p #

Yang benar-benar membuka pintu adalah penerbitan port (port publishing) — operasi runtime yang memetakan port host ke port container. Kita sudah memakainya sejak docker run pertama untuk myapp pada Chapter Membangun Image dengan Dockerfile; kali ini Budi melakukannya dengan kesadaran penuh akan mekanismenya:

bash
$ docker run -d --name myapp-terbit -p 8000:8000 myapp:1.1

Output yang Dihasilkan:

text
7f1c3e9b5d2a0b8e4d6f1a3c7e5b9d0f2a4c6e8b1d3f5a7c9e1b3d5f7a9c2e4b
bash
$ curl localhost:8000

Output yang Dihasilkan:

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

Mari kita telaah apa yang berubah:

  1. -p 8000:8000 — pola host:container; permintaan yang datang ke port 8000 di host diteruskan daemon ke port 8000 di container. Pemetaan ini dibuat Docker daemon saat container diciptakan, dan inilah yang dinamakan port publishing.
  2. curl berhasil — pemetaan port, bukan EXPOSE, yang membuat permintaan host sampai ke aplikasi di dalam container.
  3. Kedua container berjalan berdampingan — myapp-sunyi dan myapp-terbit sama-sama mendengarkan 8000 di namespace masing-masing tanpa bentrok; hanya myapp-terbit yang punya jembatan ke host.
  4. Variasi lengkap flag -p — mengikat alamat IP tertentu, protokol udp, sampai rentang port — adalah materi Chapter Port Publishing di Section Konfigurasi Container; prinsipnya cukup satu: penerbitan terjadi saat runtime, sedangkan EXPOSE hanyalah catatan.

Flag -P Menerbitkan Seluruh Daftar EXPOSE #

Flag -P (huruf besar, bentuk panjangnya --publish-all) adalah jembatan antara dokumentasi dan penerbitan. Flag ini memerintahkan daemon menerbitkan seluruh port yang dideklarasikan EXPOSE pada image — setiap port mendapat port host acak dari rentang port tinggi yang sedang tidak terpakai. Di sinilah nilai praktis EXPOSE terlihat: daftar yang ditulis penulis Dockerfile menjadi daftar yang diterbitkan otomatis:

bash
$ docker run -d --name myapp-acak -P myapp:1.1

Output yang Dihasilkan:

text
9b2e5d8a1c4f7e3b6d0a8f2c5e7b1d4f8a3c6e0b2d9f4a7c1e5b8d3f0a6c2e4b

Port host mana yang dipilih daemon untuk container ini? Perintah docker port membacanya untuk kita:

bash
$ docker port myapp-acak

Output yang Dihasilkan:

text
8000/tcp -> 0.0.0.0:32771
8000/tcp -> [::]:32771
bash
$ curl localhost:32771

Output yang Dihasilkan:

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

Mari kita telaah rangkaian di atas:

  1. 8000/tcp -> 0.0.0.0:32771 — port 8000 milik container dipetakan ke port host 32771 di seluruh antarmuka IPv4; baris kedua adalah pasangan IPv6-nya dengan tanda [::].
  2. Angka 32771 dipilih acak — daemon mengambil port tinggi yang kosong; nilainya bisa berbeda pada setiap mesin dan setiap docker run. Selalu baca hasil pemetaan lewat docker port atau kolom PORTS, jangan berasumsi angkanya tetap.
  3. curl berhasil lewat port acak — penerbitan -P membaca satu-satunya daftar yang tersedia, yaitu EXPOSE pada image. Tanpa EXPOSE, flag -P tidak punya apa pun untuk diterbitkan.

Ketiga container tadi kini berdiri berdampingan dan memperlihatkan seluruh wajah port sekaligus dalam satu tabel:

bash
$ docker ps

Output yang Dihasilkan:

text
CONTAINER ID   IMAGE       COMMAND          CREATED              STATUS              PORTS                                          NAMES
4c9a7e2b1d8f   myapp:1.1   "node server.js" 3 minutes ago        Up 3 minutes        8000/tcp                                       myapp-sunyi
7f1c3e9b5d2a   myapp:1.1   "node server.js" 2 minutes ago        Up 2 minutes        0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp    myapp-terbit
9b2e5d8a1c4f   myapp:1.1   "node server.js" About a minute ago   Up About a minute   0.0.0.0:32771->8000/tcp, [::]:32771->8000/tcp   myapp-acak

Mari kita telaah ketiga barisnya sebagai satu kesatuan pelajaran:

  1. Tiga container dari image yang sama — myapp-sunyi, myapp-terbit, dan myapp-acak menjalankan myapp:1.1 yang identik; perbedaan akses di antara mereka murni buatan flag runtime.
  2. myapp-sunyi hanya 8000/tcp — deklarasi EXPOSE terlihat tanpa pemetaan; container sepenuhnya tertutup dari host.
  3. myapp-terbit memakai -p tetap — port host 8000 dipetakan eksplisit oleh operator.
  4. myapp-acak memakai -P acak — daftar EXPOSE diterbitkan ke port host acak; pola yang sama akan bekerja otomatis bila kelak image mengekspos lima port sekaligus.

EXPOSE untuk Beberapa Port dan Rentang #

Satu instruksi EXPOSE boleh memuat lebih dari satu port, lengkap dengan rentang dan protokol. Bentuk-bentuk sahnya terlihat seperti ini:

dockerfile
EXPOSE 8000 8443
EXPOSE 7000-7002
EXPOSE 53/udp

Mari kita telaah ketiga baris di atas:

  1. EXPOSE 8000 8443 — dua port dipisah spasi dalam satu instruksi; aplikasi dengan jalur HTTP dan HTTPS adalah contoh paling lazimnya.
  2. EXPOSE 7000-7002 — rentang tiga port; flag -P akan menerbitkan setiap anggota rentang ke port host acak masing-masing.
  3. EXPOSE 53/udp — protokol eksplisit /udp; tanpa sufiks protokol, tcp adalah nilai bawaan.
  4. Semuanya tetap catatan — aturan “dokumentasi, bukan penerbitan” berlaku sama untuk bentuk apa pun; akses baru terbuka saat operator menerbitkan port saat runtime.

EXPOSE versus Publish #

Perbedaan kedua sisi ini layak dirangkum dalam satu tabel: miskonsepsi EXPOSE begitu umum sehingga tabel berikut sebaiknya diperlakukan sebagai kartu koreksi.

AspekEXPOSE di DockerfilePublish dengan -p atau -P
Siapa yang bertindakBuilder, saat image dibangunDocker daemon, saat container dibuat
Kapan terjadiSekali per buildSetiap kali docker run
Efek jaringanTidak ada — catatan pada konfigurasi imagePemetaan port host-container benar-benar dibuat
Di mana terlihatdocker image inspect dan docker historydocker ps kolom PORTS dan docker port
Hubungan keduanyaMenjadi daftar yang dibaca flag -P-p memakai nilai eksplisit; -P membaca daftar EXPOSE

Dengan demikian posisi EXPOSE akhirnya jelas: ia berbicara kepada pembaca image dan tool otomatis, sementara keputusan akses sepenuhnya berpindah ke tangan operator saat container dijalankan. Pertanyaan berikutnya mengurus dua hal yang juga menentukan kenyamanan image di tangan orang lain: di dalam filesystem image, direktori mana yang menjadi rumah bagi berkas aplikasi, dan sebagai user siapa proses container berjalan. WORKDIR dan USER menjawab keduanya dengan pola yang sudah akrab bagi kita — instruksi yang menetapkan konteks bagi semua baris setelahnya — dan itulah bahasan Chapter WORKDIR dan USER.

Referensi #