Container menjual isolasi, tetapi isolasinya lebih tipis dari kedengarannya: semua container di satu host berbagi satu kernel, dan secara bawaan container berjalan sebagai root. Dua fakta itu membuat container bukan tembok kokoh melainkan pagar yang harus kita tinggikan lapis demi lapis. Chapter ini menyusun pertahanan berlapis itu dari bawah ke atas: Dockerfile yang aman, audit otomatis, penanganan secret yang benar, hingga pembatasan runtime. Docker tidak terpasang di lingkungan latihan, jadi seluruh latihan berjalan di atas fixture Dockerfile dan JSON yang dibedah dengan diff, grep, dan jq.

Risiko Bawaan Container #

Empat lubang paling sering ditemukan pada setup container yang dibiarkan bawaan:

RisikoKenapa terjadiMitigasi
container berjalan sebagai rootDockerfile tanpa instruksi USERUSER non-root, atau rootless mode
supply chain tak terkendaliFROM image:latest dari sumber asingpin versi dan digest, image resmi
secret terkubur di imageENV PASSWORD=... di Dockerfilefile/secret runtime, bukan layer
container tanpa batasmemori dan CPU bebas dipakai--memory, --cpus

Akar soalnya satu: kelompok risiko di kiri terasa tidak berbahaya karena container “sudah terisolasi”. Kenyataannya, isolasi container berbagi kernel dengan host — kerentanan container escape yang menemukan root di dalam container berusaha keras memperlebar cakarannya ke host. Setiap lapis mitigasi memperkecil apa yang bisa diraih saat lapis pertama jebol.

Dockerfile Aman vs yang Berisiko #

Dua fixture di bundle materi — Dockerfile.riskan dan Dockerfile.aman — membangun aplikasi Node.js yang sama; bedanya hanya disiplin penulisnya. Kita tampilkan keduanya lalu bedah dengan diff:

bash
echo '== Dockerfile.riskan =='
cat Dockerfile.riskan
echo '== Dockerfile.aman =='
cat Dockerfile.aman
echo '== perbedaannya =='
diff -u -L Dockerfile.riskan -L Dockerfile.aman Dockerfile.riskan Dockerfile.aman || true

Output yang Dihasilkan:

text
== Dockerfile.riskan ==
FROM node:latest
ENV DB_PASSWORD=CONTOH_PASSWORD_KUAT
COPY . /app
RUN curl -s https://contoh.example.com/install.sh | sh
CMD ["node", "/app/server.js"]
== Dockerfile.aman ==
FROM node:22.11-alpine
WORKDIR /app
COPY package.json .
RUN npm ci --omit=dev && npm cache clean --force
COPY . .
USER node
CMD ["node", "server.js"]
== perbedaannya ==
--- Dockerfile.riskan
+++ Dockerfile.aman
@@ -1,5 +1,7 @@
-FROM node:latest
-ENV DB_PASSWORD=CONTOH_PASSWORD_KUAT
-COPY . /app
-RUN curl -s https://contoh.example.com/install.sh | sh
-CMD ["node", "/app/server.js"]
+FROM node:22.11-alpine
+WORKDIR /app
+COPY package.json .
+RUN npm ci --omit=dev && npm cache clean --force
+COPY . .
+USER node
+CMD ["node", "server.js"]

Bacaan diff-nya, potongan demi potongan:

  1. FROM node:latest menjadi FROM node:22.11-alpine: versi dipin ke angka konkret dan varian alpine yang ramping; latest berubah diam-diam — build hari ini dan bulan depan bisa jadi dua aplikasi berbeda.
  2. Baris ENV DB_PASSWORD hilang: kredensial tidak pernah menyentuh image — environment image terlihat oleh siapa pun yang bisa menarik image itu, selamanya.
  3. COPY package.json lalu RUN npm ci: layer dependensi terpisah dari source code — COPY . . di bawahnya tidak memicu instalasi ulang selama package.json tidak berubah; cache layer bekerja, image lebih kecil.
  4. USER node: proses berjalan sebagai user node bawaan image, bukan root — satu baris ini menutup risiko terbesar tabel di atas.
  5. CMD ["node", "server.js"]: bentuk exec form dengan WORKDIR /app, tanpa path absolut yang rapuh.

Audit Dockerfile dengan Skrip Mini #

Empat pemeriksaan yang menutup tabel risiko di atas bisa ditulis sebagai skrip grep sederhana — versi mini dari linter yang sesungguhnya:

bash
audit() {
  file=$1
  echo "== audit $file =="
  if grep -q 'FROM .*:latest' "$file"; then
    echo "  [X] FROM memakai tag :latest"
  else
    echo "  [ok] image dasar dipin"
  fi
  if grep -q '^USER ' "$file"; then
    echo "  [ok] ada USER non-root"
  else
    echo "  [X] tanpa USER: berjalan sebagai root"
  fi
  if grep -Eq '(curl|wget)[^|]*\|[[:space:]]*(ba)?sh' "$file"; then
    echo "  [X] pola unduh-lalu-eksekusi"
  else
    echo "  [ok] tidak ada pola unduh-lalu-eksekusi"
  fi
  if grep -Eq '^ENV .*(PASSWORD|SECRET|TOKEN)' "$file"; then
    echo "  [X] secret tertulis di ENV"
  else
    echo "  [ok] tidak ada secret di ENV"
  fi
}
audit Dockerfile.riskan
audit Dockerfile.aman

Output yang Dihasilkan:

text
== audit Dockerfile.riskan ==
  [X] FROM memakai tag :latest
  [X] tanpa USER: berjalan sebagai root
  [X] pola unduh-lalu-eksekusi
  [X] secret tertulis di ENV
== audit Dockerfile.aman ==
  [ok] image dasar dipin
  [ok] ada USER non-root
  [ok] tidak ada pola unduh-lalu-eksekusi
  [ok] tidak ada secret di ENV

Cara kerjanya: fungsi audit menerima satu berkas, lalu empat pasang if grep menguji pola berisiko dan melaporkan hasilnya — :latest di baris FROM, absennya USER, pola curl ... | sh yang mengeksekusi skrip unduhan, dan kata sandi di ENV. Skrip 25 baris ini menutup keempat lubang tabel pembuka; untuk pemeriksaan mendalam, hadolint me-lint Dockerfile dan trivy memindai image jadi — keduanya standar di pipeline CI sebelum image diberi izin masuk registry.

Secret Bukan Environment Variable #

Menaruh password di docker run -e DB_PASSWORD=... terasa praktis, tetapi seluruh nilainya terbaca oleh siapa pun yang bisa menjalankan docker inspect pada container itu. Fixture inspect-web.json memperlihatkan betapa terbukanya:

bash
echo '== docker inspect web-toko (potongan) =='
jq -r '.[0].Config.Env[]' inspect-web.json
echo '== user prosesnya =='
jq -r '.[0].Config.User | if . == "" then "(kosong: berjalan sebagai root)" else . end' inspect-web.json

Output yang Dihasilkan:

text
== docker inspect web-toko (potongan) ==
DATABASE_URL=postgres://budi:CONTOH_PASSWORD_KUAT@db:5432/toko
PATH=/usr/local/bin:/usr/bin:/bin
== user prosesnya ==
(kosong: berjalan sebagai root)

Dua temuan dalam satu berkas: DATABASE_URL lengkap dengan passwordnya duduk di environment — terbaca tanpa satu pun sudo tambahan — dan field User yang kosong menegaskan prosesnya root. Tiga tempat yang benar untuk kredensial: build-time secret lewat RUN --mount=type=secret (nilai masuk memori build, bukan layer image), runtime file yang dipasang read-only ke container, atau secret manager untuk skala besar. Pola umumnya satu di semua tempat itu: kredensial disuntik saat proses berjalan, tidak pernah dibakar ke dalam image.

Hardening Runtime dan Checklist #

Setelah image bersih, giliran runtime memagari prosesnya. Tiga opsi docker run paling berdampak: --read-only membuat seluruh filesystem container baca-saja (tambahkan --tmpfs /tmp untuk berkas sementara), --cap-drop=ALL membuang seluruh capability kernel yang tidak diperlukan, dan --memory 256m --cpus 0.5 membatasi konsumsi sumber daya supaya satu container yang rusak tidak menyeret host-nya. Ringkasan seluruh praktik chapter ini:

LapisanPraktikBentuknya
Imagepin versi dan digestnginx:1.27@sha256:...
Dockerfileproses non-rootUSER node
Dockerfiletanpa secret di layerRUN --mount=type=secret
Runtimefilesystem baca-saja--read-only --tmpfs /tmp
Runtimebuang capability--cap-drop=ALL
Runtimebatas sumber daya--memory 256m --cpus 0.5
Supply chainscan berkalatrivy image nginx:1.27

Digest pinning melengkapi pin versi: tag bisa digeser orang lain ke image berbeda, sedangkan @sha256:... mengunci byte persis yang kita audit. Scanning melengkapinya dari sisi waktu — image yang bersih hari ini bisa berisi CVE bulan depan, jadi pindai ulang secara berkala, bukan sekali saat build.

Dengan keamanan container, perlengkapan section ini lengkap: dari hypervisor dan VM, container dan compose, jaringan dan storage, hingga pagar kelilingnya. Peta perjalanan berikutnya berpindah medan — mesin Linux yang kita kelola jarang berada di meja kita sendiri; server di rak, VPS di cloud, Raspberry Pi di lemari. Section Remote Access dan Headless Linux dimulai dari mesin paling murni: yang bahkan tidak punya monitor — Headless Linux.

Referensi lanjut: Docker Engine security untuk pembahasan mendalam tiap lapisan, serta Build secrets untuk mekanisme secret saat build.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

inspect-web.json

Unduh berkas
json
[
  {
    "Name": "/web-toko",
    "Config": {
      "Env": [
        "DATABASE_URL=postgres://budi:CONTOH_PASSWORD_KUAT@db:5432/toko",
        "PATH=/usr/local/bin:/usr/bin:/bin"
      ],
      "User": ""
    }
  }
]