Rumus pada Chapter Prinsip Keamanan Container menutup dengan satu kata yang murah tetapi jarang dilakukan: identitas. Sejak chapter pertama course ini, hampir semua container kita berjalan sebagai root — bukan karena keputusan sadar, melainkan karena Docker membiarkannya begitu: kecuali image-nya sendiri menurunkan identitas lewat instruksi USER, setiap container menyala sebagai user nomor 0. Chapter ini membedah kebiasaan bawaan tersebut, membangun pola penurunan identitas yang benar di Dockerfile, menimpanya dari sisi runtime dengan --user, lalu menyiapkan kita menghadapi tiga jebakan yang paling sering menjatuhkan tim yang baru pindah ke container non-root: volume milik root, port di bawah 1024, dan image resmi yang ternyata sudah menurunkan dirinya sendiri.

Default yang Mahal: Semua Berjalan sebagai Root #

Kita buktikan dulu bahwa default-nya memang begitu. Image nginx dijalankan tanpa aplikasi apa pun — kita ganti perintah bawaannya dengan whoami, pola command override yang sudah kita pegang sejak Section Dasar Docker CLI:

bash
$ docker run --rm nginx:1.29 whoami

Output yang Dihasilkan:

text
root

Mari kita telaah satu kata root di atas dan rantai risiko yang dibawanya:

  1. Image nginx tidak memuat instruksi USER — perintah whoami berjalan sebagai proses pertama container dengan identitas bawaan Docker: UID 0. Proses pengganti apa pun yang kita jalankan dari image ini mewarisi identitas yang sama.
  2. Master nginx yang sesungguhnya pun berjalan sebagai root — image nginx melakukannya dengan sengaja: master root membuka port 80 dan membaca konfigurasi, lalu menurunkan setiap worker ke UID 101 lewat directive nginx sendiri; hanya pekerja yang turun, pengawasnya tetap root — pola yang sudah kita amati lewat docker top pada Chapter User dan Permissions.
  3. Rantai risikonya tersusun dari tiga mata rantai — pertama, bug pada aplikasi (misalnya path traversal atau eksekusi kode arbitrer) memberi penyerang shell di dalam container; kedua, shell itu mendarat sebagai UID 0 dengan dua belas capability bawaan, cukup untuk menulis backdoor ke writable layer, mengubah kepemilikan berkas, dan mengikat port mana pun; ketiga, penyerang tinggal menunggu celah runc atau kernel untuk melompat ke host — persis skenario WARNING pada chapter sebelumnya.
  4. Mata rantai termurah untuk diputus adalah yang pertama — shell hasil eksploitasi yang mendarat sebagai user biasa tidak bisa menulis ke berkas milik root, tidak bisa mengikat port rendah, dan daya rusaknya terkurung jauh lebih dini.

Pola penurunan yang sehat dikerjakan saat build, ketika kita masih memegang kendali penuh atas isi image. Kita bangun image latihan berbasis ubuntu:24.04 dengan satu user aplikasi bernama app:

dockerfile
FROM ubuntu:24.04

RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

RUN groupadd -g 1000 app && useradd -u 1000 -g app -m app

RUN mkdir -p /app && chown app:app /app

WORKDIR /app

USER app

CMD ["bash"]
bash
$ docker build -t aplikasi:nonroot .

Output yang Dihasilkan:

text
[+] Building 14.2s (10/10) FINISHED
 => [internal] load build definition from Dockerfile                    0.1s
 => => transferring dockerfile: 216B                                    0.0s
 => [internal] load metadata for docker.io/library/ubuntu:24.04        2.1s
 => [1/6] FROM docker.io/library/ubuntu:24.04                          1.4s
 => => resolve docker.io/library/ubuntu:24.04                          1.3s
 => [2/6] RUN apt-get update  && apt-get install -y --no-install-re...  9.6s
 => [3/6] RUN groupadd -g 1000 app && useradd -u 1000 -g app -m app    0.4s
 => [4/6] RUN mkdir -p /app && chown app:app /app                      0.3s
 => [5/6] WORKDIR /app                                                 0.0s
 => [6/6] USER app                                                     0.0s
 => exporting to image                                                 0.4s
 => => exporting layers                                                0.3s
 => => writing image sha256:4e8a1c5f9b3d7e2a6c0f8d4b1e7a3c5f9b2d6e8a0c4f7b1e3d5a9c2f6b8d0e4a  0.0s
 => => naming to docker.io/library/aplikasi:nonroot                    0.0s

Mari kita telaah Dockerfile di atas poin per poin:

  1. Angka UID dan GID ditulis eksplisit — -g 1000 pada groupadd dan -u 1000 pada useradd mematok nomor secara pasti; kernel membandingkan angka, bukan nama, saat memeriksa izin berkas, sehingga nomor yang tetap membuat berkas di volume selalu cocok dengan pemiliknya. Konflik angka antara image dan volume adalah masalah klasik yang kita bedah tuntas pada Chapter Permission dan Ownership Volume.
  2. apt-get diletakkan sebelum USER — pemasangan paket menuntut hak root untuk menulis ke /var/lib/apt; seluruh pekerjaan ber-hak-root harus dikerjakan selagi build masih berjalan sebagai root. apt-get yang diletakkan setelah USER app akan gagal dengan galat izin.
  3. WORKDIR dan kepemilikannya diurus sebelum turun — direktori yang dibuat WORKDIR selagi build masih root akan dimiliki root; mkdir -p /app && chown app:app /app mendahului WORKDIR /app supaya user app berhak menulis di rumahnya sendiri. Untuk berkas aplikasi, padanannya adalah COPY --chown=app:app . ..
  4. Flag -m pada useradd membuat home directory — /home/app tersedia untuk aplikasi yang menyimpan konfigurasi atau cache di HOME; tanpa -m, tool semacam git atau npm bisa gagal mencari rumahnya.
  5. Kenapa angka, bukan sekadar nama — useradd app tanpa -u menerima nomor otomatis berikutnya (misalnya 1000 atau 1001) tergantung isi image; nomor yang bisa bergeser antar build adalah bom waktu kepemilikan volume. Nama enak dibaca manusia, angka yang dipercaya kernel — kebiasaan yang kita sepakati sejak Chapter WORKDIR dan USER.

Identitas hasil build kita buktikan langsung dengan dua perintah pemeriksaan, plus satu uji tulis ke /app:

bash
$ docker run --rm aplikasi:nonroot whoami

Output yang Dihasilkan:

text
app
bash
$ docker run --rm aplikasi:nonroot id

Output yang Dihasilkan:

text
uid=1000(app) gid=1000(app) groups=1000(app)
bash
$ docker run --rm aplikasi:nonroot sh -c "touch /app/tanda && ls -l /app"

Output yang Dihasilkan:

text
total 0
-rw-r--r-- 1 app app 0 Oct 13 10:12 tanda

Tiga jawaban itu menutup kontrak Dockerfile: proses berjalan sebagai app bernomor 1000, dan user tersebut benar-benar berhak menulis di /app — bukti bahwa urutan chown sebelum USER bekerja seperti dirancang.

Menimpa Identitas saat Runtime #

Instruksi USER pada image bukan kata terakhir. Flag --user (bentuk pendek -u) menimpa identitas proses utama container per jalannya — tanpa menyentuh image sama sekali, mekanisme yang kita kenal dari Chapter User dan Permissions:

bash
$ docker run --rm --user 1234:1234 aplikasi:nonroot id

Output yang Dihasilkan:

text
uid=1234 gid=1234

Timpaan bekerja dua arah, dan arah yang jarang disadari adalah naik:

bash
$ docker run --rm --user 0:0 aplikasi:nonroot whoami

Output yang Dihasilkan:

text
root

Mari kita telaah dua arah timpaan tersebut:

  1. --user 1234:1234 menimpa USER app dari image — proses berjalan sebagai UID 1234 meski Dockerfile menyebut app; image tidak berubah sedikit pun.
  2. --user 0:0 bisa menaikkan kembali ke root — penurunan di Dockerfile bukan tembok penjara bagi operator; siapa pun yang menjalankan container berhak menimpanya kembali.
  3. Konsekuensi keamanannya jujur dan penting — USER di Dockerfile melindungi kita dari bug aplikasi: proses yang berhasil ditembus tetap mendarat sebagai user biasa. USER tidak melindungi kita dari operator yang tidak dipercaya — tetapi operator yang bisa menjalankan docker run toh sudah memegang kendali setara root di host lewat docker.sock, pembahasan yang menunggu di Chapter Docker Socket Security.

Gotcha: Kernel Memakai Angka, Bukan Nama #

Menimpa identitas dengan angka yang tidak dikenal image memunculkan kejutan yang membingungkan pemula. Kita pakai image alpine yang tidak punya user bernomor 1000:

bash
$ docker run --rm --user 1000:1000 alpine:3.20 whoami

Output yang Dihasilkan:

text
whoami: unknown uid 1000
bash
$ docker run --rm --user 1000:1000 alpine:3.20 id

Output yang Dihasilkan:

text
uid=1000 gid=1000

Mari kita telaah pasangan yang tampak bertentangan itu:

  1. Proses benar-benar berjalan sebagai UID 1000 — perintah id membuktikan angkanya; penimpaan identitas tidak gagal sama sekali.
  2. whoami gagal menerjemahkan, bukan kernel menolak menjalankan — tool whoami mencari entri bernomor 1000 di /etc/passwd milik image alpine, tidak menemukannya, lalu menyerah; keluarga galat semacam ini sudah kita kenali polanya pada Chapter Memahami Exit Code.
  3. Solusinya persis pola Dockerfile di atas — daftarkan user di dalam image supaya angka punya nama, atau pilih angka yang memang sudah dikenal image. Aplikasi yang membaca HOME atau nama usernya sendiri adalah yang paling cepat tersandung gotcha ini.

Jebakan Volume Milik Root #

Penurunan identitas paling sering kandas di satu tempat: volume. Daemon Docker menciptakan volume baru dengan kepemilikan root:root, sehingga container non-root yang menulis ke sana ditolak mentah-mentah:

bash
$ docker volume create data-uji

Output yang Dihasilkan:

text
data-uji
bash
$ docker run --rm -v data-uji:/data alpine:3.20 touch /data/awal.txt

Perintah kedua di atas berjalan sebagai root bawaan, sehingga awal.txt tercipta tanpa suara. Container berikutnya kita turunkan identitasnya lalu diminta menulis ke volume yang sama:

bash
$ docker run --rm --user 1000:1000 -v data-uji:/data alpine:3.20 \
    touch /data/tambahan.txt

Output yang Dihasilkan:

text
touch: /data/tambahan.txt: Permission denied

Mari kita telaah penolakan yang sangat lazim itu:

  1. Volume segar dimiliki root:root dengan mode 755 — proses UID 1000 bukan pemiliknya dan bukan anggota grupnya, sehingga haknya tinggal r-x milik pihak lain; menulis tidak termasuk.
  2. Kegagalan muncul tanpa satu petunjuk akar masalah — pesan galat hanya menyebut Permission denied; tidak ada kata “volume” atau “kepemilikan” — itulah sebabnya masalah ini kerap disalahkan ke aplikasi dan menghabiskan berjam-jam diagnosa.
  3. Resep intinya tiga jalur — bila Dockerfile ada di tangan kita, chown path data ke UID aplikasi saat build atau pakai COPY --chown; bila image milik pihak ketiga, entry point berjalan sejenak sebagai root untuk membereskan kepemilikan lalu turun lewat gosu atau su-exec; bila data sudah telanjur dimiliki UID tertentu, jalankan container persis sebagai UID itu.

Ketiga jalur tersebut, beserta dua arah interaksi angka pemilik dan tabel diagnosis gejala, sudah kita bedah tuntas sampai ke perintahnya pada Chapter Permission dan Ownership Volume — chapter itu adalah rujukan lengkap setiap kali Permission denied muncul berdampingan dengan --user.

Port di Bawah 1024 #

Identitas non-root membawa satu aturan lama Linux ke dalam container: kernel melarang proses non-root mengikat port di bawah 1024 kecuali proses memegang capability NET_BIND_SERVICE. Aturan ini akan kita bahas penuh sebagai bagian dari peta capability pada chapter berikutnya; di sini cukup satu catatan praktis.

Image Resmi yang Menurunkan Diri Sendiri #

Sebagian besar image resmi besar tidak menunggu kita: mereka menurunkan identitas sendiri dengan pola dua tingkat — proses persiapan berjalan sebagai root, proses pekerjaan berjalan sebagai user rendah. Image nginx menurunkan worker ke UID 101 lewat directive user nginx; di konfigurasinya, seperti yang kita lihat pada kolom UID docker top di Chapter User dan Permissions. Entry point redis membetulkan kepemilikan direktori datanya dulu, lalu turun ke user redis. Entry point postgres menyiapkan direktori data sebagai root, kemudian berpindah ke user postgres bernomor UID 999 lewat gosu — alat penurunan wewenang khas keluarga Debian yang sudah kita temui pada Chapter Permission dan Ownership Volume.

Implikasinya dua arah. Pertama, sebelum menambahkan --user pada image resmi, periksa dulu dokumentasinya — menimpa identitas yang sudah diturunkan dengan angka yang salah justru menciptakan masalah izin baru, persis skenario Permission denied di atas. Kedua, untuk aplikasi kita sendiri, pola dua tingkat itu layak ditiru bila inisialisasi memang menuntut root: entry point root membereskan berkas, lalu exec gosu app menyerahkan tongkat ke user aplikasi.

Pemetaan User Namespace #

Ada satu teknik penurunan yang bekerja satu tingkat lebih dalam daripada USER dan --user: user namespace remap, konfigurasi daemon yang memetakan ulang seluruh UID container ke rentang UID tinggi di host. Dengan remap aktif, root container (UID 0 di dalam) diterjemahkan host sebagai user biasa bernomor tinggi — misalnya 100000 — sehingga berkas yang ditulis container tampak milik 100000 di sisi host; sekalipun ada penyerang yang “lolos” sebagai root container, ia mendarat di host sebagai user tanpa privilege apa pun. Konfigurasi ini berlaku daemon-wide untuk semua container dan berkerabat erat dengan mode rootless yang akan kita jumpai lagi pada Chapter Docker Socket Security; untuk sebagian besar tim, USER plus --user di level image sudah menutup kebutuhan sehari-hari.

Tiga Jalan Penurunan #

CaraBekerja padaPaling pas untuk
USER di Dockerfilebuild — menempel permanen di imageimage aplikasi milik sendiri
--user saat docker runcreate — per container, menimpa imageimage pihak ketiga yang belum menurunkan
distroless :nonrootbuild — UID 65532, tanpa root dan tanpa shellimage produksi minimal

Identitas kini berada di bawah kendali kita: image aplikasi sendiri menurunkan sejak build, image orang lain bisa kita timpa saat runtime, dan jebakan volume maupun port sudah punya peta jalannya. Tetapi rumus pada chapter pembuka masih menyisakan suku yang lebih besar: kekuatan root yang tersisa itu sendiri. Root di dalam container masih membawa dua belas capability — unit-unit privilege seperti CHOWN, SETUID, dan NET_BIND_SERVICE — dan sebagian besar aplikasi nyata hanya membutuhkan satu-dua di antaranya. Chapter Linux Capabilities membedah peta lengkapnya: apa saja yang dibawa container bawaan, bagaimana mencabut semuanya, lalu menambahkan kembali hanya yang benar-benar dipakai aplikasi.

Referensi #