Biri "bunu container'da çalıştırıyoruz, yani izole" dediğinde genelde kontrol etmediği iki şeyi kastediyor: içerideki sürecin host'a dokunamayacağını ve imajın temiz olduğunu. İkisi için de sayı istedim. Küçük bir Flask servisini dört ayrı biçimde derledim, tam python:3.13 imajından distroless'a kadar, her birini Trivy ile taradım, sonra host dosya sistemi içine bağlı bir container'a asla görmemesi gereken bir dosyayı okutmayı denedim. Tam imaj 3998 bilinen CVE taşıyordu; distroless 329 taşıdı ama 71 MB ve shell'siz. Root çalışan bir container host'un shadow dosyasını tek komutta okudu; aynı imaj user namespace altında okuyamadı. Bu yazı, rootless container'ın gerçekte ne olduğu, container'daki root'un neden host'taki root olduğu, ve imajın ile çalışma zamanının her birinin neyi geçirdiği; ölçerek.
Rootless container nedir?
Container, etrafına birkaç çekirdek özelliği sarılmış sıradan bir Linux sürecidir: namespace'ler ona kendi dosya sistemi, ağ, süreç listesi ve makine adı görünümünü verir; cgroup'lar ne kadar CPU ve bellek kullanacağını sınırlar; capability'ler root'un gücünü yaklaşık 40 parçaya böler (düşük port bağlama, dosya sahibi değiştirme, dosya sistemi mount etme) ki bir süreç bir kısmını tutup diğerini tutmasın; seccomp profili ise asla yapmaması gereken sistem çağrılarını engeller. Bunların hiçbiri sürecin kim olduğunu değiştirmez. İçerideki süreç uid 0 olarak çalışıyorsa, çekirdek de onu uid 0 görür; host'taki root kullanıcısıyla aynı sayı.
Bu yazının bütün meselesi bu. "Container içinde root", daha zayıf bir root türü değildir. Çitin arkasındaki root'tur. Çit iyidir ama çitteki her delik, bir bind mount, sızmış bir soket, bir çekirdek açığı, içerideki kişiye gerçek bir root verir.
Rootless iki farklı şey için kullanılır ve ikisi de önemlidir:
- Rootless (non-root) container: içerideki süreç yetkisiz bir kullanıcı olarak çalışır. Bunu Dockerfile'da
USER app,docker run'da--userya da Kubernetes securityContext'inderunAsNonRoot/runAsUserile alırsınız. İçerideidçıktısıuid=10001der. Host'ta da aynısı. - Rootless container motoru (user namespace): motorun kendisi, ya da en azından container, bir Linux user namespace içinde çalışır. Container içinde
idyineuid=0der ama çekirdek o 0'ı host'ta yetkisiz bir uid'e eşler, örneğin 100000. İçeride root, dışarıda hiç kimse. Podman'in varsayılanı budur, rootless Docker budur, Docker'ınuserns-remapdaemon seçeneği budur, Kubernetes'inhostUsers: falsedediği budur.
Birincisi imajınızla ilgilidir. İkincisi çalışma zamanınızla. Aşağıdaki lab ikisini de yapar ve farklı şeylere karşı korurlar.
Rootless container ve imaj sıkılaştırma hangi sorunu çözer?
İçerideki root, dışarıdaki root'tur. Container'dan host dosya sistemine giden her yol, çoğu zaman bir volume ya da bind mount, container'ın uid'iyle kullanılır. Host dizinini mount eden bir root container /etc/shadow'u okuyabilir, host'un root'a ait gördüğü dosyalar oluşturabilir ve arkasında bir setuid ikili bırakabilir. Bu bir exploit değil, uid'lerin çalışma şeklidir. Capability düşürmek bunu değiştirmez, çünkü dosya sahipliğini capability değil uid belirler.
İkinci yol çekirdek açıklarıdır. Dirty Pipe (CVE-2022-0847) ya da runc dosya tanımlayıcı sızıntısı (CVE-2024-21626) gibi container kaçışları container'daki bir süreçten başlar, host'ta kod çalıştırmayla biter. Verdikleri zarar o sürecin kim olduğuna bağlıdır. Kaçan non-root bir süreç, host'ta non-root bir süreçtir.
Büyük imajlar hiç kullanmadığınız açıkları taşır. python:3.13, derleyicileri, curl, git, Perl, mercurial ve yüzlerce kütüphaneyle gelen tam bir Debian sistemidir, çünkü birileri bir şey derlemek için isteyebilir. Sizin Flask uygulamanızın ihtiyacı yorumlayıcı ve iki pakettir. Fazladan her paket, bir sonraki taramanın bulacağı bir CVE, içeri giren saldırgana bir shell ve her node'da çekilecek megabaytlardır. İmaj sıkılaştırma, yalnızca çalışanı göndermektir.
Kubernetes eninde sonunda hayır diyecek. restricted Pod Security Standard'ı runAsNonRoot: true ister. Son USER'ı root olan bir imaj bunun altında CreateContainerConfigError ile başlamaz. Dockerfile'larında hiç kullanıcı belirlemeyen ekipler bunu, cluster yöneticisinin politikayı açtığı gün keşfeder.
Root vs non-root vs user namespace: ne kazanır, ne ödersiniz
| Root container (varsayılan) | Non-root süreç (USER, runAsNonRoot) |
User namespace (userns-remap, rootless Podman/Docker, hostUsers: false) |
|
|---|---|---|---|
İçeride id |
uid 0 | uid 10001 | uid 0 |
| Host'ta uid | 0 | 10001 | 100000 + n (yetkisiz) |
Bind mount ile host /etc/shadow okur |
evet | hayır | hayır |
| Paylaşılan volume'e yazdığı dosyalar | root'a ait | 10001'e ait | 100000'e ait |
| Dockerfile değişmeli mi | hayır | evet (USER, dosya izinleri) |
hayır, imaj olduğu gibi kalabilir |
| 1024 altı portlar | evet | hayır (8080 bağla, proxy eşlesin) | içeride hayır, motor yönlendirir |
| Çalışma anında paket kurmak | çalışır | başarısız (iyi) | içeride çalışır, dışarıda zararsız |
| Bedel | yok | imajı düzeltirken volume ve /tmp izin hataları |
mevcut volume'ler sahip değiştirir, --privileged ve bazı sürücüler çalışmaz, dosya sahipliğinde hafif ek yük |
| Kim kontrol eder | imaj yazarı | imaj yazarı ya da pod spec | cluster ya da daemon yöneticisi |
Sağdaki iki sütun birbirinin alternatifi değildir. Non-root süreç, çalışma zamanı yanlış yapılandırılmış olsa bile sizi korur; user namespace, imaj başkasının olsa ve root çalışsa bile sizi korur. Yapabildiğinizde ikisini birlikte kullanın.
Nasıl çalışır: içeride uid 0, dışarıda kim?

User namespace, çekirdeğin container başına tuttuğu bir çeviri tablosudur; /proc/self/uid_map'te görünür. 0 100000 65536 satırı "içerideki uid 0, dışarıda uid 100000'dir, ve 65536 id için böyle devam eder" demektir. Host'u ilgilendiren her izin kontrolü, bir dosya açmak, sinyal göndermek, mount etmek, dışarıdaki sayıyı kullanır. İçeride süreç hâlâ kendi dosyalarına sahiptir, hâlâ port 80'i bağlar, hâlâ paket kurar, çünkü namespace içinde namespace'e ait şeyler üzerinde gerçekten uid 0'dır. Host'a ait bir şeye dokunduğu an 100000'dir.
User namespace olmadan tablo da yoktur. Uid 0, uid 0'dır ve onu durduran tek şey capability'ler (root'un neye izinli olduğu), seccomp (hangi sistem çağrılarını yapabileceği) ve mount namespace'idir (ne görebileceği). Üçü de container başına ayarlanabilir ve üçü de bir flag ile zayıflatılabilir.
Dockerfile'lar: A'dan D'ye
Lab tek bir ~30 satırlık Flask servisi ve dört Dockerfile kullanır. Önemli olan ikisi şunlar.
C: slim, multi-stage, non-root
FROM python:3.13-slim AS build
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --target /build/deps -r requirements.txt
FROM python:3.13-slim
RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --home /app --shell /usr/sbin/nologin app
WORKDIR /app
COPY --from=build /build/deps /app/deps
COPY --chown=app:app app.py .
ENV PYTHONPATH=/app/deps PYTHONDONTWRITEBYTECODE=1
USER app
EXPOSE 8080
CMD ["python", "app.py"]
Sadece isim değil sabit bir sayısal uid (10001), çünkü Kubernetes runAsNonRoot yalnızca sayısal uid'i doğrulayabilir; imajdan uid'ini çözemediği bir USER app, "image has non-numeric user" olarak raporlanır. Bağımlılıklar bir build aşamasında kurulup kopyalanır, böylece pip, önbelleği ve build araçları hiçbir zaman çalışma zamanı imajına ulaşmaz. Servis 80 değil 8080 dinler, çünkü non-root bir süreç CAP_NET_BIND_SERVICE olmadan 1024 altını bağlayamaz.
D: distroless
FROM python:3.11-slim AS build
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --target /build/deps -r requirements.txt
FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
COPY --from=build /build/deps /app/deps
COPY app.py .
ENV PYTHONPATH=/app/deps
CMD ["app.py"]
Distroless imajlarda shell, paket yöneticisi ve root kullanıcı yoktur; :nonroot etiketi uid 65532 ile çalışır. docker exec -it ... sh'in çalıştıracağı hiçbir şey yoktur, mesele de budur. Hata ayıklama geçici bir container (kubectl debug) ile ya da loglara bakarak yapılır. Python sürümü Debian 12 neyi getiriyorsa odur (3.11), bu yüzden bağımlılık aşaması aynı ara sürümü kullanır.
Lab: tek uygulama, dört imaj, üç çalıştırma biçimi
Servis waitress arkasında ~30 satırlık bir Flask; kendi uid'ini ve /'a yazıp yazamadığını döndürüyor. Dört Dockerfile: A python:3.13, B python:3.13-slim, C slim + multi-stage + USER 10001, D distroless. Her şey yerelde Colima üzerinde koştu (bir Lima VM'i, aşağıdaki kaçış testlerinde Docker host'u o), Trivy 0.65.0 ve kind 0.33.0. Registry yok, bulut yok.
A. Dört imaj: boyut, kullanıcı, shell

Tam imaj 1141 MB; aynı uygulama için slim olan 157 MB, çünkü python:3.13 uygulamanın hiç çağırmadığı derleyici ve araçlarla dolu bütün bir Debian getiriyor. Non-root imaj (C) 149 MB ve uid 10001 ile çalışıyor. Distroless (D) 71 MB, uid 65532 ile çalışıyor ve hiç shell'i yok: docker run --entrypoint sh başarısız, çünkü çalıştıracak bir şey yok. Aynı JSON'u veriyor ({"uid":65532,"user":"nonroot","writable_root":false}), Python 3.11 ile çünkü Debian 12 distroless onu getiriyor.
B. Trivy: imaj başına CVE sayısı


En büyük kazanım en ucuz olanı: python:3.13'ten python:3.13-slim'e geçmek paket sayısını 494'ten 114'e, CVE sayısını 3998'den 194'e indirdi; tabanda tek satırlık değişiklikle, yüzde 95 düşüş. Slim'de hiç CRITICAL kalmadı.
Sonra dürüst sonuç olduğu için tuttuğum bir sürpriz: distroless'ın paketi slim'den az (42'ye karşı 114) ama CVE'si fazla (329'a karşı 194), 2 CRITICAL dahil. Sebep "taban" sütununda: distroless python3-debian12 imajı Debian 12 ve Python 3.11 izliyor, slim ise Debian 13 ve Python 3.13. Daha az ama daha eski paket; libsqlite3, libexpat1 ve standart kütüphane build'lerinde yamanmamış CVE'lerle. Distroless yine önemli yerde kazanıyor, 71 MB, shell yok, paket yöneticisi yok, varsayılan non-root, read-only root, ama ham CVE sayısında değil. Devamı Nerede patladı #1'de.
C. Container'daki root, host'taki root'tur
"Container'daki root" ne demek, görmenin en net yolu container'a host'u verip izlemek. Aşağıdaki her koşu host dosya sistemini /host'a bağlıyor (-v /:/host), dikkatsiz bir volume ya da ele geçmiş bir job'ın yapacağı gibi.

Root container /host/etc/shadow'u okudu, yalnızca root'un okuyabildiği dosyayı, ve host'un uid 0 olarak sahiplendiği bir dosya yazdı. uid_map'i 0 0 4294967295: kimlik eşlemesi, yani user namespace yok ve içeride uid 0, host'ta uid 0. Bu bir exploit değil; bir volume ve bir uid tam tanımlandığı gibi davranıyor.

Aynı imajı --user 10001 ile çalıştır, hem okuma hem yazma izin reddedildi ile düşüyor. Yalnızca uid değişti. O görselin alt yarısı insanların kaçırdığı nokta: --cap-drop ALL, --security-opt no-new-privileges ve varsayılan seccomp profili olan bir root container yine shadow dosyasını okudu, çünkü root'a ait bir dosyayı okumak uid 0 gerektirir ve capability düşürmek uid'inizi değiştirmez.
D. Aynı root imajı, bir user namespace ile zararsız
Non-root, imajı değiştirmek demek. User namespace imaja dokunmadan korur: Docker daemon'da userns-remap'i açtım ve orijinal root imajını yeniden çalıştırdım.

İçeride id hâlâ uid 0 diyor ve imaj bayt bayt aynı. Ama uid_map artık 0 100000 65536: çekirdek container'ın root'unu host uid 100000'e eşliyor ve host'a karşı her kontrol o sayıyı kullanıyor. Shadow okuma ve host yazma ikisi de düşüyor. İmaj root kaldı; çalışma zamanı onu güvenli yaptı. Bedel gerçek ve yukarıdaki tabloda: mevcut volume'ler sahip değiştiriyor, --privileged çalışmıyor, ve bu daemon genelinde bir yöneticinin kontrol ettiği bir ayar.
E. Kubernetes: cluster non-root'ta ısrar edebilir
Cluster aynı fikri runAsNonRoot: true ile dayatıyor. Her pod'a bu securityContext'i verdim ve yalnızca imajı değiştirdim.

- securityContext olmayan düz bir root pod çalışıyor; cluster varsayılan olarak root'u durdurmuyor.
runAsNonRoot: truealtında tam root imajıCreateContainerConfigErrorile reddedildi: "container has runAsNonRoot and image will run as root". Hiç başlamıyor.- Distroless (imaj kullanıcısı
65532) başlıyor. Non-root imaj da başladı, ama ancak gerçek bir tuzağı düzelttikten sonra; Nerede patladı #2'de. hostUsers: false(Kubernetes user namespace'leri) kind'ın varsayılan node'unda olmayan bir node desteği istiyor; sysfs mount hatasıyla düştü. D bölümündeki daemonuserns-remapgösterimi aynı çekirdek mekanizması ve o koştu.
Nerede patladı: gerçek sürprizler
1. Distroless'ın CVE'si slim'den fazlaydı, çünkü tabanı bir sürüm geride
En küçük imajın en az CVE'si olmasını bekledim. En az paketi vardı (42'ye karşı slim'in 114'ü) ama daha fazla CVE'si: 329'a karşı 194, 2 CRITICAL'a karşı 0. gcr.io/distroless/python3-debian12 imajı Debian 12 ve Python 3.11'e sabitli, python:3.13-slim ise Debian 13 ve Python 3.13. Eski taban, yamanmamış CVE'li daha eski libsqlite3, libexpat1 ve standart kütüphane build'leri taşıyor. Ders: daha az paket otomatik olarak daha az CVE değil; tabanın güncelliği boyutundan daha çok belirleyici. Distroless saldırı yüzeyi için (shell yok, paket yöneticisi yok, non-root, read-only root) yine doğru seçim, ama CVE sayısı için seçiyorsanız tarayın ve taban sürümünü kontrol edin, varsaymayın.
2. runAsNonRoot non-root imajımı reddetti, çünkü USER bir isimdi
İlk non-root Dockerfile'ım USER app ile bitiyordu. Container Docker altında sorunsuz çalıştı (id uid 10001 dedi). Kubernetes runAsNonRoot: true altında CreateContainerConfigError ile reddedildi: "container has runAsNonRoot and image will run as root". Kubernetes imajdan /etc/passwd'yi okumaz, bu yüzden app ismini bir uid'e çeviremez; yalnızca sayısal bir uid'i doğrulayabilir ve doğrulanamayan kullanıcı root sayılır. docker image inspect --format '{{.Config.User}}' 10001 değil app gösterdi. Dockerfile'ı USER 10001 yapmak Config.User'ı sayısal yaptı ve pod başladı. Distroless baştan geçti çünkü :nonroot etiketi USER'ı 65532 yapıyor. USER'a her zaman sayısal bir uid koyun, ya da pod'da runAsUser ayarlayın.
3. --cap-drop ALL root container'ın host'u okumasını durdurmadı
Root container korkutucu olunca ilk içgüdü capability düşürmek. Root imajını --cap-drop ALL --security-opt no-new-privileges ve varsayılan seccomp profiliyle çalıştırdım ve yine /host/etc/shadow'u okudu. Root'a ait bir dosyayı okumak uid 0 gerektirir ve container hâlâ uid 0'dı; "zaten uid'inin sahip olduğu bir dosyayı oku" diye bir capability yok. Capability'ler özel işlemleri sınırlar (düşük port bağla, başka dosyanın sahibini değiştir, modül yükle), sahip uid'in sıradan dosya erişimini değil. Düzeltme uid 0 olmamaktır: non-root bir kullanıcı ya da user namespace.
4. userns-remap'i açmak mevcut tüm imajları gizledi
userns-remap'i ayarlayıp daemon'ı yeniden başlatınca docker images birden boştu. Daemon remap'li container ve imajları ayrı bir dizinde saklıyor (/var/lib/docker/<uid>.<gid>/), bu yüzden geçişten önce derlenen hiçbir şey görünmüyor ve mevcut adlı volume'ler ilk kullanımda sahip değiştiriyor. İmajı önce docker save ile bir tar'a kaydedip geri yüklemiştim; userns-remap'i mevcut bir host'ta denemenin acısız yolu bu. Paylaşılan bir host'ta çevirmeden önce depolama ve volume kaymasını planlayın.
Ne zaman hangisi
| Durum | Yap | Neden |
|---|---|---|
| Her yeni Dockerfile | multi-stage, slim ya da distroless taban, sayısal uid'li USER, 1024 üstü dinle |
imaj, tamamen kontrol ettiğiniz tek şey |
| Root'ta ısrar eden üçüncü parti imaj | tolere ediyorsa --user / runAsUser; etmiyorsa user namespace altında çalıştır |
yeniden derleyemezsiniz ama sınırlayabilirsiniz |
| Geliştirici dizüstüleri, imaj derleyen CI runner'ları | rootless Podman ya da rootless Docker | daemon'ın kendisi makinenizde root süreç olmaktan çıkar |
| Paylaşılan Docker host | daemon.json'da userns-remap |
her container'ın root'u yetkisiz bir host uid'i olur, imaj değişmez |
| Kubernetes cluster | namespace'lerde restricted Pod Security Standard; runtime destekliyorsa hostUsers: false |
politika non-root imajı zorunlu kılar; user namespace kaçanları yakalar |
| İmaj tarama | CI'da Trivy (ya da Grype), düzeltmesi bilinen CRITICAL'da gate | CVE'lerin çoğu taban imajda yaşar, taban değiştirmek tek satırlık düzeltmedir |
| Üretimde shell lazım | geçici container'la kubectl debug |
uygulama imajı shell'siz kalır |
Özet
- Container, etrafında namespace, cgroup, capability ve seccomp olan bir süreçtir; bunların hiçbiri uid'ini değiştirmez. İçerideki root, çekirdek için uid 0'dır, host'taki root ile aynı.
- Host içine bağlıyken root container host'un
/etc/shadow'unu okudu ve host'un root olarak sahiplendiği dosyalar yazdı.--cap-drop ALLbunu durdurmadı, çünkü dosya erişimini capability değil uid belirler. - İki düzeltme, iki farklı sorun için. Non-root kullanıcı (
USER 10001,runAsNonRoot) imajı düzeltir. User namespace (userns-remap, rootless Docker ya da Podman,hostUsers: false) çalışma zamanını düzeltir ve root imajı olduğu gibi bırakır. İkisi de root'un geçtiği aynı okumada düştü. - İmaj sıkılaştırma varsayılmaz, ölçülür:
python:3.13'te 3998 CVE,slim'de 194 vardı ve full'dan slim'e geçiş tek satırlık bir değişiklik. Distroless 71 MB ve shell'siz, ama eski Debian 12 tabanı 329 CVE taşıdı, slim'den fazla; o yüzden boyutuna güvenmek yerine tarayın. - Kubernetes
runAsNonRootyalnızca sayısal uid'i doğrular;USER approot sayılır ve reddedilir.USER'a bir sayı koyun.
Seride sırada
Bu, Temel Konular, Ölçerek serisinin 4. yazısı. Önceki yazı, SAST ve DAST Arasındaki Fark Nedir?, bir pipeline'ın tarayıcılarının neyi yakaladığını saydı; bu yazı imajın ve çalışma zamanının neyi geçirdiğini saydı. Sırada: CMD vs ENTRYPOINT, ADD vs COPY, herkesin kopyaladığı ama azının açıklayabildiği Dockerfile komutları; sinyallere, argümanlara ve cache'e ne olduğuyla ölçülerek.
Bu lab'ın kodu (uygulama, dört Dockerfile, build, tarama ve kaçış script'leri, kind manifest'leri ve Makefile) Buy Me a Coffee'de Lab Access üyeleriyle paylaşılıyor; katıldıktan sonra repo daveti e-posta ile gelir: https://buymeacoffee.com/miraccanyilmaz/membership