Docker imaj build'ini hızlandırmak: katman önbelleği ve çok aşamalı Dockerfile
Bir ekipte CI hattının en yavaş adımı çoğu zaman testler değil, Docker imajının build edilmesidir. Her commit'te bağımlılıklar baştan indirilir, derleme baştan yapılır ve on dakika sonra aynı imaj yeniden üretilir. Oysa Docker'ın katman önbelleği doğru kullanıldığında bu sürenin büyük kısmı atlanabilir.
Bu yazıda önbelleğin nasıl çalıştığını, Dockerfile'ı ona göre nasıl düzenleyeceğinizi ve CI ortamında önbelleği nasıl koruyacağınızı anlatıyorum. Örnekler .NET ve Node.js üzerinden, ama mantık her yığında aynı.
Katman önbelleği nasıl çalışır
Dockerfile'daki her talimat bir katman üretir. Docker bir talimatı çalıştırmadan önce, aynı talimatın aynı girdilerle daha önce çalışıp çalışmadığına bakar. Çalışmışsa sonucu önbellekten alır.
Kritik kural şudur: bir katman geçersiz olursa, ondan sonraki tüm katmanlar da yeniden üretilir. Yani sık değişen şeyleri dosyanın sonuna, nadir değişenleri başına koymalısınız. COPY talimatında girdi, kopyalanan dosyaların içeriğidir. Tek bir kaynak dosya değişse bile COPY . . katmanı geçersiz olur.
Bağımlılıkları kaynak koddan ayırın
En sık görülen hata, tüm projeyi kopyalayıp sonra bağımlılıkları kurmaktır:
COPY . .
RUN npm ci
Bu durumda her kod değişikliği npm ci adımını da yeniden çalıştırır. Doğrusu, önce yalnızca bağımlılık tanım dosyalarını kopyalamaktır:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Artık package-lock.json değişmediği sürece npm ci önbellekten gelir. Kod değişince yalnızca son iki adım çalışır. .NET'te aynı desen proje dosyalarıyla uygulanır:
COPY *.sln ./
COPY src/Api/Api.csproj src/Api/
COPY src/Domain/Domain.csproj src/Domain/
RUN dotnet restore
COPY . .
RUN dotnet publish src/Api -c Release -o /out --no-restore
Birden çok proje varsa csproj dosyalarını tek tek kopyalamak sıkıcıdır ama restore adımının önbelleğe girmesi için gereklidir.
.dockerignore ile gereksiz girdiyi kesin
COPY . . build bağlamındaki her şeyi görür. .git, node_modules, bin, obj ve yerel log dosyaları bağlama dahil olursa hem bağlamın gönderilmesi yavaşlar hem de alakasız bir dosya değişikliği önbelleği bozar. Bir .dockerignore dosyası bunu çözer:
.git
**/node_modules
**/bin
**/obj
*.log
.env*
.env* satırı güvenlik açısından da önemlidir: yerel sırlar yanlışlıkla imaja girmez.
Çok aşamalı build ile imajı küçültün
Derleme araçları çalışma zamanında gerekmez. Çok aşamalı Dockerfile, derlemeyi ağır bir imajda yapıp yalnızca çıktıyı hafif bir imaja taşır:
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.sln ./
COPY src/Api/Api.csproj src/Api/
RUN dotnet restore
COPY . .
RUN dotnet publish src/Api -c Release -o /out --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /out .
ENTRYPOINT ["dotnet", "Api.dll"]
Bunun iki faydası var. Son imaj küçük olur, yani çekmesi ve dağıtması hızlanır; saldırı yüzeyi de daralır. Ayrıca build aşamaları bağımsız önbelleğe alınır. Etiket sürümlerini projenizin kullandığı sürüme göre değiştirin; önemli olan desen.
BuildKit cache mount
Katman önbelleği, bağımlılık dosyası değiştiğinde tüm paketleri baştan indirir. BuildKit'in cache mount özelliği, paket yöneticisinin önbellek dizinini build'ler arasında korur:
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm \
npm ci
Bir paket eklediğinizde yalnızca yeni paket indirilir, diğerleri önbellek dizininden gelir. Cache mount içeriği imaja yazılmaz, bu da imajı şişirmez. Aynı yaklaşım NuGet için /root/.nuget/packages, pip için /root/.cache/pip dizinlerine uygulanabilir.
CI'da önbelleği koruyun
Yerelde işe yarayan her şey, CI'da temiz bir makinede çalışıyorsanız boşa gider. Çünkü her çalıştırmada önbellek sıfırdır. İki yaygın çözüm var:
- Kayıt defteri önbelleği: Build sonunda önbellek katmanlarını bir registry'ye gönderir, sonraki build'de oradan alırsınız.
docker buildx build --cache-from type=registry,ref=... --cache-to type=registry,ref=...,mode=maxbiçiminde kullanılır. - CI sağlayıcısının önbellek deposu: Kullandığınız CI aracı yerel dizin ya da kendi önbellek türünü destekliyorsa buildx'in ilgili
--cache-to/--cache-fromtürünü kullanın.
mode=max ara katmanları da dışa aktarır. Çok aşamalı build'lerde bu önemlidir, çünkü varsayılan mod yalnızca son imajın katmanlarını saklar ve derleme aşamasındaki önbellek kaybolur. Önbellek depolama alanı kullanır; eski girdileri temizleyen bir saklama politikası belirleyin.
Önbelleğin çalıştığını ölçün
Tahminle optimizasyon yapmayın. Build çıktısında önbellekten gelen adımlar CACHED olarak işaretlenir. Kod değiştirip yeniden build ettiğinizde bağımlılık adımının CACHED göründüğünü doğrulayın. CI'da toplam build süresini bir panoda izleyin; değişiklikten önce ve sonra karşılaştırın.
Önbelleğin beklenmedik biçimde bozulduğu durumlarda şunlara bakın: COPY ile giren ve her build'de değişen dosyalar (örneğin build zamanı yazan bir dosya), ARG ile verilen ve her seferinde farklı olan değerler (commit SHA'sı gibi) ve RUN apt-get update gibi zamana bağlı komutlar. Commit SHA'sı gibi değişken değerleri, mümkünse dosyanın en sonunda kullanın.
Kapanış
Hızlı Docker build'i için sihirli bir ayar yok; birkaç basit alışkanlık var. Bağımlılık dosyalarını önce kopyalayın, .dockerignore kullanın, çok aşamalı build yapın, paket önbelleği için cache mount ekleyin ve CI'da önbelleği saklayın. Her adımın etkisini CACHED satırlarından ve build süresinden ölçün. Bu değişiklikler bir öğleden sonrada yapılabilir ve her commit'te geri döner.