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/maindosyası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
rootkullanı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!