Mert Özen Her Satırda Daha İleri
DevOps Docker — #07

Multi-stage Build Derinlemesine: Target'lar, Test Stage'i, Paralel Stage'ler ve Image Boyutu

Mert Özen 14 Kas 2026 16 dk 10 görüntülenme
Multi-stage Build Derinlemesine: Target'lar, Test Stage'i, Paralel Stage'ler ve Image Boyutu

Multi-stage build'de son image yalnız son stage'in içeriğinden oluşur ve BuildKit yalnız hedefe giden stage'leri, birbirine bağlı olmayanları paralel build eder. Bookstore.Tools'u restore, build, test, export, debug ve final stage'lerine bölüp image'ı 1,34 GB'tan 143 MB'a indiriyor, test stage'inin ve chiseled image'ın sınırlarını görüyor, --platform=$BUILDPLATFORM ile QEMU'suz iki mimarili image üretiyoruz.

Sürüm notu: Bu yazıdaki örnekler Docker Engine 29.9.3 (BuildKit 0.34.2), Buildx 0.39.0, .NET SDK 10.0.404 ve .NET Runtime 10.0.15 image'ları, xunit.v3 4.0.3 (Microsoft.Testing.Platform) ve registry:3 ile denenmiştir. Boyutlar linux/amd64 image'larından alınmıştır.

Önce Tahmin Et

Bir Dockerfile'da beş stage var: restore, build, test, runtime ve final. test stage'i dotnet test çalıştırıyor, final ise runtime'dan türüyor ve ondan hiçbir dosya almıyor. docker build . dediğinde testler çalışır mı? İkinci soru: 6. yazının sonunda 1,34 GB olan Bookstore.Tools image'ı, SDK'yı final image'dan çıkarınca kaç MB'a iner: 600, 300 yoksa 150?

Neden Stage'lere Bölüyoruz?

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) import aracı Bookstore.Tools'un image'ı şu an her şeyi taşıyor: .NET SDK'sı, NuGet ile ilgili ara dosyalar, kaynak kod, obj/ ve bin/ klasörleri. Container'da çalışan tek şey dotnet Bookstore.Tools.dll ve bunun için runtime ile publish çıktısı yeterli. Fazlası yalnız boyut değil: image'ı çeken herkes kaynak kodu okuyabilir, SDK'daki her paketin CVE'si (Common Vulnerabilities and Exposures; açıklanmış güvenlik açıklarının kataloğu) tarama raporuna girer, saldırganın kullanabileceği araçlar (derleyici, shell, curl) hazır bekler.

Multi-stage build'in fikri basit: bir Dockerfile'da birden çok FROM yazarsın, her biri yeni bir stage başlatır ve son image yalnız son stage'in içeriğinden oluşur. Önceki stage'lerden dosya almanın tek yolu COPY --from=<stage>'dir. ASP.NET Core Web API serisinin "ASP.NET Core Web API'yi Docker'a Almak ve GitHub Actions ile CI/CD Kurmak" yazısında iki stage'li temel bir örnek görmüştük; burada aynı fikri Bookstore'da genişletiyoruz: ayrı bir test stage'i, hedef mimariye göre derleme, host'a çıktı veren bir stage ve debug için ayrı bir target.

Bookstore.Tools İçin Stage'ler

Önce testleri ekledik: tests/Bookstore.Tools.Tests, BookRow'un 13 haneli ISBN (International Standard Book Number; kitaplara verilen uluslararası numara) doğrulamasını ve 6. yazıda eklediğimiz Trim() düzeltmesini test ediyor. Dockerfile'ın tamamı:

# syntax=docker/dockerfile:1
ARG DOTNET_VERSION=10.0

# restore: SDK build makinesinin platformunda çalışır, asla QEMU altında değil
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS restore
WORKDIR /src
COPY Bookstore.slnx global.json ./
COPY src/Bookstore.Tools/Bookstore.Tools.csproj src/Bookstore.Tools/
COPY tests/Bookstore.Tools.Tests/Bookstore.Tools.Tests.csproj tests/Bookstore.Tools.Tests/
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet restore Bookstore.slnx
COPY src/ src/

# build: hedef mimari için publish (cross-compilation)
FROM restore AS build
ARG TARGETARCH
ARG BUILD_CONFIGURATION=Release
ARG APP_VERSION=1.0.0
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet publish src/Bookstore.Tools/Bookstore.Tools.csproj -c $BUILD_CONFIGURATION -a $TARGETARCH \
        -o /app -p:Version=$APP_VERSION

# test: yalnız istendiğinde build edilir (--target test ya da bake'teki "test" target'ı)
FROM restore AS test
COPY tests/ tests/
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet test --project tests/Bookstore.Tools.Tests/Bookstore.Tools.Tests.csproj --no-restore

# export: publish çıktısını host'a verir (docker build --target export --output out)
FROM scratch AS export
COPY --from=build /app /

# runtime: yalnız runtime; SDK yok, shell yok, root değil
FROM mcr.microsoft.com/dotnet/runtime:${DOTNET_VERSION}-noble-chiseled AS runtime
ARG APP_VERSION=1.0.0
WORKDIR /app
COPY --from=build /app ./
COPY --chown=app:app data/books.csv data/books.csv
LABEL org.opencontainers.image.title="Bookstore.Tools" \
      org.opencontainers.image.version=$APP_VERSION
ENV DOTNET_ENVIRONMENT=Production \
    Import__File=data/books.csv
USER app
ENTRYPOINT ["dotnet", "Bookstore.Tools.dll"]
CMD ["--Import:DelayMs=500"]

# debug: runtime + başka bir image'dan alınan statik busybox
FROM runtime AS debug
COPY --from=busybox:1.37-musl /bin/busybox /busybox/busybox

# final: son stage varsayılan target'tır, bu yüzden production image'ı olmalı
FROM runtime AS final

Birkaç ayrıntı:

  • ARG DOTNET_VERSION ilk FROM'dan önce tanımlı; böyle bir ARG yalnız FROM satırlarında kullanılabilir ve .NET sürümünü tek yerden değiştirmeyi sağlar. Stage'in içinde kullanmak istersen orada yeniden ARG yazman gerekir; APP_VERSION'ı iki stage'de ayrı ayrı tanımlamamızın nedeni bu.
  • AS restore, AS build gibi adlar stage'lere verilir ve FROM restore AS build bir stage'i diğerinin üzerine kurar. build ve test aynı restore stage'inden türüyor, yani restore bir kez yapılıyor.
  • global.json ilk COPY'de, çünkü dotnet test'in hangi test platformuyla çalışacağını o belirliyor (birazdan). 6. yazıdaki kural geçerli: restore'u ve SDK davranışını etkileyen dosyalar kaynak koddan önce gelir.
  • runtime image'ı Ubuntu 24.04 tabanlı chiseled varyant: yalnız .NET runtime'ının ihtiyaç duyduğu paketler var, shell ve paket yöneticisi yok, app kullanıcısı tanımlı. Image ailelerini 8. yazıda ayrıntılı karşılaştıracağız.

Hangi Stage'ler Çalışır? Target'lar ve Paralellik

BuildKit Dockerfile'ı yukarıdan aşağıya satır satır çalıştırmaz; hedef stage'den geriye doğru bir bağımlılık grafiği çıkarır ve yalnız o hedefe giden stage'leri build eder. Hedef verilmezse son stage hedeftir. Bu yüzden ilk sorunun cevabı hayır:

docker build --progress=plain -f src/Bookstore.Tools/Dockerfile -t dockerseries-bookstore-tools:1.0.0 .
#8 [restore 1/7] FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:fe0fd4e93bd2fbb47c883b7a6369e5c8d3db8bf01cdc5981f6ee35fbdf3ff4fb
#9 [runtime 1/4] FROM mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled@sha256:1b234cef150624fe8cc7d027e8d742c0f03a16aef2484dc469eb8c872167255e
#14 [restore 6/7] RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages     dotnet restore Bookstore.slnx
#14 CACHED
#15 [restore 7/7] COPY src/ src/
#16 [build 1/1] RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages     dotnet publish src/Bookstore.Tools/Bookstore.Tools.csproj -c Release -a amd64         -o /app -p:Version=1.0.0
#16 0.629   Restored /src/src/Bookstore.Tools/Bookstore.Tools.csproj (in 125 ms).
#16 DONE 1.3s
#17 [runtime 2/4] WORKDIR /app
#18 [runtime 3/4] COPY --from=build /app ./
#19 [runtime 4/4] COPY --chown=app:app data/books.csv data/books.csv
#20 naming to docker.io/library/dockerseries-bookstore-tools:1.0.0 done

Çıktıda test, export ve debug yok; BuildKit onları hiç çalıştırmadı. final stage'inin kendi adımı olmadığı için log'da adımlar runtime adıyla görünüyor. TARGETARCH hedef platformdan geldiği için publish -a amd64 ile çalıştı. Publish'in içindeki kısa restore (125 ms), -a verildiğinde SDK'nın hedef mimari için yaptığı ek restore; paketler cache mount'tan geldiği için hızlı. Bir de paralellik var: birbirine bağlı olmayan stage'ler aynı anda çalışır. Bu çıktıda restore cache'ten geldiği için görünmüyor; ilk build'de runtime image'ının indirilmesi restore'la aynı anda başlamış ve ondan önce bitmişti. runtime stage'i yalnız COPY --from=build satırında build'i bekler.

Testleri çalıştırmak için hedefi açıkça verirsin:

docker build --progress=plain --target test -f src/Bookstore.Tools/Dockerfile .
#14 [test 1/2] COPY tests/ tests/
#15 [test 2/2] RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages     dotnet test --project tests/Bookstore.Tools.Tests/Bookstore.Tools.Tests.csproj --no-restore
#15 2.478 Test run summary: Passed!
#15 2.478   total: 6
#15 2.478   failed: 0
#15 2.478   succeeded: 6
#15 2.478   skipped: 0
#15 2.478   duration: 646ms

Restore stage'i önceki build'den CACHED geldi; yalnız testler çalıştı. Testler kırılırsa dotnet test sıfırdan farklı kodla çıkar ve build durur. 6. yazıdaki Bake dosyasına bir test target'ı ekleyip ci grubuna koyduk:

target "test" {
  context    = "."
  dockerfile = "src/Bookstore.Tools/Dockerfile"
  target     = "test"
  output     = ["type=cacheonly"]
}

type=cacheonly sonucu bir image olarak kaydetmez, yalnız build cache'ine yazar; test stage'inden image istemiyoruz, yalnız başarılı olmasını istiyoruz. tools target'ına da target = "final" ekledik, böylece Dockerfile'a yeni stage eklense bile hangi image'ın üretileceği açık. Grubu çalıştırınca test ve build aynı oturumda yan yana ilerliyor:

docker buildx bake ci
#13 [test restore 6/7] RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages     dotnet restore Bookstore.slnx
#22 [test test 1/2] COPY tests/ tests/
#23 [tools runtime 1/4] FROM mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled@sha256:1b234cef150624fe8cc7d027e8d742c0f03a16aef2484dc469eb8c872167255e
#24 [tools build 1/1] RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages     dotnet publish ...
#25 [test test 2/2] RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages     dotnet test --project tests/Bookstore.Tools.Tests/Bookstore.Tools.Tests.csproj --no-restore
#25 2.336 Test run summary: Passed!
#28 [tools runtime 4/4] COPY --chown=app:app data/books.csv data/books.csv
#29 naming to docker.io/library/dockerseries-bookstore-tools:1.0.0 done
lint
Check complete, no warnings found.

#24 publish ve #25 test aynı anda başladı; ortak restore stage'i bir kez çalıştı. Dikkat: tools target'ı testin sonucunu beklemiyor. Bunu denemek için HasValidIsbn13'teki digit * 3'ü bilerek digit * 2 yaptık ve docker buildx bake ci'yi yeniden çalıştırdık:

#29 naming to docker.io/library/dockerseries-bookstore-tools:1.0.0 done
#24 2.125 Test run summary: Failed!
#24 2.125   failed: 2
ERROR: target test: failed to solve: process "/bin/sh -c dotnet test --project tests/Bookstore.Tools.Tests/Bookstore.Tools.Tests.csproj --no-restore" did not complete successfully: exit code: 2

Bake hata verdi, ama dockerseries-bookstore-tools:1.0.0 tag'i o arada hatalı kodla build edilen yeni image'a geçmişti (docker image ls'te image ID'si değişti). "Test geçmeden image yok" kuralı istiyorsan bunu pipeline'ın adım sırasıyla kurarsın: önce test target'ı, başarılıysa image. Final image'ı yapay olarak test stage'ine bağlamak (ör. test stage'inden boş bir dosya kopyalamak) da çalışır, ama paralelliği kaybettirir.

Test Stage'i ve Neden CI'da Ayrı Bir Job Hâlâ Gerekli

Test projesi xunit.v3 kullanıyor. xunit.v3'ün 4.x sürümü Microsoft.Testing.Platform (MTP; Microsoft'un VSTest'in yerine getirdiği yeni test çalıştırma altyapısı) üzerinde çalışıyor ve .NET 10 SDK'da dotnet test'in bu modda çalışması için repository köküne bir global.json gerekiyor. İlk denemede bunu unuttuk ve test stage'i şu hatayla durdu:

error : Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. If you use dotnet test, you should opt-in to the new dotnet test experience. For more information, see https://aka.ms/dotnet-test-mtp-error
{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

Bu modda dotnet test proje yolunu --project ile alıyor. Testlerin kendisi sıradan xUnit testleri:

using Bookstore.Tools;
using Xunit;

namespace Bookstore.Tools.Tests;

public sealed class BookRowTests
{
    [Theory]
    [InlineData("9780441172719", true)]
    [InlineData("9780141439587", true)]
    [InlineData("9780141439510", false)]
    [InlineData("978014143958", false)]
    [InlineData("97801414395AB", false)]
    public void HasValidIsbn13_checks_length_digits_and_check_digit(string isbn, bool expected)
    {
        BookRow row = new(isbn, "Any", "Any", 100m);

        Assert.Equal(expected, row.HasValidIsbn13());
    }

    [Fact]
    public void Parse_trims_fields_and_reads_invariant_price()
    {
        BookRow row = BookRow.Parse("9780441172719, Dune ,Frank Herbert,420.50");

        Assert.Equal("Dune", row.Title);
        Assert.Equal(420.50m, row.PriceTry);
    }
}

Test stage'inin iki faydası var: testler production image'ıyla aynı SDK sürümünde, aynı restore sonucuyla çalışır ve test bağımlılıkları (xunit, test projesi, bin/Debug klasörü) final image'a hiç girmez. Yine de testleri CI'da ayrı bir job'da (dotnet test doğrudan runner'da) koşmayı tercih ediyoruz. Nedenleri pratik: test sonuç dosyaları ve coverage raporu build container'ının içinde kalır ve dışarı almak için ek --output ayarı gerekir; başarısız bir testin çıktısı BuildKit log'unda kesilerek görünür; veritabanı isteyen integration testleri docker build içinde çalışamaz, çünkü build adımlarının container başlatma yetkisi yok (bunlar Integration ve E2E (uçtan uca) Test serisindeki Testcontainers konusu). Test stage'i en çok "Dockerfile'ın build ettiği kod gerçekten test edilen kod mu?" sorusunu kapatmak ve lokal geliştirmede tek komutla her şeyi koşmak için işe yarıyor.

COPY --from, FROM scratch ve Distroless Fikri

COPY --from yalnız stage adı almaz; bir image referansı da alabilir. debug stage'inde busybox:1.37-musl image'ından tek bir dosyayı, statik derlenmiş busybox binary'sini aldık. Neden gerektiğini chiseled image'da görüyoruz:

docker run --rm --entrypoint sh dockerseries-bookstore-tools:1.0.0
docker build --target debug -f src/Bookstore.Tools/Dockerfile -t dockerseries-bookstore-tools:1.0.0-debug .
docker run --rm --entrypoint /busybox/busybox dockerseries-bookstore-tools:1.0.0-debug ls -ln /app/data
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "sh": executable file not found in $PATH
total 4
-rw-r--r--    1 1654     1654           314 Nov 14 11:01 books.csv

Production image'ında shell yok; içine docker exec ile girip bakamazsın. Bu bilinçli: shell olmayan bir image'da saldırganın ele geçirdiği bir process'in kullanabileceği araç da yok. Bakmak gerektiğinde ayrı bir debug target'ı build edersin; production'a giden image değişmez. Shell'siz image'ı hiç yeniden build etmeden debug etmenin yollarını (docker debug, aynı namespace'lere bağlanan yardımcı container) 14. yazıda göreceğiz.

Aynı mantıkla araç taşımak yaygın bir kullanım: başka bir image'dan statik bir healthcheck binary'si, bir sertifika paketi ya da bir CLI aracı almak. Kural, kopyaladığın dosyanın hedef image'da çalışabilmesi: dinamik bağlı bir binary, hedefte olmayan bir libc'ye ihtiyaç duyarsa çalışmaz. busybox'ın musl varyantını bu yüzden seçtik.

FROM scratch boş bir image demek: içinde hiçbir dosya yok. export stage'i bunu bir build çıktısını host'a almak için kullanıyor. --output ile BuildKit, hedef stage'in dosya sistemini image yerine bir klasöre yazar:

docker build --target export --output type=local,dest=out -f src/Bookstore.Tools/Dockerfile .
ls out | head -5
#16 exporting to client directory
#16 copying files 1.34MB 0.0s done
Bookstore.Tools
Bookstore.Tools.deps.json
Bookstore.Tools.dll
Bookstore.Tools.pdb
Bookstore.Tools.runtimeconfig.json

Bu, makinede .NET SDK'sı olmayan bir ortamda (ör. yalnız Docker'ı olan bir build agent'ında) derleme yapıp çıktıyı almanın kısa yolu. out/ klasörü 5. yazıdan beri .dockerignore'da, böylece bir sonraki build'in context'ine girmiyor. scratch final image olarak da kullanılır, ama yalnız hiçbir şeye bağımlı olmayan statik binary'ler için (Go programları gibi). .NET runtime'ı bir libc'ye ve birkaç sistem kütüphanesine ihtiyaç duyduğu için .NET'te bu fikrin karşılığı distroless image'lar: paket yöneticisi ve shell'i olmayan, yalnız uygulamanın çalışması için gereken dosyaları taşıyan image'lar. Microsoft'un Ubuntu chiseled ve Azure Linux distroless image'ları bu sınıfta.

Image Boyutu: Ne Kazandık?

İkinci sorunun cevabı. Bütün image'lar linux/amd64 için docker image ls --tree ile ölçüldü:

ImageDISK USAGECONTENT SIZE
sdk:10.01.31GB359MB
runtime:10.0 (Ubuntu 24.04)301MB84.6MB
runtime:10.0-noble-chiseled141MB43.5MB
runtime-deps:10.0-noble-chiseled20.4MB5.95MB
Bookstore.Tools, 6. yazı (tek stage, SDK)1.34GB367MB
Bookstore.Tools, multi-stage (final)143MB44.1MB
Bookstore.Tools, debug target'ı145MB44.9MB

Cevap 150'ye en yakın olanı: 1,34 GB'tan 143 MB'a, yaklaşık dokuzda bir. Registry'den inen sıkıştırılmış boyut (CONTENT SIZE) 367 MB'tan 44,1 MB'a indi; bu, her deploy'da ve her yeni node'da çekilen veri. containerd image store'da DISK USAGE sıkıştırılmış içerikle açılmış layer'ların toplamı; bizim image için 143 MB yaklaşık 99 MB açılmış dosya ve 44 MB sıkıştırılmış içerikten oluşuyor. Tabloda dikkat çeken bir şey daha var: bizim uygulamamız base image'a yalnız 1,4 MB ekliyor. Image boyutunu artık neredeyse tamamen base image seçimi belirliyor. runtime-deps ise .NET runtime'ını bile içermiyor; self-contained ya da Native AOT (Ahead-of-Time; uygulamayı önceden makine koduna derleme) ile derlenmiş uygulamalar için.

Layer bazında nereden ne geldiğini görmek için 5. yazıdaki gibi docker history yeterli:

docker history dockerseries-bookstore-tools:1.0.0
IMAGE          CREATED              CREATED BY                                      SIZE      COMMENT
b3faf6b94f0d   About a minute ago   CMD ["--Import:DelayMs=500"]                    0B        buildkit.dockerfile.v0
<missing>      About a minute ago   ENTRYPOINT ["dotnet" "Bookstore.Tools.dll"]     0B        buildkit.dockerfile.v0
<missing>      About a minute ago   USER app                                        0B        buildkit.dockerfile.v0
<missing>      About a minute ago   ENV DOTNET_ENVIRONMENT=Production Import__Fi…   0B        buildkit.dockerfile.v0
<missing>      About a minute ago   LABEL org.opencontainers.image.title=Booksto…   0B        buildkit.dockerfile.v0
<missing>      About a minute ago   COPY --chown=app:app data/books.csv data/boo…   16.4kB    buildkit.dockerfile.v0
<missing>      About a minute ago   COPY /app ./ # buildkit                         1.41MB    buildkit.dockerfile.v0
<missing>      About a minute ago   WORKDIR /app                                    8.19kB    buildkit.dockerfile.v0
<missing>      About a minute ago   ARG APP_VERSION=1.0.0                           0B        buildkit.dockerfile.v0
<missing>      3 days ago           CMD ["--info"]                                  0B        buildkit.dockerfile.v0
<missing>      3 days ago           ENTRYPOINT ["/usr/bin/dotnet"]                  0B        buildkit.dockerfile.v0
<missing>      3 days ago           COPY /dotnet-symlink /usr/bin # buildkit        12.3kB    buildkit.dockerfile.v0
<missing>      3 days ago           COPY /usr/share/dotnet /usr/share/dotnet # b…   83.2MB    buildkit.dockerfile.v0
<missing>      3 days ago           ENV DOTNET_VERSION=10.0.15                      0B        buildkit.dockerfile.v0
<missing>      3 days ago           USER 1654                                       0B        buildkit.dockerfile.v0
<missing>      3 days ago           COPY /rootfs/home/app /home/app # buildkit      12.3kB    buildkit.dockerfile.v0
<missing>      3 days ago           ENV APP_UID=1654 ASPNETCORE_HTTP_PORTS=8080 …   0B        buildkit.dockerfile.v0
<missing>      3 days ago           COPY /rootfs / # buildkit                       14.4MB    buildkit.dockerfile.v0

Base image'ın kendisi de multi-stage build edilmiş: COPY /rootfs / ve COPY /usr/share/dotnet satırları Microsoft'un Dockerfile'ında başka stage'lerden kopyalanan içerik. 14,4 MB'lık kök dosya sistemi ve 83,2 MB'lık .NET runtime'ı, üstüne bizim 1,41 MB'lık publish çıktımız. Layer'ın içinde hangi dosyanın ne kadar yer tuttuğunu görmek istersen dive gibi araçlar layer'ları dosya ağacı olarak gösterir; ama çoğu soruya docker history cevap veriyor.

QEMU'suz Çok Mimarili Build

6. yazıda .NET SDK'sını QEMU (başka mimarinin komutlarını yazılımla çalıştıran emülatör) altında çalıştırmamamız gerektiğini söylemiştik. Bu Dockerfile'da bunu üç satır sağlıyor. FROM --platform=$BUILDPLATFORM, SDK stage'lerini her zaman build makinesinin mimarisinde çalıştırır. ARG TARGETARCH, BuildKit'in her hedef platform için doldurduğu mimari adını (amd64, arm64) alır ve dotnet publish -a $TARGETARCH o mimari için çıktı üretir. Final stage'deki FROM'da --platform olmadığı için o stage hedef platformun runtime image'ını kullanır. .NET SDK'sı başka mimari için derlemeyi kendisi yapabildiği için emülasyon gerekmiyor. Bake dosyasındaki tools-release target'ına iki platform ekledik:

target "tools-release" {
  inherits   = ["tools"]
  platforms  = ["linux/amd64", "linux/arm64"]
  tags       = ["${REGISTRY}/bookstore/tools:${TAG}"]
  cache-from = ["type=registry,ref=${REGISTRY}/bookstore/tools:buildcache"]
  cache-to   = ["type=registry,ref=${REGISTRY}/bookstore/tools:buildcache,mode=max"]
  output     = ["type=registry"]
}
docker buildx bake tools-release
docker buildx imagetools inspect localhost:5000/bookstore/tools:1.0.0
docker run --rm --platform linux/arm64 localhost:5000/bookstore/tools:1.0.0
Name:      localhost:5000/bookstore/tools:1.0.0
  Name:        localhost:5000/bookstore/tools:1.0.0@sha256:b364362899ccf588a2638eb9ffaf9122923849efc85e7827bc08e4c65a15db73
  Platform:    linux/amd64
  Name:        localhost:5000/bookstore/tools:1.0.0@sha256:efccdbf15b9c0646577649efe26e5a985dff50f12df34b3c2181e2479d75a146
  Platform:    linux/arm64
...
11:22:24 info: Bookstore.Tools.ImportWorker[0] Import finished: 5 books in 3 batches

Build log'unda restore stage'i bir kez çalışıyor; publish adımı ise her hedef için ayrı satırda, [<build platformu>-><hedef> build 1/1] biçiminde görünüyor ve biri -a amd64, diğeri -a arm64 ile çalışıyor. arm64 image'ını x64 bir makinede çalıştırdığında runtime'ın kendisi QEMU ile çalışır; bu deneme için kabul edilebilir, ama arm64 image'ını asıl test edeceğin yer arm64 bir makine. Aynı işi Dockerfile'sız, SDK'nın kendi container desteğiyle yapmayı 9. yazıda göreceğiz.

Bu yaklaşımın bir sınırı var: -a ile publish, uygulama native bir kütüphane içeren bir NuGet paketi kullanıyorsa (ör. SQLite ya da bir görüntü işleme kütüphanesi) hedef mimarinin native dosyasını seçer, bu doğru davranış. Ama build stage'inde hedef mimari için bir şey çalıştırman gerekirse (ör. hedef mimariye özel bir aracı RUN ile çağırmak) o adım yine QEMU'ya düşer. .NET'te bu nadir; Angular build'inde ise hiç gerekmez, çünkü çıktı mimariden bağımsız JavaScript.

Üretimde Dikkat

Final stage'i ve hangi stage'in hangi image'ı ürettiğini açık tut. Bake'te ya da pipeline'da her zaman --target final (ya da target = "final") ver; Dockerfile'a birisi en alta yeni bir stage eklediğinde production'a giden image sessizce değişmesin.

Production image'ına SDK, test projesi, kaynak kod ya da .pdb dışında bir build artığı girmediğini kontrol et. docker history'de beklenmedik bir büyük layer ya da docker run --entrypoint ile listelenen beklenmedik dosyalar bunun işaretidir. .pdb dosyaları stack trace'lerde satır numarası verdiği için çoğu ekip bunları bilerek bırakır; bırakmak istemiyorsan publish'e -p:DebugType=none ekleyebilirsin.

Base image'ı uygulamanın gerçek ihtiyacına göre seç. Chiseled image'larda shell, curl, ICU (International Components for Unicode; kültüre özel sıralama ve büyük-küçük harf kurallarını sağlayan kütüphane) ve saat dilimi verisi yok. Bookstore.Tools için sorun değil; Türkçe sıralama yapan ya da Europe/Istanbul saat dilimini kullanan Bookstore.Api için bunun nasıl bir hataya döndüğünü bir sonraki yazıda göreceğiz.

Debug target'ını registry'ye ayrı bir tag'le (ör. 1.0.0-debug) gönder ya da hiç gönderme. Aynı tag'i paylaşırlarsa production'da hangisinin çalıştığını bilemezsin.

Sık Düşülen Tuzaklar

Final stage'i SDK stage'inden türetmek. FROM build AS final yazmak multi-stage'in bütün kazancını götürür ve image yeniden 1 GB'ın üzerine çıkar. Final stage'in FROM'u her zaman bir runtime image'ı olmalı.

Stage'lere ad yerine index ile başvurmak. COPY --from=0 ilk stage'i gösterir; Dockerfile'ın başına yeni bir stage eklenince aynı satır sessizce başka bir stage'den kopyalamaya başlar. Her stage'e AS ile ad ver ve adıyla başvur.

Debug ya da test stage'ini en alta yazmak. Hedef verilmeyen bir docker build son stage'i build eder; en altta debug varsa production'a busybox'lı image gider. Bu yüzden Dockerfile'ımızda son stage final.

ARG'ı FROM'dan önce tanımlayıp stage'in içinde kullanmaya çalışmak. İlk FROM'dan önceki ARG yalnız FROM satırlarında geçerli. Küçük bir denemede (ARG DOTNET_VERSION=10.0, ardından FROM alpine:3 ve RUN echo "version=$DOTNET_VERSION") docker build --check "no warnings found" dedi, build'in çıktısı ise version= oldu: değer boş geldi. Stage'in içinde gerekiyorsa ARG DOTNET_VERSION'ı orada değersiz olarak yeniden yaz; varsayılan değeri yukarıdan alır.

Test stage'inin her build'de çalıştığını sanmak. Yukarıda gördüğümüz gibi BuildKit yalnız hedefe giden stage'leri build eder. Testlerin CI'da gerçekten çalıştığından emin olmak için onları ayrı bir adım ya da Bake target'ı olarak çağır.

Küçük Bir Deneme

★☆☆ BookRow.HasValidIsbn13'teki digit * 3'ü digit * 2 yap. Önce docker build ., sonra docker build --target test . çalıştır. Hangisi hatayı yakaladı ve neden?

★★☆ Final stage'in base image'ını sırayla runtime:10.0, runtime:10.0-alpine ve runtime:10.0-noble-chiseled yap; her birinde docker image ls --tree ile linux/amd64 boyutunu ölç ve uygulamanın üçünde de çalıştığını kontrol et.

★★★ build stage'inde publish'i -p:PublishSingleFile=true --self-contained ile yap ve final image'ı runtime-deps:10.0-noble-chiseled üzerine kur. ENTRYPOINT'i nasıl değiştirmen gerekti, image ne kadar küçüldü ya da büyüdü?

Bookstore.Tools artık 143 MB'lık, shell'siz, root olmayan ve iki mimari için QEMU'suz build edilen bir image. Bir sonraki yazıda aynı yaklaşımı asıl uygulamaya, Bookstore.Api'ye uyguluyoruz: ASP.NET Core image aileleri, port 8080, environment variable ile configuration ve chiseled image'da Türkçe sıralamanın ve saat diliminin neden bozulduğu.