Mert Özen Her Satırda Daha İleri
Bilgisayar Bilimi Temelleri İşletim Sistemi Temelleri — #09

Container'ı İşletim Sistemi Gözüyle Anlamak: Namespace, cgroup v2 ve "Container Sadece Bir Process'tir"

Mert Özen 14 Kas 2026 10 dk 1 görüntülenme
Container'ı İşletim Sistemi Gözüyle Anlamak: Namespace, cgroup v2 ve "Container Sadece Bir Process'tir"

Container, namespace'lerle neyi göreceği ve cgroup'larla ne kadar kullanacağı sınırlanmış sıradan bir process'tir; host'un kernel'ini paylaşır. Container'da PID 1 olan dotnet'in host'ta 300861 numaralı bir process olduğunu, user namespace'in paylaşıldığını, .NET'in CPU ve bellek limitini cgroup'tan okuduğunu ve pids.max'a çarpan bir thread açılışının bellek boşken "Out of memory." ile çöktüğünü gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; testler xUnit v3 4.0.3 ile yazıldı ve .NET 11 karşılaştırması 11.0.0 runtime'ı ile yapıldı. Ölçümler Docker 29 ile mcr.microsoft.com/dotnet/sdk:10.0 ve mcr.microsoft.com/dotnet/runtime:11.0 container'larında (Ubuntu 24.04, Linux 6.x kernel, cgroup v2) alındı. Namespace ve cgroup bilgileri Linux man sayfalarına ve kernel dokümanına, Windows bilgileri Microsoft Learn'e dayanır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) bir container'da çalışıyor. Container'ın içinde ps dediğinde dotnet process'inin PID'si (process ID; kernel'in process'e verdiği numara) 1. Container'ı çalıştıran Linux makinesinde aynı process'e bakarsan PID'si ne olur: yine 1 mi, başka bir sayı mı, yoksa process orada hiç görünmez mi?

İkinci soru: container'ın içinde id komutu uid=0(root) diyor. Bu root, makinenin root'uyla aynı kullanıcı mı? Bu son yazıda serinin bütün parçalarını bir araya getirip container'a OS'un gözüyle bakıyoruz.

Container = Process + Namespace + cgroup

Linux kernel'inde "container" diye bir nesne yoktur. Container, iki kernel özelliğiyle çevrelenmiş sıradan bir process'tir. Namespace'ler process'in neyi görebileceğini sınırlar: kendi PID numaraları, kendi network arayüzleri, kendi dosya sistemi görünümü, kendi hostname'i. cgroup'lar process'in ne kadar kullanabileceğini sınırlar: CPU, bellek, I/O, process sayısı. Docker, containerd ve Kubernetes bu iki özelliği yöneten araçlardır; image ise process'in göreceği dosya sistemini hazır paketler.

VM ile farkı buradan çıkar. Bir VM kendi kernel'ini çalıştırır; container ise host'un kernel'ini paylaşır. Bunu iki farklı dağıtımın image'ıyla görmek kolay:

docker run --rm alpine:3 head -1 /etc/os-release
docker exec bookstore head -1 /etc/os-release
[ "$(docker run --rm alpine:3 uname -r)" = "$(docker exec bookstore uname -r)" ] && echo "same kernel in alpine and ubuntu containers"
NAME="Alpine Linux"
PRETTY_NAME="Ubuntu 24.04.5 LTS"
same kernel in alpine and ubuntu containers

Kullanıcı tarafı (libc, shell, paketler) farklı; kernel aynı. Birinci yazıdaki system call'lar, ikinci yazıdaki process tablosu, dördüncü yazıdaki scheduler ve altıncı yazıdaki OOM (out of memory; bellek bitince process öldüren kernel mekanizması) killer, container'daki process için de aynı kernel'in aynı mekanizmalarıdır.

Namespace'ler: Process Neyi Görür?

namespaces(7) man sayfası sekiz namespace türü sayıyor: cgroup, IPC (process'ler arası iletişim nesneleri), network, mount, PID, time, user ve UTS (hostname ve domain adı). Her process her türden tam olarak bir namespace'in üyesidir ve üyelik /proc/<pid>/ns altında bir symlink olarak görünür; köşeli parantez içindeki sayı namespace'in kimliğidir. Bookstore container'ını limitleriyle başlattım ve içine baktım:

docker run -d --name bookstore --memory=256m --cpus=1.5 --pids-limit=100 -v ${PWD}/out:/app -w /app mcr.microsoft.com/dotnet/sdk:10.0 dotnet Bookstore.OsLab.dll serve 1
docker exec bookstore sh -c 'ps -eo pid,comm; hostname; id; for n in pid net mnt uts ipc user cgroup; do printf "%s -> %s\n" $n $(readlink /proc/1/ns/$n); done'
    PID COMMAND
      1 dotnet
     14 sh
     20 ps
70992f41ff59
uid=0(root) gid=0(root) groups=0(root)
pid -> pid:[4026532694]
net -> net:[4026532696]
mnt -> mnt:[4026532690]
uts -> uts:[4026532691]
ipc -> ipc:[4026532692]
user -> user:[4026531837]
cgroup -> cgroup:[4026532695]

PID namespace'i sayesinde dotnet kendini PID 1 olarak görüyor; sekizinci yazıdaki signal davranışı bundandı. UTS namespace'i hostname'i container ID'sinin kısa hâline çevirmiş. Mount namespace'i ona image'ın dosya sistemini gösteriyor, network namespace'i kendi arayüzlerini. Dikkat çeken satır user: diğerlerinin sayıları birbirine yakın ve yeni, user namespace'inin numarası ise çok daha küçük. Docker varsayılan olarak yeni bir user namespace'i oluşturmaz; container host'un user namespace'ini paylaşır. İkinci sorunun cevabına döneceğiz.

Container'ı Dışarıdan Görmek

Şimdi birinci soru. Docker, container'ın ana process'inin host tarafındaki PID'sini bilir:

docker top bookstore -eo pid,comm
docker inspect bookstore --format 'State.Pid={{.State.Pid}}'
PID                 COMMAND
300861              dotnet
State.Pid=300861

Cevap: başka bir sayı. Container'daki PID 1, host'un PID namespace'inde 300861 numaralı sıradan bir process. Host'un ps çıktısında öteki process'lerin arasında görünür, top'ta CPU'su sayılır ve host'tan kill ile durdurulabilir. Bir process aynı anda iç içe birden çok PID namespace'inde, her birinde farklı bir numarayla yaşar; proc(5)'e göre /proc/<pid>/status'taki NSpid satırı bu numaraların hepsini listeler. Bu ortamda host, Docker'ın Linux VM'iydi; Windows'ta Docker Desktop genellikle WSL (Windows Subsystem for Linux; Windows'ta Linux çalıştıran katman) 2'nin VM'ini kullanır ve Microsoft'un WSL dokümanına göre bu VM gerçek bir Linux kernel'i çalıştırır.

Namespace'ler paylaşılabilir de. Kubernetes'in debug container'ı, ya da Docker'da --pid=container:, ikinci bir container'ı birincinin PID namespace'ine sokar:

docker run --rm --pid=container:bookstore mcr.microsoft.com/dotnet/sdk:10.0 sh -c 'ps -eo pid,comm; for n in pid net mnt uts; do printf "%s -> %s\n" $n $(readlink /proc/self/ns/$n); done; hostname'
    PID COMMAND
      1 dotnet
     30 sh
     36 ps
pid -> pid:[4026532694]
net -> net:[4026532966]
mnt -> mnt:[4026532962]
uts -> uts:[4026532963]
309d1303072f

PID namespace'inin numarası Bookstore'unkiyle aynı (4026532694) ve yeni container Bookstore'un dotnet'ini PID 1 olarak görüyor; network, mount ve UTS namespace'leri ise kendisine ait. Birinci yazıda bahsettiğimiz ephemeral debug container tam olarak bu: kendi araçlarıyla gelen ama hedef process'i görebilen bir container. Linux bir sunucuda aynı şeyi nsenter yapar: nsenter(1) man sayfasına göre verilen PID'nin namespace'lerine girip bir program çalıştırır; sudo nsenter -t 300861 -n ss -tlnp container'ın network namespace'inde host'taki ss'i çalıştırır. Bu adım root yetkisi istediği için bu yazıda çalıştırılmadı; docker exec de aynı işi Docker'ın kendi yetkisiyle yapar.

cgroup v2: Process Ne Kadar Kullanabilir?

Namespace görünürlüğü sınırlar, cgroup tüketimi. Kernel'in cgroup v2 dokümanına göre cgroup'lar /sys/fs/cgroup altında tek bir ağaçtır; her directory bir cgroup, içindeki dosyalar o cgroup'un controller'larının ayarları ve sayaçlarıdır: cpu.max, memory.max, io.max, pids.max. Container'a cgroup namespace'i de verildiği için /proc/self/cgroup 0::/ gösterir: process kendi cgroup'unu ağacın kökü sanır. Deney uygulamasına bu değerleri ve .NET'in onlardan ne anladığını yazan bir komut ekledim:

docker exec bookstore dotnet Bookstore.OsLab.dll container-info
runtime              = .NET 10.0.15
/proc/self/cgroup    = 0::/
memory.max           = 268435456
cpu.max              = 150000 100000
pids.max             = 100
ProcessorCount       = 2
GC available memory  = 192 MB
Process.MaxWorkingSet = 256 MB

--cpus=1.5 her 100 ms'lik periyotta 150 ms CPU zamanı demek; .NET bunu yukarı yuvarlayıp ProcessorCount'u 2 yaptı. Dördüncü yazıda --cpus=0.5'in 1 verdiğini ve throttling'i görmüştük. memory.max 256 MB; GC (garbage collector; belleği toplayan bileşen) bundan altıncı yazıdaki yüzde 75'lik heap sınırını (192 MB) türetti. Process.MaxWorkingSet de limiti cgroup'tan okudu.

pids.max en az bilinen ama en sinsi limit. cgroup dokümanına göre limit aşılırsa fork ve clone EAGAIN ile başarısız olur ve Linux'ta thread'ler de clone ile açıldığı için bu sınır thread'leri de sayar. Üçüncü yazıdaki thread-cost komutunu aynı container'da 200 thread ile çalıştırdım:

docker exec bookstore sh -c 'cat /sys/fs/cgroup/pids.current; dotnet Bookstore.OsLab.dll thread-cost 200; echo "exit=$?"; cat /sys/fs/cgroup/pids.events'
9
Out of memory.
Aborted
exit=134
max 2

Process "Out of memory." yazıp abort etti; exit code 134, yani 128 + SIGABRT (6). Bellek bitmemişti: kernel yeni thread'i EAGAIN ile reddetti ve runtime bunu bellek hatası olarak raporladı. pids.events'teki max sayacı limite kaç kez çarpıldığını tutar; iki denemeden sonra 2'ydi. Bu, log'a bakan birini yanlış yere götüren bir hata: OOMKilled yok, bellek grafiği düz, ama process ölüyor. 50 thread'le aynı komut sorunsuz çalıştı.

.NET'in Container Farkındalığı ve .NET 11

Runtime cgroup'u iki yerde okur: native taraf (GC'nin heap sınırı, ProcessorCount) ve managed taraf (Process sınıfının Linux'taki kodu). İkisinin ağacı dolaşma kodu ayrıdır. dotnet/runtime'daki 133470 numaralı issue'ya göre managed kod, process cgroup hiyerarşisinin kökünde durduğunda, yani yukarıdaki 0::/ durumunda, memory limitini ararken mount noktasının üstüne çıkabiliyordu; native kod bu sınırı zaten kontrol ediyordu. .NET 11'in RC (release candidate; son sürümden önceki aday sürüm) 2 sürüm notlarına göre runtime artık cgroup v2 memory limitlerini hiyerarşinin kökünde doğru dolaşıyor; düzeltme 11.0.0 GA'ya (general availability; herkese açık kararlı sürüm) girdi ve 10.0 dalına da backport edildi. Aynı container-info komutunu .NET 11 runtime image'ında çalıştırdım:

docker run --rm --memory=256m --cpus=1.5 -e DOTNET_ROLL_FORWARD=Major -v ${PWD}/out:/app -w /app mcr.microsoft.com/dotnet/runtime:11.0 dotnet Bookstore.OsLab.dll container-info
runtime              = .NET 11.0.0
/proc/self/cgroup    = 0::/
memory.max           = 268435456
cpu.max              = 150000 100000
pids.max             = max
ProcessorCount       = 2
GC available memory  = 192 MB
Process.MaxWorkingSet = 256 MB

Bu container'a --pids-limit vermediğim için pids.max max; geri kalan değerler .NET 10.0.15 ile aynı. Bu ortamda .NET 10.0.15 de limitleri doğru okudu. Pratik sonuç basit: container'da .NET kullanıyorsan güncel servis sürümünde kal, çünkü cgroup okuma kodu hâlâ düzeltme alıyor.

Güvenlik Sınırı: Aynı Kernel

Şimdi ikinci soru. User namespace paylaşıldığı için container'daki uid=0 kernel'in gözünde host'taki root'la aynı kullanıcıdır. Onu sınırlayan başka katmanlar var: Docker varsayılan olarak capability'lerin (root yetkisinin parçalara bölünmüş hâli) çoğunu düşürür ve bir seccomp (secure computing; process'in yapabileceği syscall'ları filtreleyen kernel özelliği) profili uygular:

docker exec bookstore grep -E "^(CapEff|Seccomp)" /proc/1/status
docker exec --user app bookstore id
CapEff:	00000000a80425fb
Seccomp:	2
Seccomp_filters:	1
uid=1654(app) gid=1654(app) groups=1654(app)

CapEff etkin capability'lerin bit maskesi; tam root'ta bütün bitler açık olurdu. Seccomp: 2 filtre modunun açık olduğunu söylüyor. Bunlar savunmayı derinleştirir ama sınırın kendisi aynı kernel'dir: kernel'deki bir güvenlik açığı, ya da --privileged gibi bu katmanları kapatan bir ayar, container'dan host'a geçişin yoludur. Hyper-V isolation'lı Windows container'ları ve Kata gibi VM tabanlı runtime'lar bu yüzden her container'a ayrı kernel verir. Microsoft'un Windows container dokümanına göre process isolation'da container'lar host'la kernel'i paylaşır, Hyper-V isolation'da her biri optimize edilmiş bir VM'de kendi kernel'iyle çalışır.

Senin tarafında en ucuz önlem root olarak çalışmamak. .NET'in resmi image'larında hazır bir app kullanıcısı (UID 1654, APP_UID değişkeninde) var; Dockerfile'da USER $APP_UID demek yeterli. Daha ileri adım user namespace remapping'tir: container'daki root host'ta yetkisiz bir UID'ye eşlenir. Dockerfile ve image güvenliği ilerideki Docker serisinin, Kubernetes'teki karşılığı (securityContext) yayınlanmış Kubernetes serisinin konusu.

Üretimde Dikkat

Container metriklerini cgroup'tan oku. memory.current, memory.events, cpu.stat'taki nr_throttled ve pids.events, container'ın neden yavaşladığını ya da öldüğünü process metriklerinden daha doğru söyler. Bir pod "nedensiz" çöküyorsa bu dört dosyaya bakmak, altıncı ve dördüncü yazılardaki ölçümlerin üretimdeki karşılığıdır.

Host'tan bakabilmenin değeri büyük. Bir container'daki process'i host'un top'unda bulabilir, /proc/<pid>/ altındaki bütün dosyaları (maps, status, fd) host'tan okuyabilirsin; bu serideki her gözlem container'ın dışından da yapılabilir.

Sık Düşülen Tuzaklar

Container'ı bir güvenlik sınırı olarak VM kadar güçlü sanmak. Aynı kernel'i paylaşan iş yüklerini birbirinden yalıtman gerekiyorsa (farklı müşterilerin kodu, güvenilmeyen eklentiler) VM ya da VM tabanlı bir container runtime düşün; root, --privileged ve host path mount'ları bu sınırı daha da inceltir.

Limitleri yalnız bellek ve CPU sanmak. pids.max thread'leri de sayar; thread başına bir istek açan eski bir kod ya da kontrolsüz Parallel.For, bellek boşken process'i düşürebilir. Kubernetes'te pod başına PID limiti cluster ayarıdır; hangi değerin geçerli olduğunu bil.

Container'daki PID 1'i sıradan bir process sanmak. PID 1'in signal'ları farklı işlediğini ve öksüz kalan process'leri toplaması gerektiğini sekizinci yazıda gördük; uygulamanı ya exec form'la PID 1 yap ya da önüne --init gibi küçük bir init koy, ama araya shell koyma.

Küçük Bir Deneme

★☆☆ Bookstore container'ını --cpus=2.5, --memory=1g ve --pids-limit=500 ile başlat; container-info'nun her satırını bu serideki ilgili yazının kuralıyla tahmin et, sonra çalıştırıp karşılaştır.

★★☆ docker run --rm --network=container:bookstore ile ikinci bir container başlat ve readlink /proc/self/ns/net'in Bookstore'unkiyle aynı olduğunu doğrula. Bu container'dan localhost'a bağlanınca kime ulaştığını düşün; Kubernetes pod'undaki container'ların neden localhost üzerinden konuşabildiğini bununla açıkla.

★★★ Bir Linux VM'inde (Windows'ta WSL 2 içinde) Docker kullanmadan bir container kur: unshare --pid --fork --mount-proc --uts ile yeni namespace'ler aç, /sys/fs/cgroup altında bir directory oluşturup memory.max'ı 128 MB yap, shell'in PID'sini cgroup.procs'a yaz ve orada dotnet Bookstore.OsLab.dll fill 1024 çalıştır. Altıncı yazıdaki sonuçla karşılaştır.

Seri burada bitiyor. Bir system call'dan başlayıp process, thread, scheduler, virtual memory, file system ve I/O üzerinden container'a geldik; hepsi aynı kernel'in, aynı .NET uygulamasına sunduğu hizmetlerdi. Bir sonraki "uygulamam neden böyle davranıyor" sorusunda cevabı çoğu zaman /proc'ta, bir strace satırında ya da bir cgroup dosyasında bulacaksın.