kertenkerem_
· DevOps · 6 dk okuma

Linux Namespace ve Cgroup: Container’ların Altındaki Mekanizma

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. İşletim sistemi seviyesinde “container” diye bir nesne ya da teknoloji yok. Docker, containerd ya da podman dediğ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ünden (wrapper) ibaret: namespace ve cgroup.

İş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 başına yaşayabilmesi için önce 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 yeterince katı bir izolasyon sağlamaz ve root yetkilerine sahip bir proses bu hapishaneden kolayca 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 önce yeni bir mount namespace oluşturmamız gerekir.

Bunun için unshare komutunu kullanacağız. 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

Bu andan itibaren açılan 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

Artık yalnızca kendi hazırladığımız rootfs içindeki ikili dosyaları (binaries) görebilen, izole edilmiş bir dosya sistemindeyiz. Ama eksik bir şeyler var: Prosesler nerede?

3. PID namespace: “Ben kimim?” sorusu

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, hâlâ 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. Böylece host ile 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

Container dünyasının kutsal kasesi bu. 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; bu proses ölürse kernel tüm namespace’i sonlandırır.

4. Network namespace: kablolama işlemleri

Şu ana kadar dosya sistemini ve prosesleri izole ettik, ancak container’ımızın dış dünyayla 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.

Önce 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: kaynakları sınırlandırmak

Elimizde izole bir ortam ve ağ var. Peki ya bu container içindeki bir proses çıldırır ve host sistemin tüm CPU ve RAM kaynaklarını tüketmeye çalışırsa? Burada cgroup (Control Groups) devreye 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 çok 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

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 ve ondan türeyecek (fork) tüm alt süreçler toplamda 100 MB bellek sınırını aşamaz. Sınırı aşmaya çalıştıkları anda kernel’ın meşhur OOM-Killer (Out of Memory Killer) mekanizması devreye girer ve o prosesi sonlandırır. Tıpkı Kubernetes ortamında aldığınız o meşhur OOMKilled hatası gibi.

Özet: container aslında bir illüzyondur

Gördüğünüz gibi arka planda çalışan gizemli hipervizörler, sanal donanımlar ya da ağır sanallaştırma katmanları yok. Yaptığımız her şey, Linux kernel’ına “Bu prosese yalnızca şu dizini göster (mount namespace), yalnızca ş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 otomatikleştirerek bize pratik bir 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ı çözmenizi sağlar.

Bir sonraki yazıda görüşmek üzere. Sistemleriniz ayakta, container’larınız hafif kalsın.