Docker Multi-Stage Build ile İmaj Boyutunu %80 Küçültme
CI/CD pipeline’larının hızını doğrudan etkileyen unsurlardan biri, optimize edilmiş container imajlarıdır. Production’a her deployment çıktığımızda şişkin bir Docker imajının registry’ye push edilmesi, ardından Kubernetes node’ları tarafından pull edilmesi ciddi bir zaman ve network maliyeti yaratır.
Multi-stage build yaklaşımı imaj boyutlarını radikal biçimde düşürür. Deployment’lar hızlanır, cold-start süreleri kısalır, güvenlik açıkları (vulnerabilities) büyük oranda azalır. Bu yazıda teoriyle yetinmeyip hantal bir imajı alıp adım adım ameliyat masasına yatıracağız.
Acı gerçek: 800 MB’lık Hello World imajı 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 derlenen dillerde ya da Node.js gibi node_modules klasörü çok şişen ortamlarda bu tam bir faciaya dönüşebilir. Basit bir Go uygulaması üzerinden durumu simüle edelim.
Aşağıda yalnızca HTTP 200 dönen basit bir Go web sunucusunun Dockerfile’ı var:
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o main .
EXPOSE 8080
CMD ["./main"]
Bu Dockerfile ile build aldığınızda imaj boyutu yaklaşık 850 MB civarında olur. Nedeni şu: Go derleyicisine, paket yöneticisine, Debian tabanlı işletim sisteminin tüm araçlarına (curl, git, apt vb.) uygulamayı yalnızca çalıştırmak için ihtiyacımız yok. Bunlar sadece derleme (build-time) aşamasında lazım. 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?
Multi-stage build, tek bir Dockerfile içinde 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, yalnızca ortaya çıkan nihai çıktıyı (artifact) bir sonraki temiz ve hafif aşamaya kopyalarız.
İlk aşamada (builder) tüm ağır SDK’leri kullanabilir, iş bitince yalnızca binary dosyasını alıp minimalist bir base image’a 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 ile optimize edelim. Dockerfile’ı 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"]
İmaj boyutu 850 MB’tan 20 MB seviyelerine düştü. Yaklaşık %97 oranında bir küçülme elde ettik. Bu kadar büyük farkın nedenleri şunlar:
- 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 bağımlılığı kapattık. Böylece binary dosyamız tamamen taşınabilir oldu.
- COPY --from=builder: İ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 tek başına yetmez. 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 satırı (instruction) yukarıdan aşağıya sırayla çalıştırır ve her satır için bir cache katmanı oluşturur.
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 satır değiştirdiğinizde Docker tüm bağımlılıkları (go dependencies) internetten tekrar indirir, çü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 budur.
Distroless base image: İçeride kimse var mı?
İmaj boyutunu Alpine ile 20 MB’a düşürdük ama daha da ileri gidebiliriz. Alpine imajları küçük olsa da içinde hâlâ bir shell (sh, ash) ve bir paket yöneticisi (apk) vardır. Bir saldırgan container’a sızmayı başarırsa bu araçlarla container içinde exploit çalıştırabilir ya da internal network’ünüzü tarayabilir.
Google’ın geliştirdiği distroless imajlar yalnızca uygulamanızın çalışması için gereken minimum çalışma zamanı (runtime) bağımlılıklarını içerir. İçinde shell de paket yöneticisi de yoktur, temel linux komutları (ls, cd, ping) bile bulunmaz.
Ş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 boyutu yaklaşık 10-12 MB bandına iner. Daha ö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 taraması (security scanning)
Bu optimizasyonların güvenliğe yansımasını görmek için açık kaynaklı tarama aracı Trivy’yi kullanabiliriz. İlk yazdığımız 850 MB’lık imajı tarattığımızda muhtemelen düzinelerce “High” ve “Critical” seviyeli işletim sistemi açığıyla 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 işletim sistemi seviyesindeki açık sayısının (Vulnerabilities) sıfıra yakın çıktığını göreceksiniz. Bu, DevSecOps süreçlerinizi otomatize ederken security gate’lerden (güvenlik bariyerleri) takılmadan geçmenizi sağlar.
Özet ve best practice’ler
Production seviyesinde container imajları hazırlarken şu kuralları aklımızdan çıkarmamalı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 pratiklerle hem bulut maliyetlerinizi (network egress ve storage) düşürebilir hem de daha güvenli ve hızlı deployment’lar yapabilirsiniz. En iyi imaj, içinde ihtiyacınız olmayan hiçbir şey barındırmayan imajdır.