Mert Özen Her Satırda Daha İleri
Backend .NET ile Mikroservis Uygulaması — #14

Container'lar ve Deploy: SDK Container Build, Kubernetes ve Service Başına Bağımsız Pipeline

Mert Özen 14 Kas 2026 10 dk 5 görüntülenme
Container'lar ve Deploy: SDK Container Build, Kubernetes ve Service Başına Bağımsız Pipeline

Image'lar Dockerfile'sız, dotnet publish /t:PublishContainer ile chiseled base üzerinde root olmayan kullanıcıyla üretiliyor; Angular BFF'nin image'ına giriyor. aspire publish preview'da geçerli bir Helm chart veriyor ama migrator'ları Deployment yapıyor ve probe eklemiyor; elle yazılan Kustomize manifest'leri, kind'de görülen veri kaybı, managed altyapı kararı ve path filtreli service başına GitHub Actions pipeline'ı anlatılıyor.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK container build), Aspire 13.6 (Aspire.Hosting.Kubernetes 13.6.5-preview), Kubernetes 1.37 (kind 0.33), Helm 4.3 ve actionlint 1.7 ile derlenip denenmiştir; GitHub Actions workflow'ları actionlint ile doğrulandı, GitHub'da çalıştırılmadı.

Önce Tahmin Et

Catalog'un image'ını .NET SDK'sı ile Dockerfile yazmadan üretiyoruz. Varsayılan ASP.NET Core base image'ı ile Ubuntu chiseled image'ı arasında kaç MB fark çıkar ve container root olarak mı çalışır? İkinci soru: Aspire'ın aspire publish'i AppHost'tan bir Helm chart üretiyor. 02'den beri WaitForCompletion ile çalıştırdığımız migrator'lar o chart'ta ne olur? Cevaplar gerçek build'lerde ve üretilen chart'ta.

Image Üretmek: SDK Container Build

.NET SDK'sı image'ı Dockerfile olmadan üretebiliyor: dotnet publish uygulamayı derliyor, Microsoft'un base image'ının üstüne bir katman olarak koyuyor ve sonucu lokal Docker'a ya da bir registry'ye gönderiyor. Docker serisinde Dockerfile ile yaptığımız multi-stage build'in yerini tutuyor. Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) ortak ayarlar Directory.Build.props'ta, image adı her service'in kendi csproj'unda (<ContainerRepository>bookstore-catalog</ContainerRepository>):

<!-- `dotnet publish /t:PublishContainer` ile üretilen image'lar (14. yazı) -->
<PropertyGroup Label="Containers">
  <!-- Ubuntu "chiseled" base: shell yok, paket yöneticisi yok, root olmayan bir kullanıcı. "extra" ICU ve tzdata'yı
       koruyor; SqlClient ve saat dilimi dönüşümleri onlara ihtiyaç duyuyor. -->
  <ContainerFamily>noble-chiseled-extra</ContainerFamily>
  <!-- OpenAPI dokümanları `dotnet build` ile üretiliyor ve commit'leniyor (03. yazı). Başka bir runtime için publish
       etmek, dokümanı yeniden üretmek için o runtime'a göre derlenmiş uygulamayı başlatır; bu farklı mimarilerde bozuluyor. -->
  <OpenApiGenerateDocumentsOnBuild Condition="'$(_IsPublishing)' == 'true'">false</OpenApiGenerateDocumentsOnBuild>
</PropertyGroup>
dotnet publish src/Services/Catalog/Bookstore.Catalog.Api -c Release --os linux --arch x64 /t:PublishContainer -p ContainerImageTag=14
  Building image 'bookstore-catalog' with tags '14' on top of base image 'mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra'.
  Pushed image 'bookstore-catalog:14' to local registry via 'docker'.

ContainerRegistry verilmediği için image lokal Docker'a gidiyor; CI'da aynı komut -p ContainerRegistry=... ile registry'ye gönderiyor (aşağıda). İkinci satırdaki OpenApiGenerateDocumentsOnBuild ayarı bir hatadan geldi. Build makinesinin mimarisinden farklı bir mimari için publish ettiğimizde 03'te kurduğumuz build-time OpenAPI üretimi o mimari için derlenmiş uygulamayı yüklemeye çalıştı ve publish kırıldı:

Microsoft.Extensions.ApiDescription.Server.targets(67,5): error : System.IO.FileNotFoundException: Could not load file or assembly 'Bookstore.Catalog.Api, Culture=neutral, PublicKeyToken=null'. The system cannot find the file specified.

Doküman build'de zaten üretilip commit'lendiği için publish sırasında yeniden üretmek gereksiz. Önce Tahmin Et'in ilk sorusu, x64 image'ların sıkıştırılmış boyutları:

ImageBaseBoyut
bookstore-catalogaspnet:10.0 (varsayılan)135 MB
bookstore-catalogaspnet:10.0-noble-chiseled-extra110 MB
bookstore-ordering, -inventory, -paymentschiseled-extra108–109 MB
bookstore-notificationschiseled-extra91 MB
bookstore-bff (Angular dahil)chiseled-extra75 MB

Chiseled image 25 MB küçük, ama asıl kazancı boyut değil: içinde shell ve paket yöneticisi yok, yani ele geçirilmiş bir container'da saldırganın kullanabileceği araç da yok. İki image'da da kullanıcı 1654: .NET 8'den beri Microsoft'un image'ları root olmayan app kullanıcısıyla geliyor ve uygulama 8080'i dinliyor. Altı image'ın base katmanları ortak; bir node'a hepsini çekmek 600 MB değil, base artı her service'in kendi katmanı demek.

Angular'ı BFF'nin Image'ına Gömmek

11'de Angular'ın build çıktısını BFF'nin (Backend for Frontend; Angular'a özel ara backend) wwwroot'una koymuştuk; production image'ında da tek origin olsun diye BFF'nin publish'i Angular'ı build ediyor ve dosyaları publish çıktısına alıyor:

<Target Name="BuildAngular" BeforeTargets="ComputeResolvedFilesToPublishList" Condition="'$(SkipAngularBuild)' != 'true'">
  <Exec Command="npm ci --no-audit --no-fund" WorkingDirectory="$(AngularRoot)" />
  <Exec Command="npm run build" WorkingDirectory="$(AngularRoot)" />
</Target>

<Target Name="PublishAngular" AfterTargets="ComputeResolvedFilesToPublishList">
  <ItemGroup>
    <AngularFiles Include="wwwroot/**" />
    <ResolvedFileToPublish Include="@(AngularFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
      <RelativePath>wwwroot/%(RecursiveDir)%(Filename)%(Extension)</RelativePath>
      <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
    </ResolvedFileToPublish>
  </ItemGroup>
</Target>

İlk denemede wwwroot'ta 11'deki development build'i (main.js, styles.css) duruyordu ve publish SDK'nın static web assets pipeline'ında kırıldı: Microsoft.NET.Sdk.StaticWebAssets.Compression.targets(403,5): error : The asset '.../obj/Release/net10.0/linux-x64/compressed/...'. SDK, proje değerlendirildiği anda gördüğü dosyaları sıkıştırmaya çalışıyordu; Angular'ın production build'i o dosyaları silip hash'li adlarla yenilerini yazmıştı. wwwroot bizim için build çıktısı, kaynak değil: BFF'de StaticWebAssetsEnabled'ı kapattık ve wwwroot'u varsayılan content item'larından çıkardık; dosyaları 11'deki UseStaticFiles sunuyor. Temiz bir publish'ten sonra image'daki dosyalar:

app/wwwroot/
app/wwwroot/3rdpartylicenses.txt
app/wwwroot/favicon.ico
app/wwwroot/index.html
app/wwwroot/main-UPE27H4M.js
app/wwwroot/styles-N2GZJMYJ.css

CI'da Angular ayrı bir adımda build edilip test ediliyorsa -p SkipAngularBuild=true ikinci build'i atlıyor.

Health Endpoint'leri Her Ortamda

02'deki ServiceDefaults şablonu /health ve /alive'ı yalnız Development'ta açıyordu. Kubernetes probe'ları için production'da da lazım:

public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
    // Kubernetes bunları her ortamda yokluyor (14. yazı): readiness için /health (bütün check'ler, Aspire'ın EF Core
    // integration'ının kaydettiği veritabanı check'i dahil), liveness için /alive (yalnız "live": process cevap veriyor).
    // Cevaplar ayrıntı taşımıyor. Cluster dışından yalnız BFF'ye ulaşılabiliyor; onun ingress'i bu yolları yönlendirmemeli.
    app.MapHealthChecks(HealthEndpointPath);
    app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
    {
        Predicate = r => r.Tags.Contains("live")
    });

    return app;
}

İki endpoint'in farkı önemli. Readiness veritabanına bağlı: veritabanı yoksa pod trafik almasın. Liveness ise yalnız process'in cevap verdiğini söylüyor; veritabanı kesintisinde pod'ları yeniden başlatmak sorunu çözmez, yalnız yükü artırır. Probe'ların ayrıntısı Kubernetes serisinin "Kubernetes Health Probe'ları" yazısında.

aspire publish: Kubernetes İçin Bir Başlangıç Noktası

Aspire, AppHost'taki modelden bir Helm chart üretebiliyor. Aspire.Hosting.Kubernetes bu yazının yazıldığı gün hâlâ preview (13.6.5-preview). AppHost'a bir satır ekledik: builder.AddKubernetesEnvironment("k8s");. İlk aspire publish hemen durdu:

System.InvalidOperationException: Step 'prepare-deployment-targets-k8s' failed: Bind mounts are not supported by the Kubernetes publisher

Bind mount, 10'daki Keycloak realm import'u: Realms klasörü container'a bağlanıyor. Production realm'i zaten böyle yönetilmemeli (10), bu yüzden import'u yalnız aspire run'a bıraktık:

// Realm import'u Realms/ klasörünün bind mount'u; Kubernetes publisher bind mount desteklemiyor
// ve production realm'ini zaten başka yerde yönetiyor (10. yazı).
if (builder.ExecutionContext.IsRunMode)
{
    keycloak.WithRealmImport("./Realms");
}

İkinci denemede pipeline geçti ve helm lint hata vermedi. Chart'ta her resource için bir klasör var: bff, catalog, catalog-migrator, inventory, inventory-migrator, k8s-dashboard, keycloak, mailpit, notifications, ordering, ordering-migrator, payment-provider, payments, payments-migrator, rabbitmq, redis, sql. helm template 14 Deployment, 3 StatefulSet, 13 Service, 14 ConfigMap ve 15 Secret çıkardı. Önce Tahmin Et'in ikinci cevabı: migrator'lar Job değil, Deployment oldu:

apiVersion: "apps/v1"
kind: "Deployment"
metadata:
  name: "catalog-migrator-deployment"
...
      containers:
        - image: "{{ .Values.parameters.catalog_migrator.catalog_migrator_image }}"
          name: "catalog-migrator"
          args:
            - "--migrate"
...
  replicas: 1

Bir Deployment, process çıktığında container'ı yeniden başlatır; migration bittiğinde çıkan bir process sonsuza kadar yeniden başlar. Chart'ta başka eksikler de vardı: hiçbir container'da probe yok (AppHost'taki WithHttpHealthCheck taşınmamış), securityContext ve resource request'leri yok, image'lar catalog:latest gibi yer tutucular ve secret'lar values.yaml'da boş. Sahte ödeme sağlayıcısı, Mailpit ve Aspire dashboard'u da chart'ta; bunlar lokal geliştirme resource'ları. Chart'ın kendisi geçerli ve yazının yazıldığı gün için bir başlangıç noktası olarak işe yarıyor; production'a almadan önce bu boşlukları doldurmak gerekiyor.

Elle Yazılmış Manifest'ler: Kustomize

Karşılaştırmak için Catalog'un manifest'lerini Kustomize ile elle yazdık (Kubernetes serisinin Kustomize yazısındaki base/overlay düzeni): deploy/k8s/base/catalog'da Deployment, Service ve migration Job'ı, deploy/k8s/overlays/local'da lokal bir cluster için namespace, secret'lar ve geçici bağımlılıklar. Deployment'ın Aspire çıktısında olmayan kısımları:

securityContext:
  # Chiseled image zaten root olmayan "app" kullanıcısıyla (1654) çalışıyor; Kubernetes bunu zorunlu kılıyor
  runAsNonRoot: true
  runAsUser: 1654
  seccompProfile:
    type: RuntimeDefault
containers:
  - name: catalog
    image: bookstore-catalog
    envFrom:
      # Şifreli connection string'ler: ortam başına bir Secret, bu repository'de hiçbir zaman yok
      - secretRef:
          name: catalog-connections
    # Liveness: process cevap veriyor. Readiness: veritabanı da cevap veriyor (14. yazı, ServiceDefaults)
    livenessProbe:
      httpGet:
        path: /alive
        port: http
      periodSeconds: 10
    readinessProbe:
      httpGet:
        path: /health
        port: http
      periodSeconds: 5
      failureThreshold: 3
    startupProbe:
      httpGet:
        path: /alive
        port: http
      periodSeconds: 2
      failureThreshold: 30
    resources:
      requests:
        cpu: 100m
        memory: 192Mi
      limits:
        memory: 384Mi
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]

Migrator bir Job: aynı image --migrate ile, bitene kadar çalışıyor. Deploy sırası Kubernetes serisinin Job'lar yazısındaki gibi: önce Job, kubectl wait --for=condition=complete, sonra Deployment. Migration'ın deploy'daki yeri ve geriye uyumlu şema (expand-contract) Deployment Stratejileri serisinin altıncı yazısında; bir Job'ın eski ve yeni sürüm aynı anda çalışırken uygulanabilmesi için şema değişikliğinin geriye uyumlu olması şart. Secret'lar repository'de değil: make-secrets.sh rastgele şifrelerle iki .env dosyası üretiyor, Kustomize'ın secretGenerator'ı onları Secret'a çeviriyor ve dosyalar .gitignore'da.

Overlay'i lokal bir kind cluster'ında denedik. Migration Job'ı tamamlandı:

job.batch/catalog-migrate condition met
      Applied 2 migration(s): 20261114122715_InitialCreate, 20261114130249_AddBookVersion
      Sample data ready: 20 books
      catalogdb is up to date in 1653 ms

Catalog ise hiç Ready olmadı. Cluster içindeki SQL Server pod'u on iki dakikada üç kez yeniden başladı ve sebep bellek yetmezliğiydi (OOMKilled); volume'u olmadığı için her yeniden başlamada catalogdb da gitti. Catalog açılışta Wolverine'in tablolarını oluştururken bağlantısı koptu (A transport-level error has occurred ... Connection was terminated), startup probe 60 saniye dolunca container'ı yeniden başlattı ve pod CrashLoopBackOff'a düştü. Rolling update de eski pod'u bıraktı: Deployment'ın durumu MinimumReplicasUnavailable. Probe'lar tasarlandığı gibi çalıştı (sağlıksız pod'a hiç trafik gitmedi), ama sistem çalışmadı. Overlay'e iki düzeltme koyduk: SQL Server'a memory request'i ve limiti, Job'a activeDeadlineSeconds: 600 (asılı kalan bir migration deploy'u sonsuza kadar bekletmesin).

14-deploy-topology-tr.png

Managed Altyapı Kararı

Bu deneme, önceden vermemiz gereken bir kararı somutlaştırdı: SQL Server, RabbitMQ ve Redis cluster'ın içinde mi çalışacak, yoksa bulut sağlayıcının managed service'i mi olacak? Cluster içinde çalıştırmak, persistent volume'ları, yedeklemeyi, sürüm yükseltmeyi, yüksek erişilebilirliği ve bellek planlamasını üstlenmek demek; denemede ilkini atladık ve verimizi kaybettik. Üç kişilik geliştirici ekibi ve yarı zamanlı tek ops kişisi olan Bookstore için (Mimariye Giriş serisindeki varsayım) cevap managed service: cluster'da yalnız durumsuz uygulama pod'ları ve Job'lar çalışıyor. Bulut seçenekleri ve maliyet tarafı Cloud Temelleri serisinde. Lokal geliştirmede container'lar kalıyor; Aspire bunu zaten yapıyor. Kubernetes serisinin 17. yazısı, bir .NET ve Angular uygulamasını managed Kubernetes'e almanın uçtan uca örneği.

Service Başına Bağımsız Pipeline

Microservice'e geçmenin vaatlerinden biri her service'in kendi temposunda deploy edilmesi. Repository tek (01'deki mono-repo kararı), bu yüzden GitHub Actions'ta her service'in küçük bir workflow'u var ve paths filtresi yalnız o service değiştiğinde çalıştırıyor; asıl işi ortak bir reusable workflow yapıyor:

name: catalog

on:
  push:
    branches: [main]
    paths:
      - src/Services/Catalog/**
      - src/BuildingBlocks/**
      - tests/Bookstore.Catalog.ContractTests/**
      - tests/pacts/**
      - Directory.Packages.props
...
jobs:
  catalog:
    uses: ./.github/workflows/service.yml
    with:
      service: catalog
      project: src/Services/Catalog/Bookstore.Catalog.Api/Bookstore.Catalog.Api.csproj
      # BFF'nin pact'inin provider tarafı burada koşuyor: BFF'yi bozan bir Catalog değişikliği Catalog'un build'ini kırıyor
      tests: tests/Bookstore.Catalog.ContractTests
    secrets: inherit

service.yml build, service'in testleri, architecture ve mesaj şeması testleri ve main'de image üretip GitHub'ın container registry'sine göndermekten oluşuyor. 13'teki contract testinin yeri burada belli oluyor. BFF'nin workflow'u tüketici testini koşuyor ve pact dosyası değiştiyse git diff --exit-code tests/pacts pull request'i durduruyor; değişen pact dosyası tests/pacts/** filtresine uyduğu için Catalog'un workflow'u da tetikleniyor ve provider doğrulaması Catalog'un pipeline'ında koşuyor. Uçtan uca test ayrı bir workflow'da, main'e merge'den sonra ve gece. Workflow'ları actionlint ile denetledik (actionlint exit 0); GitHub'da çalıştırmadık. Deploy adımını bilerek eklemedik: cluster'a hangi yolla gidileceği (GitOps ile Argo CD, ya da pipeline'dan kubectl) ayrı bir karar; GitOps ve Argo CD serisinin konusu.

Bağımsız deploy'un bir ön şartı var: src/BuildingBlocks/** filtresi. Bookstore.Contracts ya da ServiceDefaults değiştiğinde bütün service'lerin pipeline'ı çalışıyor. 01'de paylaşılan kodu bu iki projeyle sınırlamamızın bir sebebi de buydu; 15'te bu kuralın tuttuğunu ölçeceğiz.

Üretimde Dikkat

Image'ları imzala ve tara. Registry'ye giden her image bir SBOM (Software Bill of Materials; image'daki bütün paketlerin listesi) ve imza ile gitmeli; chiseled base taramada çıkacak bulgu sayısını da azaltıyor.

latest etiketiyle deploy etme. Aspire'ın chart'ındaki catalog:latest gibi bir etiket, hangi kodun çalıştığını söylemez ve geri almayı imkânsızlaştırır. Workflow image'ı commit SHA'sıyla (Git'in her commit'e verdiği hash) da etiketliyor; manifest'te o etiket kullanılmalı.

Migration'ı deploy'dan ayır. Job ve Deployment'ı tek bir kubectl apply ile göndermek, denemede gördüğümüz gibi uygulamanın şema hazır olmadan açılmasına yol açar. Ya pipeline'da sıralı adımlar ya da Argo CD sync wave'leri ve Helm hook'ları kullanılmalı.

Ingress yalnız BFF'yi göstermeli. Diğer service'ler ClusterIP; health endpoint'leri ve iç API'ler dışarıdan erişilemez olmalı. Service'ler arası trafiği NetworkPolicy ile daraltmak, 10'daki "iç ağa güvenme" kuralının ağ katmanındaki karşılığı.

Sık Düşülen Tuzaklar

Migrator'ı Deployment olarak çalıştırmak. Bitince çıkan bir process Deployment'ta sürekli yeniden başlar; Job ya da Helm hook olmalı.

Durum tutan bir container'ı volume'suz ve memory request'siz çalıştırmak. Bellek baskısında ilk ölen o olur ve verisiyle birlikte gider.

Liveness probe'una veritabanını bağlamak. Veritabanı kesintisi bütün pod'ların yeniden başlamasına dönüşür.

Ortak klasörleri path filtresinde unutmak. src/BuildingBlocks/** filtrede olmazsa contract'ı değişen bir service eski contract'la deploy edilir.

Üretilen manifest'i incelemeden uygulamak. aspire publish çıktısında probe, securityContext ve Job yok; araç başlangıç noktası, son hâl değil.

Küçük Bir Deneme

★☆☆ Catalog'un image'ını ContainerFamily olmadan bir kez daha üret ve iki image'ın katmanlarını docker history ile karşılaştır. Fark hangi katmanda?

★★☆ Ordering için Kustomize base'ini yaz: Deployment, Service ve migration Job'ı. Ordering'in Catalog'dan farklı olarak hangi ek ayarlara ihtiyacı var (ör. RabbitMQ queue'ları, ödeme timeout'u)?

★★★ Wolverine'in tablolarını migration Job'ında oluştur ve service'lerde açılışta şema oluşturmayı kapat. Bunun denemedeki startup probe sorununa etkisi ne olur?

Sistem artık image'larıyla, manifest'leriyle ve pipeline'larıyla deploy edilebilir hâlde. Serinin son yazısında kurduğumuz her şeye geriye dönüp bakıyoruz: distributed monolith belirtileri, gerçek ölçümlerle kaynak kullanımı ve maliyet, aynı işlevin monolith sürümüyle karşılaştırma ve neyi tekrar birleştirirdik.