Kasım 8 2024

Docker Multi-Stage Build ile İmaj Boyutunu %80 Küçültme

Modern devops süreçlerinde, CI/CD pipeline’larının hızını doğrudan etkileyen en kritik unsurlardan biri optimize edilmiş container imajlarıdır. Üretim ortamına (production) her deployment çıktığımızda, şişkin bir docker imajının registry’ye push edilmesi ve ardından Kubernetes node’ları tarafından pull edilmesi ciddi bir zaman ve network maliyeti yaratır. İşte tam bu noktada, multi-stage build yaklaşımı imaj boyutlarını radikal bir şekilde düşürerek deployment süreçlerimizi hızlandırır, cold-start sürelerini minimize eder ve güvenlik açıklarını (vulnerabilities) büyük oranda azaltır. Bu yazıda, sadece teoriden bahsetmeyeceğiz; hantal bir imajı alıp adım adım ameliyat masasına yatıracağız.

Acı Gerçek: 800 MB’lık Hello World İmajı Nasıl Yapılır?

Pek çok yazılım ekibinde, “çalışıyor işte kurcalama” mantığıyla yazılmış Dockerfile’lar görürüz. Özellikle Go, Rust gibi statik olarak derlenen dillerde veya Node.js gibi devasa node_modules klasörüne sahip ortamlarda bu durum tam bir faciaya dönüşebilir. Gelin, basit bir Go uygulaması üzerinden durumu simüle edelim.

Aşağıda, sadece HTTP 200 dönen basit bir Go web sunucusunun Dockerfile’ı yer alıyor:

FROM golang:1.21

WORKDIR /app

COPY . .

RUN go build -o main .

EXPOSE 8080

CMD ["./main"]

Bu Dockerfile ile build aldığınızda, karşınıza çıkacak imaj boyutu yaklaşık 850 MB civarında olacaktır. Peki neden? Çünkü Go derleyicisine, paket yöneticisine, Debian tabanlı işletim sisteminin tüm araçlarına (curl, git, apt vb.) sadece uygulamayı çalıştırmak için ihtiyacımız yok. Bunlar sadece derleme (build-time) aşamasında lazım olan araçlar. Production runtime’ında bu araçların durması hem gereksiz bir disk yükü hem de potansiyel birer güvenlik açığıdır.

Multi-Stage Build Nedir ve Neden Hayat Kurtarır?

Multi-stage build, tek bir Dockerfile içerisinde birden fazla FROM ifadesi kullanarak imaj oluşturma sürecini aşamalara (stage) bölmemizi sağlar. Derleme araçlarını ilk aşamada bırakır, sadece ortaya çıkan nihai çıktıyı (artifact) bir sonraki temiz ve hafif aşamaya kopyalarız.

Bu yöntemle, ilk aşamada (builder) tüm ağır SDK’leri kullanabilir, işimiz bittiğinde ise sadece binary dosyasını alıp minimalist bir base image üzerine koyabiliriz. Böylece, yüzlerce megabaytlık derleme araçları nihai imajın katmanlarında (layer) yer almaz.

Dockerfile’ı Ameliyat Ediyoruz

Şimdi yukarıdaki kötü senaryoyu multi-stage kullanarak optimize edelim. Dockerfile’ımızı iki aşamaya bölüyoruz: “builder” ve “runner”.

# 1. Aşama: Derleme (Build Stage)
FROM golang:1.21-alpine AS builder

WORKDIR /app

# Bağımlılıkları kopyala ve indir
COPY go.mod go.sum ./
RUN go mod download

# Kaynak kodları kopyala ve derle
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/main .

# 2. Aşama: Çalıştırma (Runtime Stage)
FROM alpine:3.18

WORKDIR /app

# Sadece derlenmiş binary'yi ilk aşamadan kopyalıyoruz
COPY --from=builder /app/main .

EXPOSE 8080

# Non-root user tanımlayarak güvenliği artırıyoruz
RUN adduser -D appuser
USER appuser

CMD ["./main"]

Sonuç inanılmaz: İmaj boyutu 850 MB’tan 20 MB seviyelerine düştü! Yaklaşık %97 oranında bir küçülme elde ettik. Peki burada tam olarak ne yaptık? Neden bu kadar büyük bir fark oluştu?

  • AS builder: İlk satırda bu aşamaya bir isim verdik. Böylece sonraki aşamalarda bu isme referans verebiliyoruz.
  • Alpine Base Image: Derleme aşamasında Debian yerine hafif bir Alpine imajı seçtik.
  • CGO_ENABLED=0: Go uygulamasını derlerken C kütüphanelerine statik bağımlılık olmamasını sağladık. Bu sayede binary dosyamız tamamen taşınabilir hale geldi.
  • COPY –from=builder: İşte sihirli an! İkinci aşamada, ilk aşamada ürettiğimiz /app/main dosyasını çekip aldık. Geri kalan tüm Go SDK ve intermediate katmanlar çöpe gitti.

Layer Caching: Docker’ı Akıllıca Kandırmak

Multi-stage build kullanmak harika, ancak CI/CD pipeline’larımızın saniyeler içinde tamamlanmasını istiyorsak layer caching mekanizmasını doğru kullanmalıyız. Docker, Dockerfile’daki her bir satırı (instruction) yukarıdan aşağıya doğru sırayla çalıştırır ve her satır için bir cache katmanı oluşturur.

Eğer bir satırda değişiklik tespit edilirse (örneğin kopyalanan kodlar değiştiyse), Docker o satırdan sonraki tüm adımların cache’ini geçersiz kılar (cache invalidation).

Sık yapılan bir hata şudur:

COPY . .
RUN go mod download

Bu senaryoda, projedeki herhangi bir .go dosyasında tek bir satır değiştirdiğinizde, Docker tüm bağımlılıkları (go dependencies) internetten tekrar indirmeye başlar. Çünkü COPY . . satırı cache’i bozmuştur. Bunu önlemek için bağımlılık tanımlarını (go.mod, package.json vb.) kaynak koddan önce kopyalamalıyız:

COPY go.mod go.sum ./
RUN go mod download  # Bu satır, bağımlılıklar değişmediği sürece cache'den gelir!
COPY . .

Bu basit yer değişimi, CI/CD pipeline’larınızdaki build sürelerini dakikalardan saniyelere indirecektir. “Neden her seferinde npm install bekliyoruz?” sorusunun yanıtı tam olarak buradadır.

Distroless Base Image: İçeride Kimse Var mı?

İmaj boyutunu Alpine kullanarak 20 MB’a düşürdük, ancak daha da ileriye gidebiliriz. Alpine imajları her ne kadar küçük olsa da içinde hala bir shell (sh, ash) ve bir paket yöneticisi (apk) barındırır. Eğer bir saldırgan container içerisine sızmayı başarırsa, bu araçları kullanarak container içinde exploit çalıştırabilir veya internal network’ünüzü tarayabilir.

Google tarafından geliştirilen distroless imajlar, sadece uygulamanızın çalışması için gereken minimum çalışma zamanı (runtime) bağımlılıklarını içerir. İçinde shell yoktur, paket yöneticisi yoktur, temel linux komutları (ls, cd, ping) bile yoktur.

Şimdi runtime stage kısmını distroless static imajı ile güncelleyelim:

# 2. Aşama: Distroless Runtime
FROM gcr.io/distroless/static-debian12:latest

WORKDIR /

# Binary'yi kopyala
COPY --from=builder /app/main /main

# Distroless imajlarında varsayılan olarak nonroot kullanıcısı hazırdır
USER nonroot:nonroot

EXPOSE 8080

ENTRYPOINT ["/main"]

Bu değişiklikten sonra imaj boyutumuz yaklaşık 10-12 MB bandına çekilir. En önemlisi, container’ın saldırı yüzeyini (attack surface) neredeyse sıfıra indirmiş olduk. Container içinde /bin/sh olmadığı için uzaktan kod çalıştırma (RCE) açıklarının sömürülmesi imkansıza yakın hale gelir.

Güvenlik Tarama (Security Scanning) Testi

Yaptığımız bu optimizasyonların güvenlik tarafındaki yansımasını görmek için popüler açık kaynaklı tarama aracı Trivy kullanabiliriz. İlk yazdığımız 850 MB’lık imajı tarattığımızda muhtemelen düzinelerce “High” ve “Critical” seviyeli işletim sistemi açığı ile karşılaşacaktık.

Distroless imajımızı taratmak için terminalde şu komutu çalıştırabiliriz:

trivy image my-distroless-app:latest

Çıktıda göreceğiniz üzere, işletim sistemi seviyesindeki açık sayısı (Vulnerabilities) sıfıra yakın çıkacaktır. Bu, DevSecOps süreçlerinizi otomatize ederken security gate’lerden (güvenlik bariyerleri) takılmadan geçmenizi sağlar.

Özet ve Best Practice’ler

Özetlemek gerekirse, production seviyesinde container imajları hazırlarken şu altın kuralları asla unutmamalıyız:

  • Her zaman multi-stage build kullanın. Derleme ortamı ile çalışma ortamını birbirinden kesin çizgilerle ayırın.
  • Değişme sıklığı en az olan adımları (bağımlılık yükleme) Dockerfile’ın en üstüne, sık değişenleri (application code) en altına yazarak layer caching avantajını maksimize edin.
  • Çalışma ortamında Alpine veya daha iyisi distroless imajları tercih edin.
  • Container’ları asla root kullanıcısı ile çalıştırmayın; Dockerfile içinde mutlaka yetkisiz bir kullanıcı (non-root) tanımlayın.

Bu pratikleri uygulayarak hem bulut maliyetlerinizi (network egress ve storage) düşürebilir hem de çok daha güvenli ve hızlı deployment süreçlerine sahip olabilirsiniz. Unutmayın, en iyi imaj, içinde ihtiyacınız olmayan hiçbir şey barındırmayan imajdır!

Category: Genel | LEAVE A COMMENT
Ekim 11 2024

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!

Category: Genel | LEAVE A COMMENT