Linux Namespace ve Cgroup: Container’ların Altındaki Mekanizma
Eğer bir teknik mülakatta ya da kahve molasında “Container nedir?” sorusuna “Hizmetleri izole eden hafifletilmiş sanal makinelerdir (VM)” yanıtını verdiyseniz, bugün bu ezberi bozuyoruz. Çünkü aslında işletim sistemi seviyesinde “container” diye bir nesne veya teknoloji bulunmuyor. Bizim docker, containerd ya da podman adını verdiğimiz araçların yaptığı her şey, linux çekirdeğinin (kernel) sunduğu iki temel özelliğin etrafına örülmüş şık birer kullanıcı arayüzü (wrapper) olmaktan ibaret: namespace ve cgroup.
Bu makalede, işin kolayına kaçıp hazır CLI araçları kullanmak yerine, modern bir container runtime’ın (örneğin containerd) yaptığı işi tamamen elle (manual) yapacağız. Sıfırdan izole bir root filesystem (rootfs) hazırlayacak, kendi ağ geçidimizi kuracak, prosesleri izole edecek ve cgroup v2 ile kaynak sınırları koyacağız. Arkanıza yaslanın, terminalinizi açın ve container dünyasının arka bahçesine hoş geldiniz.
1. Hazırlık: Minimal Bir Root Filesystem (rootfs) Oluşturmak
Bir container’ın kendi dünyasında yaşayabilmesi için öncelikle bağımsız bir dosya sistemine ihtiyacı vardır. Docker imajları aslında katmanlaştırılmış (layered) tar dosyalarından başka bir şey değildir. Biz de işe minimal bir Alpine Linux rootfs indirerek başlayacağız.
# Çalışma dizinimizi oluşturalım
mkdir -p /tmp/kerten-container/rootfs
cd /tmp/kerten-container
# Alpine rootfs indiriyoruz
curl -sSL https://dl-cdn.alpinelinux.org/alpine/v3.18/releases/x86_64/alpine-minirootfs-3.18.4-x86_64.tar.gz -o alpine.tar.gz
tar -xzf alpine.tar.gz -C rootfs/
rm alpine.tar.gz
Artık elimizde izole bir işletim sisteminin sahip olması gereken tüm temel dizin yapısı (bin, sbin, etc, lib, proc…) mevcut. Sıradaki adım, bu dizini yeni dünyamızın kök dizini (root) haline getirmek.
2. Chroot Değil, Pivot Root: Güvenli Mount Namespace
Dosya sistemi izolasyonu denince akla gelen ilk syscall (sistem çağrısı) genellikle chroot olur. Ancak chroot esnektir ve root yetkilerine sahip bir proses kolayca bu hapishaneden kaçabilir (jailbreak). Modern container dünyası bunun yerine çok daha güvenli olan pivot_root sistem çağrısını kullanır.
pivot_root, mevcut mount namespace’in root mount noktasını yeni bir dizine taşır ve eski root’u başka bir dizine bind eder. Bunu elle simüle etmek için öncelikle yeni bir mount namespace oluşturmamız gerekir.
İşte sihirli komutumuz: unshare. Bu komut, belirtilen namespace türlerini sıfırdan oluşturarak yeni bir proses başlatır.
# Mount, UTS (hostname) ve IPC namespace'lerini izole ederek yeni bir bash oturumu açıyoruz
unshare --mount --uts --ipc --fork /bin/bash
Şu andan itibaren açılan bu yeni shell oturumunda yaptığımız mount işlemleri host sistemimizi etkilemeyecek. Şimdi rootfs dizinimizi bir mount noktasına dönüştürelim ve pivot_root için hazırlayalım:
# rootfs dizinimizi bir bind mount olarak işaretliyoruz (pivot_root bunu şart koşar)
mount --bind /tmp/kerten-container/rootfs /tmp/kerten-container/rootfs
# Eski root dizinini koyacağımız geçici bir klasör oluşturuyoruz
mkdir -p /tmp/kerten-container/rootfs/put_old
# pivot_root komutunu çalıştırıyoruz: [yeni_root] [eski_root_un_duracagi_yer]
cd /tmp/kerten-container/rootfs
pivot_root . put_old
# Artık yeni root içerisindeyiz. Eski sisteme ait mount noktalarını temizleyelim
cd /
umount -l /put_old
rmdir /put_old
# Hostname'i değiştirelim (UTS namespace sayesinde host etkilenmez)
hostname kerten-container
Harika! Şu an sadece kendi hazırladığımız rootfs içerisindeki ikili dosyaları (binaries) görebilen, izole edilmiş bir dosya sistemindeyiz. Ancak eksik bir şeyler var: Prosesler nerede?
3. PID Namespace: “Ben Kimim?” Sorusu
Eğer az önce oluşturduğumuz ortamda ps aux çalıştırmayı denerseniz, hata alırsınız. Çünkü proseslerin yönetildiği ve kernel bilgilerinin okunduğu sanal dosya sistemi olan /proc henüz mount edilmedi. Daha da önemlisi, hala host sistemin PID (Process ID) uzayını paylaşıyoruz.
Gelin, yeni bir PID namespace oluşturalım. Bunun için yeni bir terminal penceresi açıp host üzerinde çalışmaya devam edeceğiz. Bu sayede hem host hem de container arasındaki ilişkiyi daha net görebiliriz.
Bu sefer PID namespace’i de işin içine katarak unshare ile yeni bir container başlatalım (dosya sistemi adımlarını bu yeni namespace içinde hızlıca tekrarladığımızı varsayalım veya doğrudan aşağıdaki komutla temiz bir başlangıç yapalım):
# PID ve Mount namespace izole edilmiş şekilde başlatıyoruz
unshare --mount --pid --fork --mount-proc /bin/bash
--mount-proc parametresi, bizim için otomatik olarak yepyeni ve izole bir /proc dosya sistemi mount eder. Şimdi bu oturumda prosesleri listeleyelim:
ps aux
Çıktıya dikkat edin:
PID USER TIME COMMAND
1 root 0:00 /bin/bash
2 root 0:00 ps aux
İşte container dünyasının kutsal kasesi! Host üzerinde binlerce çalışan proses varken, bizim container’ımız kendisini dünyadaki tek proses (PID 1) olarak görüyor. PID 1 olmak büyük bir sorumluluktur; eğer bu proses ölürse, kernel tüm namespace’i sonlandırır.
4. Network Namespace: Kablolama İşlemleri
Şu ana kadar dosya sistemini ve prosesleri izole ettik ancak container’ımızın dış dünya ile bağlantısı yok (loopback arayüzü bile kapalı). Docker arkada bu işi sanal bir switch (docker0 bridge) ve veth pair (sanal ethernet kablosu) kullanarak çözer.
Gelin bu kablolamayı host üzerinde elle yapalım. Bu senaryo için host üzerinde root yetkileriyle yeni bir terminal açın.
Öncelikle container’ımızın namespace’ini kalıcı hale getirmemiz gerekir ki host üzerinden oraya erişebilelim. Linux’ta her namespace /proc/[PID]/ns/ altında bir dosya olarak temsil edilir.
# Container'ımızın PID'sini host üzerinde bulalım (örneğin 12345 olsun)
# Host üzerinde:
ip netns attach kerten-netns 12345
Şimdi sanal ethernet çiftimizi oluşturalım. Bu işlem, bir ucu hostta, diğer ucu container içinde olan sanal bir kablo yaratacaktır:
# veth çiftini oluştur
ip link add veth-host type veth peer name veth-container
# Kablonun container ucunu container'ın network namespace'ine taşıyalım
ip link set veth-container netns kerten-netns
# Host tarafındaki uca IP verelim ve ayağa kaldıralım
ip addr add 10.200.0.1/24 dev veth-host
ip link set veth-host up
# Container tarafındaki uca IP verelim ve ayağa kaldıralım
ip netns exec kerten-netns ip addr add 10.200.0.2/24 dev veth-container
ip netns exec kerten-netns ip link set veth-container up
ip netns exec kerten-netns ip link set lo up # Loopback'i unutmayalım
# Container için default gateway tanımlayalım
ip netns exec kerten-netns ip route add default via 10.200.0.1
Artık container içinden host tarafındaki 10.200.0.1 IP adresine ping atabilirsiniz. Container runtime’ların her container için saniyeler içinde yaptığı o karmaşık ağ konfigürasyonunun temel mekanizması tam olarak budur.
5. Cgroup v2 Sihri: Kaynakları Sınırlandırmak
Güzel, izole bir ortamımız ve ağımız var. Peki ya bu container içerisindeki bir proses çıldırır ve host sistemin tüm CPU ve RAM kaynaklarını tüketmeye çalışırsa? İşte burada devreye cgroup (Control Groups) giriyor.
Modern Linux dağıtımları artık varsayılan olarak cgroup v2 kullanıyor. Cgroup v2, v1’deki dağınık yapıyı tek bir hiyerarşik ağaç altında birleştirerek işleri inanılmaz derecede kolaylaştırdı. Cgroup v2 hiyerarşisi varsayılan olarak /sys/fs/cgroup dizininde yaşar.
Şimdi elle “kerten-limit” adında bir kontrol grubu oluşturalım ve container’ımızın bellek kullanımını 100 MB ile sınırlayalım:
# Host üzerinde cgroup dizinine gidelim
cd /sys/fs/cgroup
# Yeni bir grup oluşturmak sadece bir dizin oluşturmaktan ibarettir!
mkdir kerten-limit
cd kerten-limit
# Kernel, bu dizini oluşturduğumuz an içine kontrol dosyalarını otomatik olarak yerleştirir.
ls -la
Şimdi bellek sınırımızı (memory limit) 100MB (104857600 bytes) olarak ayarlayalım:
echo "104857600" > memory.max
Peki bu sınırı container prosesimize nasıl uygulayacağız? Çok basit: Container prosesimizin host üzerindeki PID’sini (örneğin 12345) cgroup altındaki cgroup.procs dosyasına yazmamız yeterli:
echo "12345" > cgroup.procs
Artık bu proses veya bu prosesten türeyecek (fork) olan tüm alt süreçler toplamda 100 MB bellek sınırını aşamazlar. Sınırı aşmaya çalıştıkları anda kernel’ın meşhur OOM-Killer (Out of Memory Killer) mekanizması devreye girecek ve o prosesi acımasızca sonlandıracaktır. Tıpkı Kubernetes ortamında aldığınız o meşhur OOMKilled hatası gibi!
Özet: Container Aslında Bir İllüzyondur
Gördüğünüz gibi, arka planda çalışan gizemli hipervizörler, sanal donanımlar veya ağır sanallaştırma katmanları yok. Yaptığımız her şey, Linux kernel’ına “Bu prosese sadece şu dizini göster (mount namespace), sadece şu prosesleri görmesine izin ver (PID namespace), ağ trafiğini şu sanal kabloya yönlendir (net namespace) ve şu kadar kaynak tüketmesine izin ver (cgroups)” demekten ibaretti.
Docker ve containerd gibi araçlar, bu karmaşık syscall ve CLI yönetimini otomatize ederek bize pratik birer imaj paketleme ve dağıtım standardı sunar. Bu temel mekanizmayı kavramak, Kubernetes ortamlarında veya büyük ölçekli altyapılarda karşılaştığınız ağ, performans ve izolasyon sorunlarını (troubleshooting) çok daha hızlı ve profesyonelce çözmenizi sağlayacaktır.
Bir sonraki derin dalış makalemizde görüşmek üzere, sistemleriniz ayakta, container’larınız hafif kalsın!