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

Azure Pipelines ile Azure'a Şifresiz Deploy: Workload Identity Federation, App Service Slot'ları ve EF Core Migration Bundle

Mert Özen 15 Kas 2026 11 dk 7 görüntülenme
Azure Pipelines ile Azure'a Şifresiz Deploy: Workload Identity Federation, App Service Slot'ları ve EF Core Migration Bundle

Workload identity federation ile Azure service connection'ı hiçbir secret saklamadan çalışır: Azure DevOps bir OIDC token'ı üretir, Entra ID onu federated credential ile eşleştirip access token verir. Bookstore'u resource group'a sınırlı connection'larla App Service staging slot'una deploy ediyor, smoke test ve swap yapıyor, migration bundle'ı Key Vault'tan gelen connection string'le çalıştırıyor ve Azure DevOps issuer'ının 2027 emekliliğini görüyoruz.

Sürüm notu: Bu yazıdaki Entity Framework Core (EF Core) migration bundle'ı .NET 10 (SDK 10.0.404) ve dotnet-ef 10.0.15 ile yerelde üretilip bir Linux container'ında çalıştırılmış, smoke test script'i yerelde denenmiştir; pipeline ve template dosyaları yq 4.54 ile ayrıştırılarak denetlenmiştir. Service connection'ların, workload identity federation'ın ve Azure task'larının davranışı için Microsoft Learn'deki resmî belgelerden (Kasım 2026 sürümleri) yararlanılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) ekibi, iki yıl önce kurduğu bir Azure service connection'ını kullanıyor: secret yok, workload identity federation ile çalışıyor. Bir sabah Azure DevOps project'inin adı Bookstore'dan Bookstore-Platform'a değiştiriliyor. Kod, pipeline ve connection'ın kendisi aynı kalıyor. Gece çalışan deploy yine başarılı olur mu?

Connection'ın ne zaman oluşturulduğuna bağlı. Eski connection'lar Azure DevOps issuer'ını kullanıyor ve Microsoft Entra ID'deki federated credential'ın subject'i sc://<organization>/<project>/<service connection name> biçiminde: project adı subject'in içinde. Ad değişince pipeline'ın sunduğu token'ın subject'i de değişiyor, Entra ID kayıtlı credential'la eşleşme bulamıyor ve task "AADSTS70021: No matching federated identity record found" hatasıyla düşüyor; sorun giderme belgesi bu hatanın nedenleri arasında organization ya da project adının değişmesini açıkça sayıyor. Mart 2025'ten (Sprint 253) beri oluşturulan connection'lar ise Microsoft Entra issuer'ını kullanıyor; subject'te ad yok, organization ve connection kimlikleri var. Bu connection'lar için yeniden adlandırma bir şey değiştirmiyor.

Service Connection'lar ve Workload Identity Federation

Pipeline'ın Azure'a erişmesi bir Azure Resource Manager service connection'ı üzerinden oluyor. Belgeler kimlik doğrulama için iki önerilen yol veriyor: workload identity federation kullanan bir app registration ya da user-assigned managed identity. Secret'lı app registration, publish profile ve agent'a atanmış managed identity gibi seçenekler belgelere göre geriye uyumluluk ve özel durumlar için duruyor; yeni bir connection için önerilmiyorlar.

Federation'ın fikri basit: Azure DevOps'ta hiçbir secret saklanmıyor. Task Azure'a erişmek istediğinde Azure DevOps o service connection için bir OIDC (OpenID Connect, OAuth 2.0 üzerine kurulu kimlik protokolü) token'ı üretiyor. Entra ID bu token'ın issuer'ını ve subject'ini, kimliğe önceden tanımlanmış federated credential ile karşılaştırıyor; eşleşirse bir access token veriyor ve task Azure Resource Manager'a bu token'la gidiyor.

07-wif-flow-tr.png

İki issuer'ın farkı, Sprint 253 sürüm notlarında ve sorun giderme belgesinde aynı tabloyla veriliyor. Bookstore'un production connection'ı için değerler şöyle görünüyor (kimlikler örnek):

Azure DevOps issuer
  Issuer:  https://vstoken.dev.azure.com/00000000-0000-0000-0000-000000000000
  Subject: sc://bookstore-org/Bookstore/bookstore-azure-production

Microsoft Entra issuer
  Issuer:  https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111/v2.0
  Subject: <entra prefix>/sc/00000000-0000-0000-0000-000000000000/22222222-2222-2222-2222-222222222222

Takvim de belli. Azure DevOps issuer'ı 1 Temmuz 2026 itibarıyla deprecated ve 1 Temmuz 2027'de emekliye ayrılıyor; roadmap'in credential theft girişimi tablosunda bu madde 2027 Q2 (yılın ikinci çeyreği) altında duruyor. Kapsam dışında kalanlar da yazılı: Azure Government gibi public olmayan bulutlar ve multitenant app registration kullanan connection'lar Azure DevOps issuer'ında kalmaya devam ediyor; multitenant için emeklilik roadmap'te tarihsiz "Future" maddesi.

Elindeki connection'ı dönüştürmek için belgelerdeki yol şu: Project settings altındaki Service connections listesinde etkilenen connection'lar işaretleniyor ve Update ile Entra issuer'ına geçiliyor. Secret'lı eski bir connection'ı federation'a çevirmek için de Convert var; ama yalnızca connection'ı Azure DevOps otomatik oluşturduysa ve tek bir project kullanıyorsa. Dönüşüm yedi gün içinde geri alınabiliyor. Yetkin Entra tarafında değilse Azure DevOps sana issuer ve subject değerlerini veriyor, kimliğin sahibi credential'ı elle ekliyor.

Bookstore'un Service Connection'ları

Her environment için ayrı bir connection oluşturduk: bookstore-azure-dev, bookstore-azure-staging ve bookstore-azure-production. Hepsi "App registration (automatic)" ve "Workload identity federation" ile, scope olarak subscription'ın tamamı yerine tek bir resource group seçilerek kuruldu; production'ınki yalnızca rg-bookstore-prod'u görüyor. Belgelerin güvenlik önerisi de bu: subscription seviyesinde geniş Contributor yetkisi vermemek, connection'ı yalnızca gereken kaynakları içeren bir resource group'a sınırlamak. "Grant access permission to all pipelines" seçeneğini işaretlemedik; belgeler bunu önermiyor, her pipeline'a ayrı yetki vermeyi söylüyor.

Altıncı yazıda production environment'ına koyduğumuz branch control ve required template check'lerini bookstore-azure-production connection'ına da ekledik. Environment'ı hiç kullanmayan, aynı connection'la doğrudan deploy eden bir pipeline yazılsa bile bu check'ler devrede. Template içinde connection adı bookstore-azure-${{ parameters.environment }} diye üretiliyor; belgelerin uyardığı gibi service connection bir değişkenden okunamıyor, dosya işlenirken bilinmesi gerekiyor.

Migration Bundle: Build'de Üret, Deploy'da Çalıştır

Migration'ların deploy sırasındaki yeri ve geriye uyumlu şema kuralları Deployment Stratejileri serisinin migration yazısında anlatıldı. Burada pipeline tarafına bakıyoruz: bundle Build stage'inde bir kez üretiliyor, release artifact'ının içinde taşınıyor ve her ortamda aynı dosya çalışıyor. İlk denememiz yerelde başarısız oldu:

$ dotnet ef migrations bundle --project src/Bookstore.Api/Bookstore.Api.csproj --configuration Release --self-contained --target-runtime linux-x64 --output out/migrations/efbundle
Build started...
Build succeeded.
An error occurred while accessing the Microsoft.Extensions.Hosting services. Continuing without the application service provider. Error: Connection string 'BookstoreDb' is missing. Set it with user-secrets or the ConnectionStrings__BookstoreDb environment variable (see README.md).
Unable to create a 'DbContext' of type 'BookstoreContext'. The exception 'Unable to resolve service for type 'Microsoft.EntityFrameworkCore.DbContextOptions`1[Bookstore.Data.BookstoreContext]' while attempting to activate 'Bookstore.Data.BookstoreContext'.' was thrown while attempting to create an instance. For the different patterns supported at design time, see https://go.microsoft.com/fwlink/?linkid=851728

Bundle'ı üretmek için EF araçları DbContext'i uygulamanın kendi host'u üzerinden kuruyor ve Program.cs connection string yoksa bilerek exception fırlatıyor. Bundle üretilirken veritabanına bağlanılmadığı için sahte bir değer yetiyor:

$ ConnectionStrings__BookstoreDb="Server=bundle-build-only;Database=BookstoreDb" dotnet ef migrations bundle --project src/Bookstore.Api/Bookstore.Api.csproj --configuration Release --self-contained --target-runtime linux-x64 --output out/migrations/efbundle
Build started...
Build succeeded.
Building bundle...
Done. Migrations Bundle: …/out/migrations/efbundle
Don't forget to copy appsettings.json alongside your bundle if you need it to apply migrations.
$ du -h out/migrations/efbundle
172M	out/migrations/efbundle

Self-contained bundle .NET runtime'ını içinde taşıdığı için 172 MB; agent'ta .NET kurulu olmasına gerek kalmıyor. Asıl ders ise çalıştırma tarafında çıktı. Bundle'ı bir Linux container'ında, dinlemeyen bir adresle iki kez çalıştırdık:

$ ./efbundle --connection "Server=127.0.0.1,1;Database=BookstoreDb;User Id=bookstore_migrator;Password=example-password-change-me;TrustServerCertificate=True;Connect Timeout=5"
An error occurred while accessing the Microsoft.Extensions.Hosting services. Continuing without the application service provider. Error: Connection string 'BookstoreDb' is missing. …
Unable to create a 'DbContext' of type 'Bookstore.Data.BookstoreContext'. …
$ ConnectionStrings__BookstoreDb="Server=127.0.0.1,1;…" ./efbundle
Microsoft.Data.SqlClient.SqlException (0x80131904): A network-related or instance-specific error occurred while establishing a connection to SQL Server. …

--connection verilse bile bundle uygulamanın host'unu kuruyor ve aynı kontrole takılıyor. Connection string'i ConnectionStrings__BookstoreDb environment variable'ı olarak verince bundle bağlantı aşamasına geçiyor; buradaki hata yalnızca adresin bilerek boş olmasından. Bu bizim için iyi bir sonuç: secret komut satırına hiç yazılmıyor, belgelerin secret'ları komut satırı parametresi olarak kullanmama önerisine de uyuyor. CI'daki step aynı dersi taşıyor:

      # Migration'ları uygulayan tek bir çalıştırılabilir dosya; burada değil, deploy stage'inde çalışır.
      - script: |
          dotnet tool restore
          dotnet ef migrations bundle --project ${{ parameters.apiProject }} --configuration ${{ parameters.buildConfiguration }} \
            --self-contained --target-runtime linux-x64 --output $(Build.ArtifactStagingDirectory)/migrations/efbundle
        displayName: Build EF Core migration bundle
        env:
          # Program.cs DbContext'i kurmak için connection string ister; bundle üretilirken bağlanılmaz.
          ConnectionStrings__BookstoreDb: Server=bundle-build-only;Database=BookstoreDb

Bundle ayrı bir migrations artifact'ı olarak yükleniyor, Package job'ı onu release/migrations/ altına koyuyor ve doğrulama script'i artık efbundle'ın varlığını da kontrol ediyor.

Connection String'i Key Vault'tan Almak

İkinci yazıda söz verdiğimiz yol bu: bir variable group'u doğrudan Azure Key Vault'a bağlamak. Library'de bookstore-production-secrets grubunu "Link secrets from an Azure key vault as variables" açık olarak oluşturduk, bir service connection ve kv-bookstore-prod vault'unu seçip yalnızca BookstoreDbMigrations secret'ını ekledik. Belgelere göre gruba yalnızca secret adları eşleniyor; değerler her run'da vault'tan en güncel hâliyle okunuyor. Vault'a yeni bir secret eklenirse grup kendiliğinden güncellenmiyor, elle eklemek gerekiyor. RBAC (role-based access control, rol tabanlı erişim denetimi) kullanan bir vault'ta connection'ın kimliğine "Key Vault Secrets User" rolü yetiyor; belgeler private endpoint arkasındaki RBAC vault'ların bu bağlantıyla desteklenmediğini de not ediyor.

Alternatif AzureKeyVault@2 task'ı: vault adı ve bir filtreyle secret'ları o job için değişkene çeviriyor. Fark yetkinin nerede durduğunda. Variable group bir protected resource; kendi pipeline permission'ları ve check'leri var ve vault'a, grubu oluştururken seçilen connection üzerinden erişiyor. Biz grubu yalnızca bu vault'u okuyabilen ayrı bir connection'la (bookstore-keyvault-production) bağladık. Task'ta ise vault'u okuma yetkisi task'a verilen connection'ın kimliğinde olmak zorunda; deploy connection'ını kullanırsak o kimlik hem deploy edebilen hem secret okuyabilen bir kimliğe dönüşüyor. Her iki yolda da secret script'e kendiliğinden environment variable olarak gelmiyor; deploy step'inde env ile açıkça eşliyoruz. Key Vault'un kendisi Configuration ve Secret Management serisinde anlatıldı.

Staging Slot, Smoke Test ve Swap

Deploy template'inin deploy hook'u artık şu step'leri içeriyor (v1.4.0); useSlot yalnızca production için açık:

                # Migration'lar yeni kod yayına girmeden çalışır; bu yüzden geriye uyumlu kalmaları gerekir.
                - script: |
                    chmod +x $(Pipeline.Workspace)/release/migrations/efbundle
                    $(Pipeline.Workspace)/release/migrations/efbundle
                  displayName: Apply EF Core migrations
                  env:
                    ConnectionStrings__BookstoreDb: $(BookstoreDbMigrations)

                - task: AzureWebApp@1
                  name: deployApi
                  displayName: Deploy API
                  inputs:
                    azureSubscription: bookstore-azure-${{ parameters.environment }}
                    appType: webAppLinux
                    appName: $(apiAppName)
                    ${{ if eq(parameters.useSlot, true) }}:
                      deployToSlotOrASE: true
                      resourceGroupName: $(resourceGroup)
                      slotName: staging
                    package: $(Pipeline.Workspace)/release/api
                    runtimeStack: DOTNETCORE|10.0

                - script: bash scripts/smoke-test.sh $(deployApi.AppServiceApplicationUrl)
                  displayName: Smoke test

                - ${{ if eq(parameters.useSlot, true) }}:
                    - task: AzureAppServiceManage@0
                      displayName: Swap staging slot into production
                      inputs:
                        azureSubscription: bookstore-azure-${{ parameters.environment }}
                        Action: Swap Slots
                        WebAppName: $(apiAppName)
                        ResourceGroupName: $(resourceGroup)
                        SourceSlot: staging
                        SwapWithProduction: true

AzureWebApp@1'in package input'u bir zip dosyası yanında MSBuild'in ürettiği bir klasörü de kabul ediyor, bu yüzden release içindeki api klasörünü olduğu gibi veriyoruz. runtimeStack listesinde DOTNETCORE|10.0 var. Task'ın tek output'u AppServiceApplicationUrl; belgeler onu seçilen App Service'in adresi diye tanımlıyor, smoke test de adresi buradan alıyor. Uygulama adı ve resource group bookstore-production variable group'undan geliyor; bunlar değişken olabilir, kısıt yalnızca connection adında.

Smoke test küçük bir script: /api/categories 200 ve boş olmayan bir JSON listesi dönene kadar birkaç kez deniyor. Yerelde, aynı cevabı veren küçük bir stub sunucuya ve hiçbir şeyin dinlemediği bir porta karşı çalıştırdık:

$ bash scripts/smoke-test.sh http://127.0.0.1:52107
Smoke test passed on attempt 1: http://127.0.0.1:52107/api/categories returned 4 categories
$ SMOKE_ATTEMPTS=3 SMOKE_DELAY_SECONDS=2 bash scripts/smoke-test.sh http://127.0.0.1:52108
Attempt 1/3: http://127.0.0.1:52108/api/categories returned HTTP 000
Attempt 2/3: http://127.0.0.1:52108/api/categories returned HTTP 000
Attempt 3/3: http://127.0.0.1:52108/api/categories returned HTTP 000
##vso[task.logissue type=error]Smoke test failed: http://127.0.0.1:52108/api/categories did not return categories after 3 attempts

Smoke test başarısız olursa job duruyor ve swap hiç çalışmıyor; production slot'u eski sürümü sunmaya devam ediyor. Swap'ın kendisi, slot'a özel ayarlar ve geri dönüş Deployment Stratejileri serisinin Blue-Green Deployment yazısında anlatıldı. AzureAppServiceManage@0 önizlemeli swap (Start Swap With Preview, Complete Swap) action'larını da destekliyor.

Angular Çıktısını Yayınlamak

Angular'ın build çıktısı statik dosya. Akla ilk gelen AzureStaticWebApp@0 task'ı, ama belgesindeki girdiler bir Azure service connection değil, bir deployment token (azure_static_web_apps_api_token) istiyor; yani pipeline'da yine bir secret saklamak gerekiyor. Bookstore'da Storage hesabının static website özelliğini seçtik ve yüklemeyi aynı federation connection'ıyla AzureCLI@2 üzerinden yapıyoruz:

                - task: AzureCLI@2
                  displayName: Upload Angular app to the static website
                  inputs:
                    azureSubscription: bookstore-azure-${{ parameters.environment }}
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: >-
                      az storage blob upload-batch --auth-mode login --overwrite true
                      --account-name $(webStorageAccount) --destination '$web'
                      --source $(Pipeline.Workspace)/release/web

--auth-mode login, Azure CLI'ın storage account key yerine oturumdaki kimliği kullanmasını sağlıyor; bunun için connection'ın kimliğine o hesapta "Storage Blob Data Contributor" gibi bir veri rolü gerekiyor. Önüne bir CDN (içerik dağıtım ağı) koymak ve cache ayarları Cloud Temelleri serisinin konusu.

Üretimde Dikkat

Kullanılmayan connection'lar kapanabiliyor. Belgelere göre Azure Pipelines 100 gün kullanılmayan Azure Resource Manager connection'larını otomatik olarak devre dışı bırakabiliyor ve onlara başvuran pipeline'lar connection yeniden açılana kadar kırılıyor. Yılda birkaç kez çalışan bir felaket kurtarma pipeline'ın varsa bunu bil.

Migration ile swap arasındaki pencereyi hesaba kat. Bundle production veritabanını yeni şemaya taşıdıktan sonra, swap tamamlanana kadar eski kod yeni şemayla çalışıyor. Bu yüzden migration'lar geriye uyumlu olmalı; kolon silmek gibi işler bir sonraki sürüme kalıyor.

Rolleri en dar seviyede ver. Production connection'ının kimliğinin App Service'e deploy etmek, slot swap yapmak ve Storage'a yazmak dışında bir işi yok. Resource group seviyesindeki Contributor bile gereğinden geniş olabilir; daha dar roller ve kaynak seviyesinde atama, erişim incelemesinde soruları azaltıyor.

Sık Düşülen Tuzaklar

Connection'ı "Grant access permission to all pipelines" ile açık bırakmak. Project'teki her pipeline, bir pull request'te yazılan bir pipeline dahil, production'a erişebilir hâle geliyor. Pipeline'ları tek tek yetkilendir ve connection'a check ekle.

RBAC rolünü subscription seviyesinde vermek. Connection kurulurken resource group seçilmezse kimliğin yetkisi bütün subscription'ı kapsıyor. Bir yanlış step bütün ortamları etkileyebilir.

Connection string'i --connection ile vermek. Hem secret komut satırına yazılıyor hem de bizim uygulamamızda olduğu gibi host'u kurulan bir projede işe yaramıyor. Environment variable kullan.

Eski issuer'ı unutmak. 2025'ten önce kurulmuş federation connection'ları da dönüştürülmeyi bekliyor; 1 Temmuz 2027'den sonra Azure DevOps issuer'ıyla token alınamayacak. Service connections listesindeki uyarıyı bugün kontrol et.

Küçük Bir Deneme

★☆☆ smoke-test.sh'e ikinci bir kontrol ekle: /api/books?pageSize=1 de 200 dönmeli. Stub sunucuya yeni bir dosya koyarak dene.

★★☆ Bookstore için bir user-assigned managed identity ile connection kurmanın adımlarını belgelerden çıkar ve app registration yolundan hangi yetkiler açısından farklı olduğunu yaz.

★★★ Smoke test başarısız olduğunda staging slot'unu bir önceki sürüme geri döndüren bir on: failure hook'u tasarla. Hook'un hangi artifact'a ihtiyaç duyacağını ve migration'ın geri alınıp alınmayacağını tartış.

Pipeline artık Azure'a şifresiz deploy ediyor. Ama bütün bu işler Microsoft'un hosted agent'larında koşuyor ve organization'ın güvenlik ayarlarına henüz dokunmadık. Bir sonraki yazıda agent seçeneklerine (Microsoft-hosted, self-hosted, Managed DevOps Pools) ve job authorization scope'tan settableVariables'a kadar güvenlik ayarlarına bakacağız.