Dockerfile'sız .NET Container: dotnet publish /t:PublishContainer ve Container MSBuild Property'leri
.NET SDK, dotnet publish /t:PublishContainer ile Dockerfile ve Docker build'i olmadan image üretir: publish çıktısını tek layer olarak base image'ın üstüne koyar, app kullanıcısını ve 8080 port'unu kendisi ayarlar, sonucu lokal daemon'a, tar dosyasına ya da doğrudan registry'ye gönderir. Bookstore.Api'yi bu yolla üretiyor, ContainerFamily, çok mimarili index ve ContainerImageFormat'ı deniyor, Dockerfile yoluyla boyut, layer ve süre açısından karşılaştırıyoruz.
Sürüm notu: Bu yazıdaki örnekler .NET SDK 10.0.404 (SDK'nın container desteği, Microsoft.NET.Build.Containers), ASP.NET Core Runtime 10.0.15 image'ları, Docker Engine 29.9.3 (containerd image store) ve registry:3 ile denenmiştir.
Önce Tahmin Et
Projede Dockerfile yok. dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer çalıştırıyorsun. Üç soru: Image hangi base image üzerine kurulur? Container root olarak mı çalışır? Docker Desktop kapalıysa komut yine de bir image üretebilir mi?
SDK Image'ı Nasıl Üretiyor?
.NET SDK, .NET 7'den beri kendi başına container image üretebiliyor. Mantığı Dockerfile'dan farklı: SDK hiçbir komutu container içinde çalıştırmaz. Uygulamayı her zamanki gibi makinede publish eder, publish klasörünü tek bir layer olarak paketler, base image'ın manifest'ini ve config'ini registry'den okur, üstüne kendi config'ini (entrypoint, kullanıcı, port, ortam değişkenleri, label'lar) yazar ve sonucu bir hedefe gönderir. Hedef lokal Docker daemon'u, bir registry ya da bir tar dosyası olabilir. Bu yüzden 6. yazıdaki build cache'e, 7. yazıdaki stage'lere ya da BuildKit'e ihtiyaç yok.
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'si Bookstore.Api'yi önce hiçbir ayar vermeden denedik:
dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer
Bookstore.Api -> /work/src/Bookstore.Api/bin/Release/net10.0/publish/
Building image 'bookstore-api' with tags 'latest' on top of base image 'mcr.microsoft.com/dotnet/aspnet:10.0'.
Pushed image 'bookstore-api:latest' to local registry via 'docker'.
İlk sorunun cevabı: ASP.NET Core projesi olduğu için aspnet, tag'i TargetFramework'ten (net10.0 → 10.0). Konsol uygulamasında runtime, self-contained bir uygulamada runtime-deps seçilir. Image adı assembly adının küçük harfli hâli, tag latest. "local registry via 'docker'" ifadesi, image'ın docker load ile lokal daemon'a verildiğini söylüyor. Image config'ine bakınca ikinci sorunun cevabı da çıkıyor:
docker image inspect bookstore-api:latest \
--format 'User={{.Config.User}} Entrypoint={{json .Config.Entrypoint}} WorkingDir={{.Config.WorkingDir}} Ports={{json .Config.ExposedPorts}}'
User=1654 Entrypoint=["dotnet","/app/Bookstore.Api.dll"] WorkingDir=/app/ Ports={"8080/tcp":{}}
Root değil. Base image Ubuntu tabanlı aspnet:10.0 ve 8. yazıda gördüğümüz gibi onun User alanı boş, ama SDK .NET 8 ve sonrasını hedefleyen projelerde ContainerUser'ı varsayılan olarak app kullanıcısına (uid 1654) ayarlıyor. Port'u da kendisi çıkarıyor: base image'daki ASPNETCORE_HTTP_PORTS=8080'i okuyup 8080/tcp'yi expose ediyor. 8. yazıda Dockerfile'a elle yazdığımız USER $APP_UID ve EXPOSE 8080 burada kendiliğinden geliyor.
Varsayılanların bir eksiği var: base image tam Ubuntu image'ı (342 MB). Bu image'da ICU (International Components for Unicode; dile özel sıralama kurallarını sağlayan kütüphane) ve tzdata olduğu için Bookstore.Api'nin Türkçe sıralaması çalışır, ama shell ve paket yöneticisi de gelir. 8. yazıdaki seçimi burada da yapmamız gerekiyor.
Container Property'leri
SDK'nın ayarları MSBuild property'leri. Bookstore.Api'nin csproj'una bir grup ekledik:
<PropertyGroup Label="Container">
<ContainerRepository>dockerseries-bookstore-api</ContainerRepository>
<ContainerImageTags>1.0.0-sdk</ContainerImageTags>
<ContainerFamily>noble-chiseled-extra</ContainerFamily>
</PropertyGroup>
dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer
docker run -d --name dockerseries-api -p 8080:8080 dockerseries-bookstore-api:1.0.0-sdk
curl -s localhost:8080/books | jq -r '.[0:2][] | .shelfLabel'
curl -s localhost:8080/store/clock
Building image 'dockerseries-bookstore-api' with tags '1.0.0-sdk' on top of base image 'mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra'.
Pushed image 'dockerseries-bookstore-api:1.0.0-sdk' to local registry via 'docker'.
ÇALIKUŞU
DUNE
{"zone":"Europe/Istanbul","localTime":"2026-11-14 14:37 +03:00"}
ContainerFamily, seçilen tag'in sonuna eklenir: 10.0 + noble-chiseled-extra = 10.0-noble-chiseled-extra. Base image'ı tamamen kendin belirlemek istersen ContainerBaseImage'a tam adı (ör. bir digest'le birlikte) yazarsın; o zaman ContainerFamily yok sayılır. Diğer sık kullanılanlar:
ContainerRepositoryveContainerImageTags: image adı ve tag'ler; birden çok tag noktalı virgülle ayrılır. Pipeline'da genellikle komut satırından verilir:-p ContainerImageTags=1.0.0.ContainerUser: varsayılanapp; root gerekiyorsa açıkçarootyazılır.ContainerPortveContainerEnvironmentVariable: item olarak yazılır (<ContainerPort Include="8080" Type="tcp" />); port çoğu zaman yukarıdaki gibi base image'dan çıkarılır.ContainerLabel: ek label'lar. SDK zatenorg.opencontainers.image.base.nameveorg.opencontainers.image.base.digestgibi label'ları kendisi yazıyor; image'ın hangi base image digest'inden üretildiği böylece kayıtlı.ContainerRuntimeIdentifiersveContainerImageFormat: birazdan.
Üç Hedef: Daemon, Tarball ve Registry
Lokal daemon varsayılan hedef ve Docker Desktop çalışmıyorsa publish hata verir. Üçüncü sorunun cevabı ise evet: diğer iki hedef Docker'a hiç ihtiyaç duymaz. ContainerArchiveOutputPath image'ı bir .tar.gz dosyasına yazar:
dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer -p ContainerArchiveOutputPath="$PWD/out/images/"
docker load -i out/images/dockerseries-bookstore-api.tar.gz
Pushed image 'dockerseries-bookstore-api:1.0.0-sdk' to local archive at '/work/out/images//dockerseries-bookstore-api.tar.gz'.
Loaded image: dockerseries-bookstore-api:1.0.0-sdk
Yolu mutlak verdik, çünkü göreli bir yol komutu çalıştırdığın klasöre değil projenin klasörüne göre çözülüyor: ilk denemede out/images/ dediğimizde dosya src/Bookstore.Api/out/images/'e yazıldı. Dosya 70 MB: base image'ın layer'ları dahil, image'ın tamamı. Tarball, image'ı registry'ye göndermeden önce bir tarama aracından geçirmek ya da Docker'ın olmadığı bir build ortamında üretip başka yerde yüklemek için kullanılır.
ContainerRegistry verilirse SDK image'ı doğrudan registry'ye gönderir; Docker daemon'u aradan çıkar. 4. yazıdaki gibi lokal bir registry (dockerseries-registry) açıp denedik:
dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer -p ContainerRegistry=localhost:5000
Pushed image 'dockerseries-bookstore-api:1.0.0-sdk' to registry 'localhost:5000'.
SDK localhost'taki registry'ye HTTP ile ek ayar istemeden bağlandı. Gerçek bir registry'de (GHCR, ACR (Azure'un image deposu hizmeti), Docker Hub) kimlik bilgisini docker login'in yazdığı yerden okur; ayrıca bir giriş mekanizması yok. CI'da Docker kurulu olmayan bir runner'da bile image üretip registry'ye göndermek bu yüzden mümkün. Pipeline'da image build ve push'u GitHub Actions serisinin 11. ve 15. yazıları anlatıyor.
Çok Mimarili Image ve ContainerImageFormat
7. yazıda Dockerfile'la yaptığımız iki mimarili image'ı SDK tek komutla üretiyor. ContainerRuntimeIdentifiers birden çok RID (Runtime Identifier; .NET'in işletim sistemi ve mimari kimliği, ör. linux-x64) aldığında SDK uygulamayı her RID için ayrı publish eder, her biri için bir image üretir ve bunları tek bir index'te birleştirir. Bu property'nin RuntimeIdentifiers'ın alt kümesi olması gerekiyor; ikisini birlikte verdik. Bash'te noktalı virgülün kaçışı için değer çift tırnağı da içeren tek tırnakla yazılıyor:
dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer \
-p ContainerRegistry=localhost:5000 \
-p RuntimeIdentifiers='"linux-x64;linux-arm64"' \
-p ContainerRuntimeIdentifiers='"linux-x64;linux-arm64"'
docker buildx imagetools inspect localhost:5000/dockerseries-bookstore-api:1.0.0-sdk
Building image 'dockerseries-bookstore-api' with tags '1.0.0-sdk-linux-x64' on top of base image 'mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra'.
Pushed image 'dockerseries-bookstore-api:1.0.0-sdk-linux-x64' to registry 'localhost:5000'.
Building image 'dockerseries-bookstore-api' with tags '1.0.0-sdk-linux-arm64' on top of base image 'mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra'.
Pushed image 'dockerseries-bookstore-api:1.0.0-sdk-linux-arm64' to registry 'localhost:5000'.
Pushed image index 'dockerseries-bookstore-api:1.0.0-sdk' to registry 'localhost:5000'.
Name: localhost:5000/dockerseries-bookstore-api:1.0.0-sdk
MediaType: application/vnd.docker.distribution.manifest.list.v2+json
Manifests:
MediaType: application/vnd.docker.distribution.manifest.v2+json
Platform: linux/amd64
MediaType: application/vnd.docker.distribution.manifest.v2+json
Platform: linux/arm64
İki ayrıntı göze çarpıyor. Birincisi, SDK her mimarinin image'ını 1.0.0-sdk-linux-x64 ve 1.0.0-sdk-linux-arm64 gibi ayrı tag'lerle de gönderdi; index bu ikisini gösteriyor. Registry'de tag listesi kalabalıklaşırsa şaşırma. İkincisi format: .NET'in base image'ları Docker formatında olduğu için SDK de Docker manifest list'i üretti; SDK dokümanı çok mimarili image'ların her zaman OCI (Open Container Initiative; container formatlarını standartlaştıran proje) formatında olduğunu söylese de bu sürümde gördüğümüz bu oldu. .NET 10 ile gelen ContainerImageFormat formatı açıkça seçmeyi sağlıyor:
dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer \
-p ContainerRegistry=localhost:5000 \
-p RuntimeIdentifiers='"linux-x64;linux-arm64"' \
-p ContainerRuntimeIdentifiers='"linux-x64;linux-arm64"' \
-p ContainerImageFormat=OCI
MediaType: application/vnd.oci.image.index.v1+json
...
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/amd64
İki format da bilgi kaybetmeden birbirinin yerine kullanılabiliyor ve Docker ikisini de çalıştırır. Format seçimi, image'ı imzalayan, SBOM (Software Bill of Materials; image'daki bileşenlerin listesi) ekleyen ya da OCI artifact'ı bekleyen araçlar için önem taşır; bu konuları DevSecOps serisi işliyor.
Konsol Uygulamaları ve Dockerfile'daki COPY'nin Karşılığı
.NET 10'dan önce konsol uygulamasının SDK ile container üretebilmesi için csproj'a <EnableSdkContainerSupport>true</EnableSdkContainerSupport> yazmak gerekiyordu. .NET 10'da bu satır gereksiz; Web ve Worker SDK'lı projeler gibi konsol projeleri de doğrudan PublishContainer'ı destekliyor. Bookstore.Tools'ta denedik:
dotnet publish src/Bookstore.Tools/Bookstore.Tools.csproj -t:PublishContainer \
-p ContainerRepository=dockerseries-bookstore-tools -p ContainerImageTags=1.0.0-sdk
docker run --rm dockerseries-bookstore-tools:1.0.0-sdk
Building image 'dockerseries-bookstore-tools' with tags '1.0.0-sdk' on top of base image 'mcr.microsoft.com/dotnet/runtime:10.0'.
Pushed image 'dockerseries-bookstore-tools:1.0.0-sdk' to local registry via 'docker'.
11:39:34 fail: Bookstore.Tools.ImportWorker[0] Cannot read import file: Could not find a part of the path '/app/data/books.csv'.
Image üretildi ve çalıştı, ama işini yapamadı. 7. yazıdaki Dockerfile'da COPY --chown=app:app data/books.csv data/books.csv satırı vardı; SDK ise yalnız publish klasörünü paketliyor ve data/books.csv projenin klasörü dışında, publish çıktısında yok. SDK yolunda bir dosyayı image'a sokmanın tek yolu onu publish çıktısına almak: csproj'da bir Content item'ı (Link ve CopyToPublishDirectory ile). Bookstore.Tools için Dockerfile'ı korumayı tercih ettik; konsol aracı veri dosyasını ve ENV varsayılanlarını Dockerfile'da daha okunur taşıyor.
Dockerfile mı, SDK mı? Boyut, Layer ve Süre
Aynı Bookstore.Api, aynı base image (aspnet:10.0-noble-chiseled-extra), iki yol. 8. yazıdaki Dockerfile'la build ettiğimiz 1.0.0 ve SDK'nın ürettiği 1.0.0-sdk, linux/amd64:
docker image ls --tree
docker history dockerseries-bookstore-api:1.0.0-sdk
dockerseries-bookstore-api:1.0.0 d6c04f6b9901 240MB 73MB
└─ linux/amd64 cb5eb143de1f 240MB 73MB
dockerseries-bookstore-api:1.0.0-sdk 1ba8cbce21ca 240MB 73MB
└─ linux/amd64 1ba8cbce21ca 240MB 73MB
IMAGE CREATED CREATED BY SIZE COMMENT
1ba8cbce21ca 3 minutes ago .NET SDK Container Tooling, version 10.0.404… 987kB
<missing> 3 days ago COPY /dotnet /usr/share/dotnet # buildkit 27.4MB buildkit.dockerfile.v0
<missing> 3 days ago ENV ASPNET_VERSION=10.0.15 0B buildkit.dockerfile.v0
| Dockerfile (8. yazı) | SDK PublishContainer | |
|---|---|---|
Boyut (DISK USAGE / CONTENT SIZE) | 240MB / 73MB | 240MB / 73MB |
| Base image'ın üstüne eklenen | WORKDIR (8,19 kB) + COPY /app (979 kB) | tek layer (987 kB) |
| Kod değişikliğinden sonra yeniden üretme (bu makinede) | yaklaşık 2,1 sn | 6–8 sn |
HEALTHCHECK | var | yok (property'si yok) |
| Build için gereken | Docker + BuildKit | .NET SDK (daemon yalnız lokal hedef için) |
Boyut aynı, çünkü ikisi de aynı base image'a aynı publish çıktısını koyuyor. Süre farkını açıklamak gerek: iki yolu da bir .cs değişikliğinden sonra, cache'ler sıcakken üçer kez ölçtük. BuildKit restore'u cache'ten alıp yalnız publish adımını çalıştırdı. SDK her seferinde MSBuild değerlendirmesini ve restore kontrolünü baştan yaptı, sonra image'ı Docker'a docker load ile verdi. Registry ya da tarball hedefinde bu son adım değişir; mutlak sayılar makineye göre oynar, ama "SDK yolu her durumda daha hızlı" demek doğru olmaz.
Ne Zaman Hâlâ Dockerfile Gerekir?
SDK'nın yapamadığı şey tek cümleyle: image içinde komut çalıştırmak. RUN karşılığı yok. Buna göre Dockerfile'ın hâlâ gerektiği durumlar:
- Native bir bağımlılık için paket kurmak (
apt-get installile bir font paketi,libgdiplus, bir veritabanı client'ı). Alternatif: bu paketleri içeren kendi base image'ını Dockerfile'la üretipContainerBaseImageile SDK'ya vermek. - Özel bir entrypoint script'i, image'a ek dosya ya da dizin izinleri (
COPY --chown,chmod). - Dockerfile'daki
HEALTHCHECKgibi SDK property'si olmayan image ayarları. - .NET dışı parçalar: Angular uygulamasının Node ile build edilip nginx'e konması (bir sonraki yazı).
- Ekip standardı: bütün servislerin aynı Dockerfile ve Bake yapısıyla build edilmesi, aynı build cache ve aynı tarama adımları.
Tersi durumda, yani saf bir ASP.NET Core ya da Worker servisinde, SDK yolu daha az dosya, daha az kural ve doğru varsayılanlar (non-root, port, label'lar) demek. Bookstore'da Api için iki yolu da açık bırakıyoruz: Bake ve Dockerfile 8. yazıdaki gibi duruyor, SDK ayarları csproj'da.
Üretimde Dikkat
Base image'ı sabitle. ContainerFamily yalnız tag'i seçer; publish anında o tag'in gösterdiği digest kullanılır. Tekrar üretilebilir build istiyorsan ContainerBaseImage'a digest'li tam adı yaz; SDK'nın yazdığı org.opencontainers.image.base.digest label'ı her image'ın hangi base'den geldiğini zaten kaydediyor.
Tag'leri pipeline'dan ver. csproj'daki ContainerImageTags lokal denemeler için; CI'da -p ContainerImageTags=$(version) ile 4. yazıdaki semver + SHA (commit'in hash değeri) stratejisini uygula.
Multi-RID publish uygulamayı RID başına bir kez daha derler; build süresi mimari sayısıyla artar. SDK bunları varsayılan olarak paralel yapar; bazı proje türlerinde (ör. Blazor WebAssembly) yarış durumu çıkarsa ContainerPublishInParallel=false ile sıralı yaptırabilirsin.
.NET 11 SDK'da lokal hedefin seçimi değişti: Windows'ta WSL (Windows'ta Linux çalıştıran alt sistem) containers (wslc) çalışıyorsa Docker'dan önce o tercih ediliyor; LocalRegistry=Docker ile sabitlenebilir. Ayrıntısını 15. yazıda göreceğiz.
Sık Düşülen Tuzaklar
RID vermeden publish edip image'ı başka mimarideki sunucuya göndermek. SDK, RID verilmezse publish'i yapan makinenin mimarisini kullanır. x64 bir Windows makinesinde bu linux-x64'tür ve sunucuyla uyumludur; ama aynı komut ARM (mobil ve yeni dizüstü bilgisayarlarda yaygın işlemci mimarisi) işlemcili bir geliştirici makinesinde (Snapdragon'lu bir Windows laptop ya da bir Apple Silicon Mac) linux-arm64 image üretir. ARM bir makinede denedik:
dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer -p ContainerImageTags=arm-dev
docker image inspect dockerseries-bookstore-api:arm-dev --format '{{.Os}}/{{.Architecture}}'
linux/arm64
Bu image x64 bir sunucuda exec format error ile hiç başlamaz; teşhisini 14. yazıda göreceğiz. Production image'ını her zaman CI'da, -r linux-x64 ya da ContainerRuntimeIdentifiers ile hedefi açıkça vererek üret.
Göreli ContainerArchiveOutputPath. Yukarıda gördüğümüz gibi yol projenin klasörüne göre çözülür. Arşiv proje klasörüne düşerse ve .dockerignore'da değilse bir sonraki docker build'in context'ine 70 MB'lık bir dosya girer.
Dockerfile'daki her ayarın SDK'da karşılığı olduğunu sanmak. HEALTHCHECK, RUN ve proje dışından dosya kopyalamak SDK yolunda yok. Bir Dockerfile'ı SDK'ya taşırken önce bu satırları listele.
Varsayılan base image'la yetinmek. ASP.NET Core projesinde varsayılan tam Ubuntu image'ı; ContainerFamily vermezsen image'a shell ve paket yöneticisi de girer. Uygulama globalization istiyorsa noble-chiseled-extra, istemiyorsa noble-chiseled seç.
Küçük Bir Deneme
★☆☆ ContainerFamily'yi noble-chiseled yap, image'ı üret ve /books'u çağır. 8. yazıdaki hatayı SDK yolunda da görüyor musun?
★★☆ csproj'a <ContainerEnvironmentVariable Include="Store__TimeZone" Value="UTC" /> ekle, image'ı üret ve docker image inspect ile config'teki Env listesine bak. Aynı değişkeni docker run -e ile verirsen hangisi kazanıyor?
★★★ Bookstore.Tools için data/books.csv'yi publish çıktısına alan bir Content item'ı yaz, image'ı SDK ile üret ve 7. yazıdaki Dockerfile image'ıyla boyut, kullanıcı (ls -ln ile dosya sahibi) ve ENV açısından karşılaştır.
.NET tarafı tamam: Bookstore.Api hem Dockerfile'la hem SDK ile container'a alınabiliyor. Bir sonraki yazıda bookstore-web'e, Angular uygulamasına geçiyoruz: Node build stage'i, root olmayan nginx, SPA (Single Page Application; tek sayfalık web uygulaması) fallback ve image'ı yeniden build etmeden her ortama farklı API adresi veren runtime configuration.