Mert Özen Her Satırda Daha İleri
Backend .NET Core Web API — #31

ASP.NET Core Web API'yi Docker'a Almak ve GitHub Actions ile CI/CD Kurmak

Mert Özen 4 Kas 2026 9 dk 2 görüntülenme
ASP.NET Core Web API'yi Docker'a Almak ve GitHub Actions ile CI/CD Kurmak

ASP.NET Core Web API'yi multi-stage Dockerfile ile container'a alıyoruz: dotnet publish, katman cache'i, .dockerignore, port ve environment ayarları; ardından GitHub Actions ile test edip imajı ghcr.io'ya gönderen bir CI/CD hattı kuruyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.

Önce Tahmin Et

Son iki yazıda API'mizi unit ve integration testlerle sağlama aldık. Kod doğru, ama hâlâ senin makinende yaşıyor. "Bende çalışıyor" cümlesini emekliye ayırmanın yolu, uygulamayı bağımlılıklarıyla birlikte tek bir pakete koymak ve o paketi her push'ta otomatik olarak test edip üretmek. Bu yazıda Docker ile paketleyip GitHub Actions ile bu süreci otomatikleştireceğiz.

Önce tahmin. Yazının ortasında productcatalog-api:1.0 adında bir imaj üreteceğiz. Yerelde dotnet run ile çalışırken uygulama launchSettings'teki porttan dinliyordu. Container'ı docker run -d -p 8080:8080 productcatalog-api:1.0 ile başlatıp curl http://localhost:8080/api/products/1 dersen ne olur?

dotnet publish: Neyi Paketliyoruz?

Container'a kaynak kodu değil, çalışmaya hazır çıktıyı koyarız. Bu çıktıyı dotnet publish üretir:

dotnet publish src/ProductCatalog.Api/ProductCatalog.Api.csproj -c Release -o /app/publish

Klasörün içinde birazdan imajda göreceğimiz dosyalar var: ProductCatalog.Api.dll uygulamanın kendisi, .deps.json bağımlılık listesi, .runtimeconfig.json hangi runtime ile çalışacağını söyleyen dosya, bir de appsettings.json. Bu yazıdaki API, unit test yazısındaki ProductCatalog API'nin bellekte çalışan bir repository ve /health/live endpoint'i eklenmiş hâli; test projesi de aynı.

build ile publish arasındaki fark şu: build geliştirme sırasında çalıştırmak için yeterli çıktı üretir, publish ise bir sunucuya kopyalanıp orada çalışacak eksiksiz bir klasör. Release yapılandırması derleyici optimizasyonlarını açar; production'a Debug çıktısı gitmemeli.

Multi-Stage Dockerfile

Proje kökünde, ProductCatalog.sln'in yanında bir Dockerfile:

# Aşama 1: SDK imajında derle ve publish et
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src

# Önce sadece csproj: restore katmanı, kod değişse de cache'ten gelir
COPY src/ProductCatalog.Api/ProductCatalog.Api.csproj src/ProductCatalog.Api/
RUN dotnet restore src/ProductCatalog.Api/ProductCatalog.Api.csproj

# Sonra kodun geri kalanı
COPY src/ src/
RUN dotnet publish src/ProductCatalog.Api/ProductCatalog.Api.csproj -c Release -o /app/publish --no-restore

# Aşama 2: sadece runtime içeren küçük imaj
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "ProductCatalog.Api.dll"]

İki FROM satırı iki ayrı aşama demek. İlk aşama sdk imajını kullanıyor: derleyici, NuGet, MSBuild, kısacası derlemek için gereken her şey burada. İkinci aşama aspnet imajı; içinde sadece .NET runtime'ı ve ASP.NET Core var. COPY --from=build ilk aşamadaki publish klasörünü ikinciye taşıyor, gerisi geride kalıyor. Bir mobilya atölyesi gibi düşün: masayı atölyede, bütün aletlerle yaparsın, ama müşterinin evine sadece masayı götürürsün, tezgâhı değil.

csproj'yi koddan önce ayrı kopyalamamızın sebebi Docker'ın katman cache'i. Docker her satırı bir katman olarak saklar ve girdisi değişmeyen katmanı yeniden çalıştırmaz. Kodunu her değiştirdiğinde COPY src/ src/ katmanı ve sonrası yeniden çalışır, ama csproj değişmediği sürece dotnet restore cache'ten gelir. Paket indirmek build'in en yavaş kısmı olduğu için bu sıralama build süresini ciddi kısaltır. Restore'u ayrı yaptığımız için publish'e --no-restore veriyoruz.

.dockerignore Neden Şart?

docker build, bulunduğu klasörün tamamını build context olarak Docker'a gönderir. COPY src/ src/ dediğinde senin makinendeki bin/ ve obj/ klasörleri de container'a gider. Dockerfile'ın yanına şu dosyayı koy:

**/bin/
**/obj/
.git/
.github/
tests/

Bunu atlarsan ne olacağını gördük. Swashbuckle referansı olan bir projeyi önce yerelde derleyip sonra .dockerignore olmadan imaj ürettiğimizde publish adımı şöyle düştü:

error NETSDK1064: Package Microsoft.OpenApi, version 1.6.22 was not found. It might have been deleted since NuGet restore. Otherwise, NuGet restore might have only partially completed, which might have been due to maximum path length restrictions.

Olan şu: container içindeki restore kendi obj/project.assets.json dosyasını üretti, ama hemen ardından COPY src/ src/ senin makinendeki obj/ klasörünü onun üzerine yazdı. O dosyadaki paket yolları macOS'taki NuGet klasörünü gösteriyordu ve container'da öyle bir klasör yok. Hiç paket referansı olmayan bir projede aynı hata çıkmayabilir, bu yüzden "bende sorun yoktu" demek kolay; ilk paketi eklediğin gün patlar.

İmajı Derleyip Çalıştırmak

docker build -t productcatalog-api:1.0 .
docker run -d -p 8080:80 --name catalog productcatalog-api:1.0
curl -s -w " %{http_code}\n" http://localhost:8080/api/products/1
{"id":1,"name":"Mechanical Keyboard","price":1450} 200
curl -s -w " %{http_code}\n" http://localhost:8080/health/live
Healthy 200

Container'ın log'una bakalım:

docker logs catalog
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:80
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /app/

Tahminin cevabı ilk satırda: uygulama container içinde 80 numaralı porttan dinliyor. Sebebi aspnet:6.0 imajının kendisi. docker inspect ile imajın environment variable'larına bakınca ASPNETCORE_URLS=http://+:80 görünüyor. -p 8080:8080 dediğinde Docker makinendeki 8080'i container'ın 8080'ine bağlıyor, ama orada dinleyen kimse yok. curl şöyle cevap verdi:

curl: (56) Recv failure: Connection reset by peer

Doğrusu -p 8080:80: sol taraf senin makinen, sağ taraf container. Ortamın da varsayılan olarak Production olduğuna dikkat et; launchSettings.json container'da kullanılmaz.

Ürettiğimiz imaj docker images çıktısında 309 MB görünüyor. İçinde SDK yok; derleme araçlarıyla birlikte paketleseydik bunun birkaç katı olurdu. Bu arada imajın içinde curl ya da wget de yok, yani Docker'ın HEALTHCHECK komutunu kullanmak istersen bir araç eklemen gerekir. Kubernetes gibi orchestrator'lar ise health check yazısındaki /health/live ve /health/ready endpoint'lerini dışarıdan, kendileri çağırır.

Ortam ve Configuration

Aynı imaj, test ortamında da production'da da çalışmalı; farkı imaj değil, ona verdiğin configuration yaratmalı. Configuration yazısında gördüğümüz environment variable provider'ı burada asıl işini yapıyor:

docker run -d -p 8080:80 -e ASPNETCORE_ENVIRONMENT=Staging --name catalog-stg productcatalog-api:1.0
docker logs catalog-stg | grep "Hosting environment"
      Hosting environment: Staging

İç içe ayarlar çift alt çizgiyle yazılır: appsettings.json'daki ConnectionStrings:Default, environment variable olarak ConnectionStrings__Default olur. Linux'ta iki nokta üst üste environment variable adında güvenle kullanılamadığı için bu çeviri var.

Altın kural: secret'lar imaja girmez. Connection string'i, JWT imzalama anahtarını ya da API key'leri Dockerfile'a ENV ile yazarsan, imajı çekebilen herkes docker inspect ile onları okuyabilir. Secret'ları çalışma anında, orchestrator'ın secret mekanizmasıyla ya da en azından -e ile ver.

GitHub Actions ile CI

Elle docker build demek bir kere iyidir, her push'ta değil. GitHub Actions, repository'ndeki .github/workflows/ klasöründe duran YAML dosyalarını tetikleyicilere göre çalıştırır. .github/workflows/ci.yml:

name: ci

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 6.0.x

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore -c Release

      - name: Test
        run: dotnet test --no-build -c Release --verbosity normal

  docker:
    # Testler geçmeden imaj üretme; PR'larda push etme
    needs: build-and-test
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v7

      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/setup-buildx-action@v4

      - id: meta
        uses: docker/metadata-action@v6
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha
            type=raw,value=latest,enable={{is_default_branch}}

      - uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

İlk job, CI'ın ta kendisi: kodu çek, .NET 6 SDK'sını kur, restore et, Release'te derle, testleri çalıştır. main'e her push'ta ve main'e açılan her pull request'te çalışır. Bir test kırılırsa job kırmızıya döner ve pull request'te bu görünür; kırık kod birleşmeden yakalanır. Her adımın ayrı olmasının sebebi de hata ayıklamak: log'da hangi adımın düştüğünü anında görürsün. --no-restore ve --no-build aynı işi iki kez yapmamak için.

Action sürümleri bu yazının yazıldığı Kasım 2026 itibarıyla güncel ana sürümler: actions/checkout@v7, actions/setup-dotnet@v6, docker/login-action@v4, docker/setup-buildx-action@v4, docker/metadata-action@v6 ve docker/build-push-action@v7. Kullandığımız her input'u, o sürümlerin action.yml dosyalarında kontrol ettik. Proje net10.0'a geçtiğinde değişen tek satır dotnet-version: 10.0.x olur.

İmajı Registry'ye Göndermek

İkinci job, CD'nin ilk adımı: test edilmiş kodu imaja çevirip bir registry'ye koymak. needs: build-and-test testler geçmeden bu job'ın başlamamasını, if: github.event_name == 'push' da pull request'lerde imaj push edilmemesini sağlıyor.

Registry olarak GitHub Container Registry'yi (ghcr.io) kullanıyoruz; bunun güzel yanı ayrı bir parola gerektirmemesi. Her workflow çalışmasına otomatik verilen GITHUB_TOKEN yetiyor, yeter ki job'ın permissions bölümünde packages: write izni olsun. metadata-action iki tag üretiyor: commit'in kısa SHA'sı (sha- önekiyle) ve varsayılan branch'teysen latest. Bir de sessiz bir iyilik yapıyor: imaj adını küçük harfe çeviriyor. Docker imaj adlarında büyük harfe izin vermediği için, MertOzen/ProductCatalog gibi bir repository adını doğrudan tag'e yazmak build'i düşürürdü.

Deploy kısmı, yani bu imajı bir sunucuda, Azure App Service'te ya da bir Kubernetes cluster'ında çalıştırmak, hedef platforma göre değişiyor. Ama zincirin mantığı her yerde aynı: push, test, imaj, registry, deploy. Her halkası bir öncekinin başarısına bağlı.

Bu yazıyı hazırlarken Dockerfile'ı hem sdk:6.0/aspnet:6.0 hem de sdk:10.0/aspnet:10.0 imajlarıyla gerçekten derleyip çalıştırdık. Workflow'un dotnet adımlarını aynı komutlarla yerelde koşturduk ve 13 testin geçtiğini gördük; workflow'un kendisini ise GitHub üzerinde çalıştırmadık, YAML'ı ayrıştırıp action input'larını doğrulamakla yetindik.

Sık Düşülen Tuzaklar

Destek dışı imaj kullanmak. mcr.microsoft.com/dotnet/aspnet:6.0 hâlâ indirilebiliyor; bizim çektiğimiz imajda runtime 6.0.36 ve Debian 11 vardı. Ama .NET 6'nın desteği 12 Kasım 2024'te bitti, yani bu imaj artık güvenlik yaması almıyor. Bu yazının imajı öğrenmek için; production'da desteklenen bir sürüm kullan.

Sürüm değiştirince portu unutmak. Aynı Dockerfile'ı 10.0 tag'leriyle derlediğimizde imajın environment'ında ASPNETCORE_URLS yerine ASPNETCORE_HTTP_PORTS=8080 vardı ve uygulama http://[::]:8080'den dinledi. .NET 8 ile gelen bu değişiklikle -p 8080:80 artık çalışmaz, -p 8080:8080 gerekir. Yeni imajlarda bir de APP_UID=1654 tanımlı, yani root olmayan bir kullanıcı hazır bekliyor; ama Dockerfile'a USER satırı eklemediğin sürece container yine root olarak çalışıyor, bizim denememizde whoami root dedi. Bu ikisini .NET 8 yazısında ayrıntılı göreceğiz.

HTTPS'i container'da açmaya çalışmak. Development sertifikası imajda yok ve olmamalı. Genelde TLS'i önündeki load balancer ya da reverse proxy sonlandırır, container düz HTTP konuşur. UseHttpsRedirection kullanıyorsan proxy arkasında doğru çalışması için forwarded header'ları da yapılandırman gerekir.

Testleri sadece Dockerfile'da çalıştırmak. RUN dotnet test satırı imaj build'ini yavaşlatır, test sonuçlarını CI'ın raporlarından saklar ve test bağımlılıklarını build aşamasına taşır. Testleri ayrı bir CI adımında çalıştırıp imajı ancak sonra üretmek daha temiz; workflow'umuz tam olarak bunu yapıyor.

Sadece latest tag'ine güvenmek. latest her push'ta başka bir imajı gösterir; bir sorun çıktığında hangi commit'in çalıştığını bilemezsin, geri dönemezsin. SHA tag'i her imajı bir commit'e bağlıyor; deploy'larda onu kullan.

Küçük Bir Deneme

★☆☆ Dockerfile'da COPY src/ src/ satırından sonra gelen bir değişiklik yap, örneğin bir controller'a yorum ekle, ve imajı yeniden derle. Çıktıda restore adımının CACHED olarak geçtiğini gör. Sonra csproj'ye bir paket ekleyip tekrar derle ve farkı karşılaştır.

★★☆ Dockerfile'ı sdk:10.0 ve aspnet:10.0 ile, csproj'yi net10.0 ile güncelle. Container'ı önce -p 8080:80 ile çalıştırıp hatayı gör, sonra doğru port eşlemesini bul. Final aşamaya USER $APP_UID ekleyip docker exec ile whoami çıktısını karşılaştır.

★★★ Workflow'a, dotnet test'e --logger trx ekleyip sonuç dosyasını actions/upload-artifact@v7 ile saklayan bir adım ekle. Bir testi bilerek kır, pull request aç ve hem job'ın kırmızıya döndüğünü hem de docker job'ının hiç başlamadığını gör.

Böylece kemik yapı tamamlandı: HTTP temellerinden routing'e, validation'dan EF Core'a, authentication'dan cache'e, testten container'a kadar bir Web API'nin iskeletini .NET 6'nın API yüzeyiyle kurduk ve her satırın .NET 10'da da çalıştığını gördük. Bundan sonra seri bir zaman makinesine dönüşüyor. Önce geriye gidip Startup.cs'li, ConfigureServices ve Configure method'lu eski projeleri okumayı öğreneceğiz; sahada hâlâ çok karşına çıkacaklar. Ardından ileri sararak .NET 7, 8, 9 ve 10'u tek tek ele alacağız: her sürümün bu iskelete neler kattığını, neyi değiştirdiğini, neyi emekliye ayırdığını, ".NET 6'da böyle yapıyorduk, şimdi şöyle" karşılaştırmasıyla göreceğiz.