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:
$ 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:
total 4
-rw-r--r-- 1 0 0 28 Okt 13 17:04 catatan.txtBerkas 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:
$ 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:
catatan dari container rootInteraksi 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:
$ ls -ln berbagi/catatan.txtOutput yang Dihasilkan:
-rw-r--r-- 1 0 0 47 Okt 13 17:06 berbagi/catatan.txt$ docker run --rm -v "$(pwd)/berbagi:/data:ro" alpine:3.20 ls -ln /data/catatan.txtOutput yang Dihasilkan:
-rw-r--r-- 1 0 0 47 Oct 13 17:06 /data/catatan.txtMari kita telaah pasangan output tersebut:
- Tiga angka yang identik — mode
644, UID0, GID0, ukuran47byte tampil sama di kedua sisi; bind mount memang meneruskan metadata berkas apa adanya, tidak ada lapisan penerjemahan di tengahnya. OktversusOct— host Budi memakai locale Indonesia sehinggalsmenyingkat Oktober sebagaiOkt, sementara container Alpine berbicara locale C dan menulisOct; 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:
$ docker run --rm --user 1000:1000 -v "$(pwd)/berbagi:/data" alpine:3.20 \
sh -c "echo revisi > /data/catatan.txt"Output yang Dihasilkan:
sh: can't create /data/catatan.txt: Permission deniedPadahal UID 1000 tidaklah lumpuh di direktori itu — direktori berbagi sendiri dimiliki UID 1000 (pembuatnya adalah budi di host), sehingga membuat berkas baru tetap diperbolehkan:
$ 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:
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.txtDan inilah momen kebenaran soal nama versus angka — berkas baru yang sama dilihat dari dua dunia:
$ ls -l berbagi/berkas-1000.txtOutput yang Dihasilkan:
-rw-r--r-- 1 budi budi 14 Okt 13 17:08 berbagi/berkas-1000.txt$ docker run --rm -v "$(pwd)/berbagi:/data:ro" alpine:3.20 ls -l /data/berkas-1000.txtOutput yang Dihasilkan:
-rw-r--r-- 1 1000 1000 14 Oct 13 17:08 /data/berkas-1000.txtMari kita telaah empat output di atas:
Permission deniedpadacatatan.txt— berkas itu dimiliki UID 0 dengan mode644; UID 1000 bukan pemiliknya, bukan pula anggota grupnya, sehingga haknya tinggalr--milik pihak lain — membaca boleh, menulis tidak.berkas-1000.txtjustru berhasil dibuat — direktoriberbagidimiliki UID 1000 dengan mode755, dan pembuatan berkas baru di dalam direktori adalah hak pemilik direktori; kernel menilai setiap operasi secara terpisah, bukan menghakimi container secara keseluruhan.budidan1000adalah pihak yang sama — host menerjemahkan UID 1000 sebagaibudikarena entri itu ada di/etc/passwdhost, sementara Alpine yang tidak mengenal UID 1000 menampilkan angkanya mentah-mentah; kedualsmembicarakan berkas identik yang sama-sama dimiliki angka 1000.- 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:
$ 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:
sh: can't create /usr/share/nginx/html/berkas.txt: Permission denied$ docker run --rm -v situs-app:/data alpine:3.20 ls -ln /dataOutput yang Dihasilkan:
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.htmlMari kita telaah mekanisme di balik penolakan ini:
- Copy-up menyalin kepemilikan image — volume kosong yang dipasang di
/usr/share/nginx/htmldiisi ulang dengan berkas bawaan nginx beserta metadata-nya; karena path itu dimiliki root di dalam image, volume baru pun tercatat milik UID 0. - 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.
--user 1000:1000bertabrakan dengan0 0— proses aplikasi bukan pemilik direktori maupun berkasnya, mode755pada direktori hanya memberir-xbagi 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:
| Pola | Inti | Paling pas saat |
|---|---|---|
1. RUN chown saat build | Image menyiapkan kepemilikan path data sejak build | Kita menguasai Dockerfile aplikasi |
2. COPY --chown | Berkas aplikasi mendarat langsung dengan UID yang benar | Data berupa berkas statis yang disalin saat build |
| 3. Entry point chown lalu turun wewenang | Container root membereskan izin saat start, lalu turun ke user aplikasi | Image pihak ketiga atau kepemilikan volume tak pasti |
4. Grup bersama GID dan umask 002 | Semua pihak menulis lewat grup yang sama | Host dan beberapa container berbagi satu kumpulan data |
| 5. Samakan UID aplikasi dengan pemilik data | Aplikasi berjalan persis sebagai pemilik berkasnya | Bind 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:
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:
COPY --chown=1000:1000 ./berkas-referensi /dataPola 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:
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"]#!/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:
$ sudo groupadd -g 2000 timdata
$ sudo chown :timdata berbagi && sudo chmod 2775 berbagi
$ sudo usermod -aG timdata budiDi sisi image, user aplikasi cukup menjadi anggota grup bernomor sama:
RUN addgroup -g 2000 timdata \
&& adduser -u 1000 -G app -H -D app \
&& adduser app timdataTiga 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:
$ 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.txtOutput yang Dihasilkan:
-rw-r--r-- 1 1000 1000 4 Okt 13 17:26 berbagi/catatan-budi.txtFlag --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:
$ docker run -d --name db-mysql \
-e MYSQL_ROOT_PASSWORD=rahasia123 \
-v mysqldata:/var/lib/mysql \
mysql:8.4Output yang Dihasilkan:
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
5f42e9ff8f078cdbaa039d120a8611a741e26208d6da0f792d787924bb20dc5fSetelah inisialisasi selesai — MySQL memerlukan belasan detik pada penyalaan pertama — isi volumenya kita periksa dengan helper yang kita percaya:
$ docker run --rm -v mysqldata:/data alpine:3.20 ls -ln /dataOutput yang Dihasilkan:
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 sysDaftar panjang dipotong — tabel internal MySQL lainnya mengisi baris-baris di tengah. Mari kita telaah maknanya:
999 999di 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.- 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.
- Helper Alpine membaca tanpa masalah — mode
640dan750membatasi 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 #
| Gejala | Penyebab paling umum | Jalan keluar |
|---|---|---|
sh: can't create ...: Permission denied dari container --user 1000 | Berkas atau direktori dimiliki UID lain, biasanya 0 | Pola 5, atau pola 4 bila banyak pihak menulis |
| Aplikasi non-root tak bisa menulis volume baru | Copy-up menyalin kepemilikan root dari image | Pola 1 atau pola 3 |
Berkas tampil sebagai angka di ls host | UID container tak punya nama di /etc/passwd host | Wajar — kernel memakai angka; tidak perlu dibetulkan |
Read-only file system saat menulis | Mount :ro atau flag --read-only | Lepaskan :ro atau sediakan tmpfs (Chapter Mount Read-Only) |
| Database tak mau menyala setelah chown manual | Kepemilikan internal engine rusak | Pulihkan 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:
$ docker rm -f db-mysql && docker volume rm mysqldata situs-appOutput yang Dihasilkan:
db-mysql
mysqldata
situs-appKita 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, danRUNuntuk pola perbaikan izin saat build.