Build süreleri, özellikle kurumsal yazılım projelerinde, çoğu zaman göz ardı edilen ama ciddi verimsizliklere yol açan bir darboğazdır. Bir üretim ERP’sinde çalışırken, yeni bir feature branch’in birleştirilmesi sonrası deployment’ın saatler sürebilmesi, developer’ların sabrını zorlayan ve teslimat hızımızı düşüren bir sorun haline gelmişti. Her küçük değişiklikte sıfırdan build almak, hem kaynak israfıydı hem de operasyonel olarak büyük bir yüktü.
Bu sorunu çözmek için çeşitli build cache stratejilerini devreye aldım. Amacım sadece build’leri hızlandırmak değil, aynı zamanda bu hızın getirdiği operasyonel karmaşıklığı da yönetilebilir kılmaktı. Çünkü hız çoğu zaman beraberinde yeni sorunlar getirir; cache’in doğru çalışması, yönetilmesi ve güvenliği gibi konular sürekli dikkat gerektirir.
Build Süreleri Neden Problemdi? Semptomlar ve İlk Gözlemler
Bir müşteri projesinde, özellikle mikroservis mimarisine geçiş yaptığımızda, build süreleri katlanarak artmaya başladı. Her servisin kendi bağımlılıkları, kendi derleme adımları vardı ve bunlar her CI/CD pipeline çalıştığında sıfırdan inşa ediliyordu. Ortalama bir git push sonrası deployment süresi, küçük bir değişiklik için bile uzun dakikalar buluyordu. Hatta bazen, büyük bir bağımlılık güncellemesi olduğunda bu süre belirgin şekilde uzayabiliyordu.
Bu durum, developer’ların geri bildirim döngüsünü uzatıyor, sürekli beklemelerine neden oluyordu. Sabah ilk iş olarak bir değişiklik gönderen developer, test sonuçlarını öğleden sonra ancak görebiliyordu. Bu, özellikle hızlı iterasyon beklediğimiz bir dönemde büyük bir engeldi. Gözlemlediğim ana semptomlar şunlardı:
- Yüksek CPU ve Disk Kullanımı: Build sunucuları sürekli çok yüksek CPU ve IOPS değerlerinde çalışıyordu. Diskler hızla doluyor, geçici build dosyaları yüzünden depolama maliyetleri artıyordu.
- Düşük Developer Verimliliği: Developer’lar build’in bitmesini beklerken başka işlere odaklanmakta zorlanıyor, bağlam değiştirmek zorunda kalıyorlardı.
- CI/CD Kuyrukları: Birden fazla developer aynı anda değişiklik gönderdiğinde, CI/CD pipeline’larında uzun kuyruklar oluşuyordu. Bu da herkesin bekleme süresini daha da uzatıyordu.
Bu sorunların temelinde, her build adımının bağımsız ve izole bir şekilde çalışması yatıyordu. Örneğin, Docker imajları oluştururken, her RUN komutu yeni bir layer oluşturur ve önceki layer’lar değişmediği sürece cache’ten faydalanır. Ancak, bağımlılık dosyaları (package.json, requirements.txt gibi) sık sık değiştiğinde veya build adımları iyi optimize edilmediğinde, bu cache mekanizması yeterince etkili olmuyordu. İlk hedefim, bu temel Docker layer caching prensibini daha verimli kullanmanın yollarını bulmaktı.
Farklı Build Cache Mekanizmaları ve Benim Tercihlerim
Build sürelerini optimize etmek için farklı cache mekanizmalarını araştırdım ve kendi projelerimde uyguladım. Her birinin kendine göre avantajları ve operasyonel yükleri var.
1. In-Container (Layer) Caching
Docker’ın yerleşik layer caching mekanizması, en temel ve çoğu zaman en etkili yöntemdir. Dockerfile’daki her komut bir layer oluşturur. Eğer bir önceki layer ve komut değişmediyse, Docker bu layer’ı yeniden inşa etmek yerine cache’ten çeker.
Dockerfile’ları optimize ederek bu cache’i daha iyi kullanmayı öğrendim. Örneğin, bağımlılıkları yükleme adımını (ki bu sık değişmez) uygulama kodundan (ki bu sık değişir) ayırmak, build süresini önemli ölçüde kısaltır:
# Önce bağımlılık dosyalarını kopyala
COPY package.json package-lock.json ./
# Bağımlılıkları yükle (eğer package.json değişmediyse bu layer cache'ten gelir)
RUN npm ci
# Şimdi uygulama kodunu kopyala
COPY . .
# Uygulamayı derle/build et
RUN npm run build
Bu basit değişiklik, package.json değişmediği sürece npm ci adımının her seferinde atlanmasını sağladı. Bu küçük optimizasyon, bağımlılık yükleme adımının dakikalar mertebesinden saniyeler mertebesine düşmesini sağladı. Ancak, tek başına bu yöntem, farklı makinelerde veya CI/CD runner’larında her seferinde sıfırdan cache oluşturulduğu için yeterli olmuyordu. Farklı runner’lar veya yeni makineler, aynı layer’ı daha önce inşa etseler bile birbirlerinin cache’inden faydalanamıyordu.
2. External (Shared) Build Cache
Birden fazla CI/CD runner’ının veya developer’ın aynı cache’i kullanabilmesi için external cache mekanizmalarına yöneldim. Bu noktada Docker Buildx’in remote cache özelliği benim için oyun değiştirici oldu. Buildx, build sonuçlarını (layer’ları) bir Docker registry’sine veya harici bir depolama birimine (örneğin S3 uyumlu bir depolama) kaydedebilir ve oradan çekebilir.
Bir müşteri projesinde, birden fazla CI/CD runner’ının olduğu bir ortamda Buildx’i S3 uyumlu bir depolama ile kurdum. Her build, tamamlandığında cache’ini bu depolama alanına gönderiyor, yeni bir build başladığında ise önce oradan cache’i çekiyordu.
docker buildx create --name mybuilder --driver docker-container --use
docker buildx build --builder mybuilder \
--platform linux/amd64 \
--cache-from type=registry,ref=myregistry.com/my-app:buildcache \
--cache-to type=registry,ref=myregistry.com/my-app:buildcache,mode=max \
-t myregistry.com/my-app:latest . --push
Bu kurulum sayesinde, farklı runner’lar aynı build’i başlattığında, daha önce tamamlanmış bir build’in cache’inden anında faydalanabiliyordu. Benim gözlemime göre, bu yöntemle ortalama build süreleri kayda değer ölçüde azaldı. Özellikle node_modules veya maven bağımlılıklarının tekrar tekrar indirilmesi engellenmiş oldu.
Kendi yan ürünüm için CI/CD pipeline’ında, sccache gibi araçları da denedim. Bu araçlar, derleyici çıktılarının bir cache sunucusunda saklanmasını sağlayarak, C/C++ gibi dillerde build sürelerini ciddi şekilde hızlandırabilir. Bu, özellikle büyük monorepo’larda veya çok sayıda bağımlılığı olan projelerde faydalı olabilir.
Operasyonel Yük: Cache Yönetimi ve Bakım
Build cache stratejilerini devreye almak, sadece build’leri hızlandırmakla kalmıyor, aynı zamanda yeni bir operasyonel yük getiriyor. Bu yükü doğru yönetemezsek, hızdan kazandığımızı karmaşıklığa kaybedebiliriz.
Cache Invalidasyon Stratejileri
Cache’in en büyük düşmanı “stale” (eskimiş) veridir. Yanlış invalidasyon, eski kodun dağıtılmasına veya hatalı davranışlara yol açabilir. Bir projede, cache’te kalan hatalı bir bağımlılık yüzünden uzun süre üretim ortamında garip bug’larla uğraştığımız oldu. Root cause, bir build cache’inin yanlış bir versiyonu tutmasıydı.
Bu tür durumları önlemek için şu stratejileri uyguladım:
- Bağımlılık Değişikliklerine Duyarlı Cache:
package.jsonveyago.modgibi bağımlılık tanımlayıcı dosyaların hash’leri değiştiğinde cache’i tamamen invalid etmek. - Manuel Cache Temizliği: Bazen, özellikle büyük versiyon güncellemelerinde veya kritik güvenlik yamalarında, tüm cache’i manuel olarak temizlemek gerekir. CI/CD pipeline’ına bu işlemi tetikleyecek bir adım ekledim.
- Time-Based Eviction: Belirli bir süredir kullanılmayan cache’leri otomatik olarak silmek.
Disk Alanı Yönetimi ve Maliyet
Build cache’leri, özellikle büyük projelerde, diskte ciddi yer kaplayabilir. Remote cache kullanırken, bu depolama maliyeti olarak karşımıza çıkar. Bir projede, Buildx cache’i S3 üzerinde gözle görülür bir depolama alanına ulaşmıştı.
Bu maliyeti ve disk kullanımını optimize etmek için:
- Yaşam Döngüsü Kuralları (Lifecycle Rules): S3 gibi depolama servislerinde, belirli bir süreden eski objeleri otomatik olarak silmek veya daha ucuz depolama katmanlarına taşımak için yaşam döngüsü kuralları tanımladım. Örneğin, 30 günden eski cache objelerini silmek.
- Periyodik Temizlik Scriptleri: Self-hosted build sunucularında, kullanılmayan Docker imajlarını, build cache’lerini ve geçici dosyaları temizleyen
cronişleri oluşturdum.
#!/bin/bash
# 7 günden eski Docker build cache'lerini temizle
docker builder prune --filter "until=7d" --force
# Kullanılmayan Docker imajlarını temizle
docker image prune --all --force
# Kullanılmayan Docker volume'lerini temizle
docker volume prune --force
Bu script’ler, disk doluluk oranının kritik seviyelere ulaşmasını engelledi ve depolama maliyetlerini kontrol altında tutmamı sağladı.
Güvenlik Riskleri
Build cache, potansiyel güvenlik riskleri de barındırır. Eğer bir build cache’i hassas veriler (API anahtarları, şifreler vb.) içeriyorsa ve bu cache doğru şekilde korunmuyorsa, ciddi bir güvenlik açığı oluşturabilir.
Bir üretim ortamında, build argümanları arasına yanlışlıkla bir API anahtarı eklenmişti. Bu anahtar, build cache’ine sızma riski taşıyordu. Bu tür durumları önlemek için:
- Hassas Verileri Cache’e Almaktan Kaçınma:
Dockerfile’dasecretmount’ları kullanmak veya build esnasında hassas verileri environment variable olarak geçip cache’e alınmamasını sağlamak. - Erişim Kontrolü: Remote cache depolama alanlarına (örn. S3 bucket) sıkı erişim kontrolü uygulamak. Sadece CI/CD runner’larının ve yetkili kişilerin erişebileceğinden emin olmak.
Performans Metrikleri ve Gözlem
Bir sistemin performansını optimize ederken, gözlem (observability) olmazsa olmazdır. Build cache stratejilerinin etkinliğini anlamak ve operasyonel sorunları erkenden tespit etmek için metrikleri yakından takip ettim.
Build Süresi Takibi
CI/CD sistemlerinde her build’in başlangıç ve bitiş zamanını kaydederek, toplam build süresini metrik olarak topladım. Prometheus ve Grafana kullanarak bu verileri görselleştirdim.
# Örnek bir Prometheus kuralı (CI/CD sisteminden gelen metrikleri topladığımızı varsayalım)
- job_name: 'ci_cd_builds'
metrics_path: /metrics
static_configs:
- targets: ['ci-cd-runner-1:9100', 'ci-cd-runner-2:9100']
relabel_configs:
- source_labels: [__address__]
regex: '([^:]+):.*'
target_label: instance
replacement: '$1'
Grafana’da, build sürelerinin zaman içindeki değişimini gösteren bir grafik oluşturdum. Bu grafik, bir gecede build sürelerinde yaşanan beklenmedik artışları veya bir optimizasyonun etkisini net bir şekilde görmemi sağladı. Örneğin, bir bağımlılık güncellemesi sonrası build sürelerinin belirgin şekilde fırladığını bu grafik sayesinde hemen fark ettim.
Cache Hit/Miss Oranları
Build cache’inin ne kadar etkili çalıştığını gösteren en kritik metrik, cache hit/miss oranıdır. Buildx gibi araçlar, --progress=plain bayrağı ile bu bilgileri çıktısında verebilir.
# Örnek docker build çıktısı
...
#10 [build 5/5] RUN npm run build
#10 CACHED
#10 DONE 0.0s
...
Bu çıktıları parse ederek veya Buildx’in build raporlarından bu oranları toplayarak, cache’in ne sıklıkta kullanıldığını gözlemledim. Eğer cache hit oranı belirgin şekilde düşüyorsa, bu genellikle bir cache invalidasyon stratejisi sorununa veya Buildx ayarlarında bir eksikliğe işaret eder. Bir projede cache hit oranı beklenmedik şekilde düştüğünde, bunun sebebinin Buildx’in remote cache’e yazma izni olmaması olduğunu tespit ettim. İzinleri düzelttiğimde oran hızla normale döndü.
Anomali Tespiti ve Uyarılar
Build süresi ve cache hit/miss oranlarındaki anormal değişimleri otomatik olarak tespit etmek için uyarı sistemleri kurdum. Prometheus Alertmanager kullanarak, belirli eşiklerin (örneğin, “build süresi son 1 saat içinde %50’den fazla arttı” veya “cache hit oranı son 4 saat içinde %70’in altına düştü”) aşılması durumunda Slack kanalına veya e-posta ile bildirim gönderilmesini sağladım.
Bu proaktif izleme, sorunları developer’lar fark etmeden veya operasyonel yük ciddi boyutlara ulaşmadan önce ele almamızı sağladı. Bir build süresi ya da disk alanı alarmı erkenden düştüğünde, mesai başlamadan müdahale edip olası bir kesintinin önüne geçmek mümkün oluyor.
Trade-off’lar ve Doğru Stratejiyi Seçmek
Build cache stratejileri seçerken, her zaman bir trade-off ile karşılaşıyoruz. “Her derde deva” tek bir çözüm yok. Benim deneyimlerimde, bu kararlar genellikle hız, karmaşıklık, maliyet ve güvenliğin bir dengesi üzerine kuruluydu.
Hız vs. Karmaşıklık
En basit Docker layer caching yöntemi hızlıdır, kurulumu kolaydır ama farklı CI/CD runner’ları arasında cache paylaşımına izin vermez. Bu, küçük projeler veya tek runner’lı ortamlar için yeterli olabilir. Ancak, daha büyük ve dağıtık CI/CD altyapılarında yetersiz kalır.
Remote cache (Buildx + registry/S3) çok daha yüksek hızlar sunar, çünkü cache’i paylaşılabilir hale getirir. Ancak, kurulumu ve yönetimi daha karmaşıktır. Bir registry veya S3 bucket’ı kurmak, erişim izinlerini yönetmek, yaşam döngüsü kurallarını ayarlamak ek operasyonel yük getirir. Bir üretim firmasının ERP’sini geliştirirken, başlangıçta basit layer caching ile idare ettik. Ancak proje büyüdükçe ve developer sayısı arttıkça, Buildx’e geçiş yapmak zorunda kaldık. Bu geçiş, ilk başta birkaç gün süren bir kurulum ve yapılandırma gerektirdi.
Maliyet vs. Verimlilik
Daha hızlı build’ler genellikle daha fazla kaynak gerektirir. Remote cache için S3 gibi bulut depolama servisleri kullanıyorsanız, depolama ve veri transferi maliyetleri artar. Ayrıca, build sunucularının daha fazla CPU ve RAM’e sahip olması gerekebilir.
Kendi yan ürünümün backend’inde, başlangıçta her şeyi kendi VPS’imde self-host ediyordum. Build cache için harici bir S3 depolama kullanmak yerine, lokal diskimde cache tutmayı tercih ettim. Bu, depolama maliyetinden tasarruf etmemi sağladı ama build süreleri daha uzundu ve eğer VPS’i yeniden kurmam gerekirse cache tamamen kayboluyordu. Bir noktada, build süresinin getirdiği verimsizlik, S3 depolama maliyetinden daha ağır basmaya başladı ve remote cache’e geçiş yaptım.
Bir projede yaptığım maliyet analizinin iskeleti şuydu:
- Seçenek A (Sıfır Cache): Her build uzun dakikalar sürüyor. Günlük build sayısıyla çarpıldığında ciddi bir toplam build süresi çıkıyor. Buna developer’ın bekleme maliyeti (ortalama saatlik ücret x bekleme süresi) + daha fazla CI/CD runner ihtiyacı ekleniyor.
- Seçenek B (Remote Cache): Build süresi belirgin şekilde kısalıyor. Karşılığında S3 depolama maliyeti + Buildx kurulum/yönetim süresi gibi kalemler ortaya çıkıyor.
Hesaplamalarım, Seçenek B’nin toplam maliyetinin, özellikle developer verimliliği açısından, çok daha düşük olduğunu gösteriyordu. Birden fazla developer’ın günde defalarca build aldığı bir ekipte, build başına kazanılan dakikalar toplamda kayda değer bir verimlilik artışı anlamına geliyordu.
Sonuç
Build cache stratejileri, modern yazılım geliştirme süreçlerinde performansı artırmanın ve developer verimliliğini sağlamanın kritik bir parçası. Ancak, bu sadece “cache’i aç” demekle bitmiyor. Doğru cache mekanizmasını seçmek, invalidasyon stratejilerini belirlemek, disk alanı ve güvenlik risklerini yönetmek ciddi bir operasyonel yük ve dikkat gerektiriyor.
Benim deneyimimde, bu süreçte önemli olan, her zaman trade-off’ları göz önünde bulundurmak ve projenin ihtiyaçlarına en uygun çözümü bulmaktır. Bir yan ürünümdeki basit bir kurulumdan, büyük bir kurumsal ERP’deki karmaşık Buildx entegrasyonuna kadar, her projede farklı denge noktaları buldum. Her zaman “hızın bir maliyeti var” ilkesiyle hareket ettim ve bu maliyeti yönetilebilir kılmak için sürekli yeni yollar aradım. Bir sonraki adım, build cache’i daha akıllı hale getirmek için AI destekli cache invalidasyon mekanizmaları üzerine çalışmak.