Tiga strategi migrasi pada Chapter Migrasi Data Container berakhir pada pengamatan yang sama: kolom ls -ln yang menampilkan 0 0 di setiap verifikasi. Angka itu bukan hiasan output — itulah keseluruhan persoalan izin di dunia container: kernel Linux tidak pernah mencocokkan nama user root, budi, atau app, melainkan hanya angka UID dan GID yang menempel pada setiap berkas. Nama hanyalah label di /etc/passwd yang isinya bisa berbeda antara host dan image, sementara angkanya berjalan tanpa pernah berubah dari satu ujung pipa tar ke ujung lainnya. Salah paham soal satu angka inilah biang dari separuh keluhan “container saya tidak bisa menulis” dan “aplikasi tiba-tiba Permission denied setelah ganti image” — dan chapter penutup section ini disiapkan persis untuk membedahnya: dua arah interaksi antara angka pemilik dan container, lima pola solusi yang bisa dipilih sesuai kendali kita atas image, lalu tabel diagnosis untuk gejala yang paling sering muncul.

Kernel Mencocokkan Angka, Bukan Nama #

Setiap berkas di Linux menyimpan tiga identitas numerik: UID pemilik, GID grup, dan tiga set bit izin untuk pemilik, grup, dan pihak lain. Saat sebuah proses mencoba menulis, kernel membandingkan UID proses dengan UID pemilik berkas — bila cocok, set izin pemilik yang berlaku; bila tidak, kernel turun ke GID, lalu ke set pihak lain. Label nama tidak pernah ikut serta dalam keputusan itu. Perintah ls -l menerjemahkan angka menjadi nama lewat /etc/passwd dan /etc/group mesin yang menjalankannya, sedangkan ls -ln memperlihatkan angka mentahnya — dan karena /etc/passwd host berbeda isinya dengan /etc/passwd image Alpine, berkas yang sama bisa tampak milik budi di host dan milik 1000 di container tanpa satu byte data berubah.

Arah Pertama: Berkas Milik Root, Container Root Berkuasa #

Perjalanan default kita sejauh ini nyaman karena dua pihak yang bertemu kebetulan memakai angka yang sama: container yang tidak menurunkan wewenang berjalan sebagai UID 0, dan banyak berkas — termasuk isi volume yang lahir dari image — juga dimiliki UID 0. Kita buktikan arah nyaman itu dengan direktori berbagi segar:

bash
$ mkdir -p berbagi
$ docker run --rm -v "$(pwd)/berbagi:/data" alpine:3.20 \
    sh -c "echo 'catatan dari container root' > /data/catatan.txt"
$ ls -ln berbagi/

Output yang Dihasilkan:

text
total 4
-rw-r--r-- 1 0 0 28 Okt 13 17:04 catatan.txt

Berkas itu lahir dari proses UID 0 di dalam container, sehingga pemiliknya tercatat sebagai 0 0 — dan di host, angka 0 diterjemahkan menjadi nama root. Container root melanjutkan pembacaan dan penulisan tanpa hambatan:

bash
$ docker run --rm -v "$(pwd)/berbagi:/data" alpine:3.20 \
    sh -c "cat /data/catatan.txt && echo 'ditambah container' >> /data/catatan.txt"

Output yang Dihasilkan:

text
catatan dari container root

Interaksi dengan mode mount juga berjalan sesuai teori: dengan :rw — mode bawaan — tulisan diterima; dengan :ro seperti pada Chapter Mount Read-Only, tulisan ditolak bukan karena masalah kepemilikan, melainkan karena mount itu sendiri terkunci. Untuk menutup arah pertama ini, kita bandingkan pandangan host dan container terhadap berkas yang sama secara berdampingan:

bash
$ ls -ln berbagi/catatan.txt

Output yang Dihasilkan:

text
-rw-r--r-- 1 0 0 47 Okt 13 17:06 berbagi/catatan.txt
bash
$ docker run --rm -v "$(pwd)/berbagi:/data:ro" alpine:3.20 ls -ln /data/catatan.txt

Output yang Dihasilkan:

text
-rw-r--r--    1 0        0                47 Oct 13 17:06 /data/catatan.txt

Mari kita telaah pasangan output tersebut:

  1. Tiga angka yang identik — mode 644, UID 0, GID 0, ukuran 47 byte tampil sama di kedua sisi; bind mount memang meneruskan metadata berkas apa adanya, tidak ada lapisan penerjemahan di tengahnya.
  2. Okt versus Oct — host Budi memakai locale Indonesia sehingga ls menyingkat Oktober sebagai Okt, sementara container Alpine berbicara locale C dan menulis Oct; perbedaan kosmetik ini pengingat bagus bahwa yang benar-benar dipertukarkan hanyalah angka, sedangkan format tampilan urusan masing-masing mesin.

Arah Kedua: Container Non-Root Menabrak Kepemilikan #

Kenyamanan arah pertama berakhir begitu container diturunkan wewenangnya — praktik yang justru dianjurkan untuk produksi. Kita jalankan container sebagai --user 1000:1000 dan memintanya menimpa berkas milik UID 0 tadi:

bash
$ docker run --rm --user 1000:1000 -v "$(pwd)/berbagi:/data" alpine:3.20 \
    sh -c "echo revisi > /data/catatan.txt"

Output yang Dihasilkan:

text
sh: can't create /data/catatan.txt: Permission denied

Padahal UID 1000 tidaklah lumpuh di direktori itu — direktori berbagi sendiri dimiliki UID 1000 (pembuatnya adalah budi di host), sehingga membuat berkas baru tetap diperbolehkan:

bash
$ docker run --rm --user 1000:1000 -v "$(pwd)/berbagi:/data" alpine:3.20 \
    sh -c "echo milik-seribu > /data/berkas-1000.txt"
$ ls -ln berbagi/

Output yang Dihasilkan:

text
total 8
-rw-r--r-- 1 0 0 47 Okt 13 17:06 catatan.txt
-rw-r--r-- 1 1000 1000 14 Okt 13 17:08 berkas-1000.txt

Dan inilah momen kebenaran soal nama versus angka — berkas baru yang sama dilihat dari dua dunia:

bash
$ ls -l berbagi/berkas-1000.txt

Output yang Dihasilkan:

text
-rw-r--r-- 1 budi budi 14 Okt 13 17:08 berbagi/berkas-1000.txt
bash
$ docker run --rm -v "$(pwd)/berbagi:/data:ro" alpine:3.20 ls -l /data/berkas-1000.txt

Output yang Dihasilkan:

text
-rw-r--r--    1 1000     1000            14 Oct 13 17:08 /data/berkas-1000.txt

Mari kita telaah empat output di atas:

  1. Permission denied pada catatan.txt — berkas itu dimiliki UID 0 dengan mode 644; UID 1000 bukan pemiliknya, bukan pula anggota grupnya, sehingga haknya tinggal r-- milik pihak lain — membaca boleh, menulis tidak.
  2. berkas-1000.txt justru berhasil dibuat — direktori berbagi dimiliki UID 1000 dengan mode 755, dan pembuatan berkas baru di dalam direktori adalah hak pemilik direktori; kernel menilai setiap operasi secara terpisah, bukan menghakimi container secara keseluruhan.
  3. budi dan 1000 adalah pihak yang sama — host menerjemahkan UID 1000 sebagai budi karena entri itu ada di /etc/passwd host, sementara Alpine yang tidak mengenal UID 1000 menampilkan angkanya mentah-mentah; kedua ls membicarakan berkas identik yang sama-sama dimiliki angka 1000.
  4. Implikasi keamanan yang menggembirakan — penurunan wewenang ke UID non-root sungguh-sungguh membatasi daya rusak: proses UID 1000 tidak bisa menimpa berkas milik UID 0 meskipun container-nya diberi akses tulis ke direktorinya.

Kejadian serupa menunggu di jalur volume, dengan mekanisme yang lebih tersembunyi. Docker diam-diam menyalin kepemilikan dari image saat volume kosong pertama kali dipasang — mekanisme copy-up yang kita manfaatkan sejak Chapter Volume juga menyalin UID pemilik path. Kita buktikan dengan volume segar yang dipasang ke path milik image nginx:

bash
$ docker volume create situs-app
$ docker run --rm --user 1000:1000 -v situs-app:/usr/share/nginx/html nginx:1.29-alpine \
    sh -c "echo tes > /usr/share/nginx/html/berkas.txt"

Output yang Dihasilkan:

text
sh: can't create /usr/share/nginx/html/berkas.txt: Permission denied
bash
$ docker run --rm -v situs-app:/data alpine:3.20 ls -ln /data

Output yang Dihasilkan:

text
total 8
-rw-r--r--    1 0        0               384 Oct 13 17:14 50x.html
-rw-r--r--    1 0        0               615 Oct 13 17:14 index.html

Mari kita telaah mekanisme di balik penolakan ini:

  1. Copy-up menyalin kepemilikan image — volume kosong yang dipasang di /usr/share/nginx/html diisi ulang dengan berkas bawaan nginx beserta metadata-nya; karena path itu dimiliki root di dalam image, volume baru pun tercatat milik UID 0.
  2. Kepemilikan volume = UID image pada path itu — inilah aturan diam-diam yang menjelaskan banyak kejutan: image yang path datanya dimiliki UID tertentu akan melahirkan volume dengan UID yang sama persis; path yang bahkan tidak ada di image membuat daemon membuat direktori segar milik root.
  3. --user 1000:1000 bertabrakan dengan 0 0 — proses aplikasi bukan pemilik direktori maupun berkasnya, mode 755 pada direktori hanya memberi r-x bagi pihak lain, dan penulisan pun ditolak; kegagalan muncul tanpa satu petunjuk bahwa akar masalahnya ada di angka kepemilikan yang disalin diam-diam.

Lima Pola Solusi #

Setelah dua arah masalah terpetakan, saatnya melengkapi kotak obat. Lima pola berikut disusun dari yang paling dianjurkan ketika kita menguasai image, hingga yang paling tepat ketika data sudah telanjur dimiliki pihak lain:

PolaIntiPaling pas saat
1. RUN chown saat buildImage menyiapkan kepemilikan path data sejak buildKita menguasai Dockerfile aplikasi
2. COPY --chownBerkas aplikasi mendarat langsung dengan UID yang benarData berupa berkas statis yang disalin saat build
3. Entry point chown lalu turun wewenangContainer root membereskan izin saat start, lalu turun ke user aplikasiImage pihak ketiga atau kepemilikan volume tak pasti
4. Grup bersama GID dan umask 002Semua pihak menulis lewat grup yang samaHost dan beberapa container berbagi satu kumpulan data
5. Samakan UID aplikasi dengan pemilik dataAplikasi berjalan persis sebagai pemilik berkasnyaBind mount ke direktori milik user host

Pola 1 — RUN chown Saat Build #

Bila Dockerfile ada di tangan kita, perbaikan paling bersih dilakukan saat build: direktori data dibuat dan di-chown ke user aplikasi sebelum instruksi USER menurunkan wewenang:

dockerfile
FROM node:22-alpine
RUN addgroup -g 1000 app \
 && adduser -u 1000 -G app -H -D app \
 && mkdir -p /data \
 && chown -R app:app /data
USER app
WORKDIR /app
COPY . .
CMD ["node", "server.js"]

Karena path /data kini dimiliki app di dalam image, mekanisme copy-up akan melahirkan volume kosong yang juga dimiliki UID 1000 — pola ini menembak tepat ke akar masalah pada subbab sebelumnya, dan tidak menambah satu langkah pun saat runtime. Pola yang sama persis dipakai image database resmi, alasan mengapa PostgreSQL dan MySQL “begitu saja bekerja” dengan volume segar.

Pola 2 — COPY –chown #

Berkas data yang ikut terkemas dalam image tidak perlu menempuh dua instruksi COPY lalu chown; satu COPY --chown menyalin dan menetapkan kepemilikan sekaligus:

dockerfile
COPY --chown=1000:1000 ./berkas-referensi /data

Pola ini paling pas untuk data referensi statis — daftar harga, kamus, geo-IP database — yang jarang berubah namun sering dibaca oleh aplikasi non-root. Angka UID dan GID boleh pula ditulis sebagai nama user yang sudah dibuat lebih dulu di image, misalnya --chown=app:app, bila nama itu dideklarasikan pada instruksi RUN sebelumnya.

Pola 3 — Entry Point yang Membetulkan Izin #

Dua pola pertama menuntut kepemilikan atas Dockerfile. Bila image berasal dari pihak ketiga — atau volumenya berisi data warisan dengan kepemilikan tak terduga, misalnya hasil migrasi — pembetulan dilakukan saat container menyala: entry point berjalan sejenak sebagai root, membereskan izin, lalu menurunkan dirinya ke user aplikasi:

dockerfile
FROM node:22-alpine
RUN apk add --no-cache su-exec \
 && addgroup -g 1000 app \
 && adduser -u 1000 -G app -H -D app \
 && mkdir -p /data
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"]
sh
#!/bin/sh
set -e
chown -R app:app /data
exec su-exec app:app "$@"

Mari kita telaah kerja entry point tersebut: baris chown -R app:app /data berjalan sebagai root karena USER tidak pernah diturunkan di Dockerfile, sehingga berhak membetulkan kepemilikan volume apa pun yang terpasang; baris exec su-exec app:app "$@" kemudian menggantikan dirinya dengan aplikasi yang berjalan sebagai UID 1000 — su-exec adalah perkakas penurunan wewenang khas Alpine, dengan gosu sebagai padanannya di image Debian, dan pola pembetulan izin semacam ini dikenal luas dengan sebutan fix-perms. Aplikasi pun menyala sebagai non-root tanpa pernah menyentuh berkas yang bukan miliknya.

Pola 4 — Grup Bersama dan umask 002 #

Bila penulisnya memang banyak — editor di host, container build, container aplikasi — yang dipersatukan bukan UID melainkan GID: semua pihak bergabung ke satu grup dengan nomor yang sama, direktori data diberi bit setgid, dan aplikasi menulis dengan umask 002 agar berkasnya selalu menulis-lebih-grup:

bash
$ sudo groupadd -g 2000 timdata
$ sudo chown :timdata berbagi && sudo chmod 2775 berbagi
$ sudo usermod -aG timdata budi

Di sisi image, user aplikasi cukup menjadi anggota grup bernomor sama:

dockerfile
RUN addgroup -g 2000 timdata \
 && adduser -u 1000 -G app -H -D app \
 && adduser app timdata

Tiga angka bekerja bersama di pola ini: 2775 menempelkan bit setgid pada direktori sehingga berkas baru selalu mewarisi GID 2000; umask 002 membuat aplikasi melahirkan berkas mode 664 alih-alih 644; dan keanggotaan grup yang sama di host maupun container membuat setiap pihak masuk ke kolom izin grup — kolom yang oleh umask 002 sengaja dibiarkan terbuka untuk menulis.

Pola 5 — Samakan UID Aplikasi dengan Pemilik Data #

Pola terakhir paling sederhana: bila data sudah dimiliki UID tertentu di host, jalankan containernya sebagai UID itu juga:

bash
$ docker run --rm --user "$(id -u):$(id -g)" -v "$(pwd)/berbagi:/data" alpine:3.20 \
    sh -c "echo pas > /data/catatan-budi.txt"
$ ls -ln berbagi/catatan-budi.txt

Output yang Dihasilkan:

text
-rw-r--r-- 1 1000 1000 4 Okt 13 17:26 berbagi/catatan-budi.txt

Flag --user "$(id -u):$(id -g)" mengambil angka UID dan GID user host yang menjalankan perintah — 1000 1000 pada mesin Budi — sehingga berkas yang tercipta langsung menjadi milik budi di mata host. Pola ini paling cocok untuk container utilitas sekali jalan di workstation; untuk aplikasi penuh ia kurang ideal karena /etc/passwd image bisa saja tidak mengenal UID itu, membuat home directory dan nama user tidak tersedia bagi aplikasi.

Kasus Nyata: Data Database Milik UID 999 #

Seluruh teori di atas tergabung dalam satu contoh production yang paling sering dijumpai. Image mysql:8.4 — seperti halnya image postgres varian Debian — menjalankan engine database-nya sebagai user internal bernomor UID 999, dan direktori datanya di-chown ke angka itu sejak build. Kita jalankan sekali dan periksa hasilnya:

bash
$ docker run -d --name db-mysql \
    -e MYSQL_ROOT_PASSWORD=rahasia123 \
    -v mysqldata:/var/lib/mysql \
    mysql:8.4

Output yang Dihasilkan:

text
Unable to find image 'mysql:8.4' locally
8.4: Pulling from library/mysql
...
Digest: sha256:c7e92b4f1a8d...
Status: Downloaded newer image for mysql:8.4
5f42e9ff8f078cdbaa039d120a8611a741e26208d6da0f792d787924bb20dc5f

Setelah inisialisasi selesai — MySQL memerlukan belasan detik pada penyalaan pertama — isi volumenya kita periksa dengan helper yang kita percaya:

bash
$ docker run --rm -v mysqldata:/data alpine:3.20 ls -ln /data

Output yang Dihasilkan:

text
total 176164
drwxr-x---    2 999      999           4096 Oct 13 17:31 #innodb_temp
-rw-r----- 1 999      999            179 Oct 13 17:31 auto.cnf
-rw-r----- 1 999      999           2433 Oct 13 17:31 binlog.000001
...
drwxr-x---    2 999      999           4096 Oct 13 17:31 mysql
drwxr-x---    2 999      999           4096 Oct 13 17:31 performance_schema
drwxr-x---    2 999      999           4096 Oct 13 17:31 sys

Daftar panjang dipotong — tabel internal MySQL lainnya mengisi baris-baris di tengah. Mari kita telaah maknanya:

  1. 999 999 di seluruh baris — entry point image mempersiapkan direktori data sebagai milik UID 999 sebelum engine dinyalakan; pola 1 dan pola 3 di atas bekerja persis seperti ini di balik layar.
  2. Bukan kebetulan angkanya sama antar image — image database resmi sengaja mematok UID tetap agar kepemilikan data tetap konsisten lintas pembaruan image; volume yang dipasang kembali ke image versi terbaru tetap dikenali miliknya.
  3. Helper Alpine membaca tanpa masalah — mode 640 dan 750 membatasi pihak lain, tetapi helper kita berjalan sebagai root di dalamnya sehingga tetap bisa membaca; penggunaan helper untuk inspeksi inilah praktik yang paling aman daripada mengubah kepemilikan data.

Tabel Diagnosis #

GejalaPenyebab paling umumJalan keluar
sh: can't create ...: Permission denied dari container --user 1000Berkas atau direktori dimiliki UID lain, biasanya 0Pola 5, atau pola 4 bila banyak pihak menulis
Aplikasi non-root tak bisa menulis volume baruCopy-up menyalin kepemilikan root dari imagePola 1 atau pola 3
Berkas tampil sebagai angka di ls hostUID container tak punya nama di /etc/passwd hostWajar — kernel memakai angka; tidak perlu dibetulkan
Read-only file system saat menulisMount :ro atau flag --read-onlyLepaskan :ro atau sediakan tmpfs (Chapter Mount Read-Only)
Database tak mau menyala setelah chown manualKepemilikan internal engine rusakPulihkan dari arsip (Chapter Backup dan Restore Volume)

Sebelum menutup section, kita rapikan mesin latihan dari sisa percobaan — container database dan volume-volume latihan ikut dibuang, sementara direktori berbagi di direktori kerja boleh dihapus manual bila tak lagi diperlukan:

bash
$ docker rm -f db-mysql && docker volume rm mysqldata situs-app

Output yang Dihasilkan:

text
db-mysql
mysqldata
situs-app

Kita kini menutup Section Penyimpanan Data dengan seluruh rantai kelengkapan di tangan: writable layer yang kita pahami batasnya, tiga jenis mount yang kita pilih dengan alasan, penguncian read-only yang kita kendalikan, arsip backup yang bisa kita pulihkan dan migrasikan, serta angka UID/GID yang tak lagi mengejutkan. Data aplikasi sudah aman hidup lebih lama dari container mana pun yang menampungnya — tetapi container itu sendiri masih saling asing: aplikasi web kita belum bisa menyapa database tetangganya, dan dunia luar baru bisa menyentuhnya lewat port yang diterbitkan satu-satu. Bagaimana container saling menemukan, berbicara, dan dibatasi jaringannya adalah pekerjaan Section Jaringan Container, dimulai dari fondasi network namespace pada Chapter Konsep Networking Docker.

Referensi #

  • Volumes — perilaku copy-up, kepemilikan awal volume, dan contoh container non-root.
  • docker container run — referensi flag --user, --group-add, dan --read-only.
  • Dockerfile Reference — sintaks COPY --chown, USER, dan RUN untuk pola perbaikan izin saat build.