Mert Özen Her Satırda Daha İleri
DevOps Web Server'lar ve Hosting — #10

IIS'te Production: Deploy, app_offline.htm, Kilitli Dosyalar, Application Initialization ve Angular

Mert Özen 8 Kas 2026 11 dk 5 görüntülenme
IIS'te Production: Deploy, app_offline.htm, Kilitli Dosyalar, Application Initialization ve Angular

IIS'te çalışan uygulamanın DLL'leri kilitli; deploy'u app_offline.htm ile ya da .NET 7'den beri desteklenen enableShadowCopy ile yap ve bu ayarları sunucuda değil projedeki web.config'te tut, publish onları koruyor. Recycle ve overlapped recycle, Application Initialization ve idle timeout, IIS'te Data Protection, sub-application, URL Rewrite ile Angular ve IIS yerine AddWindowsService; IIS bu Mac'te çalıştırılmadı, web.config ve build çıktıları gerçek.

Sürüm notu: Bu yazıdaki publish çıktıları ve web.config dosyaları .NET 10 SDK (Software Development Kit) 10.0.402 ve Angular CLI (Command Line Interface) 22.2.6 ile üretilmiş, Windows Service örneği .NET 10 ve Microsoft.Extensions.Hosting.WindowsServices 10.0.14 ile derlenip çalıştırılmıştır. IIS (Internet Information Services) bu Mac'te çalışmadığı için IIS'in kendi davranışları (dosya kilitleri, recycle, URL (Uniform Resource Locator) Rewrite) denenmedi; Microsoft Learn'deki dokümantasyona dayanıyor.

Önce Tahmin Et

Bookstore.Api, Windows Server'da IIS'te in-process çalışıyor. İkinci sürümü deploy etmek için yeni publish çıktısını robocopy ile site klasörünün üstüne kopyalıyorsun. appsettings.json ve web.config kopyalanıyor, ama Bookstore.Api.dll'de kopyalama "dosya başka bir process tarafından kullanılıyor" hatasıyla duruyor.

Tahmin et: şu an sunucuda hangi sürüm çalışıyor, hangi dosyalar yeni? Ve web.config'in değişmiş olmasının çalışan uygulamaya bir etkisi oldu mu?

Deploy: Web Deploy, Dosya Kopyalama ve app_offline.htm

Windows'ta çalışan bir process'in yüklediği DLL (Dynamic Link Library) dosyaları kilitli; üzerine yazılamıyor. In-process modelde uygulama w3wp.exe'nin içinde olduğu için kilidi IIS worker process'i tutuyor. Tahminin cevabı karışık bir durum: appsettings.json yeni, DLL eski. web.config'in değişmesi ise IIS'in sitenin app pool'unu recycle etmesini tetikliyor (Microsoft'un dokümanına göre bir sitenin web.config'i değişince site recycle ediliyor); yani kopyalama yarısında eski kodla yeni configuration'ı birleştiren bir process ayağa kalkmış olabilir. Bu yazıda ANCM (ASP.NET Core Module) dediğimiz modül, dokuzuncu yazıdaki köprü. Yarım kalmış bir deploy, hiç yapılmamış bir deploy'dan daha tehlikeli.

Microsoft'un "App Offline file" sayfasındaki mekanizma bu sorun için: site klasörünün köküne app_offline.htm adında bir dosya koyduğunda ANCM uygulamayı graceful şekilde kapatıyor, yeni istekleri işlemeyi bırakıyor ve bütün isteklere bu dosyanın içeriğini dönüyor. Uygulama shutdownTimeLimit (varsayılan 10 saniye) içinde kapanmazsa process'i durduruyor. Dosya silinince bir sonraki istek uygulamayı yeniden başlatıyor. Bir deploy script'i (PowerShell; bu Mac'te çalıştırılmadı):

$site = "C:\sites\bookstore-api"

# 1. uygulamayı kapat; ANCM istekleri app_offline.htm ile karşılar
Copy-Item .\app_offline.htm "$site\app_offline.htm"
Start-Sleep -Seconds 5

# 2. yeni dosyaları kopyala (kilitler kalktı)
robocopy .\publish $site /MIR /XF app_offline.htm /XD logs

# 3. uygulamayı geri aç
Remove-Item "$site\app_offline.htm"

Bu adımların süresince kullanıcılar uygulama yerine app_offline.htm'in içeriğini görüyor; dosyayı kısa bir "bakımdayız" sayfası olarak yaz. Dokümanın iki notu var: dosya 4 GB'tan küçük olmalı ve out-of-process modelde açık bir bağlantı (örneğin bir WebSocket) kapanmayı geciktirebilir. Visual Studio'nun ve Web Deploy'un (msdeploy) yaptığı da tam olarak bu: Web Deploy ile yayınladığında araç app_offline.htm'i kendisi koyup kaldırıyor. Web Deploy'un artısı fark bazlı senkronizasyon ve uzaktan yayın; eksisi sunucuda ayrı bir servis ve kimlik bilgisi yönetimi. CI (Continuous Integration) ve CD (Continuous Delivery) pipeline'larında çoğu ekip artık artefact'ı sunucuya kopyalayıp yukarıdaki gibi bir script çalıştırıyor; pipeline tarafı ilerideki CI/CD Temelleri ve GitHub Actions serilerinde.

Kilitli Dosyalar ve Shadow Copy

app_offline.htm kilit sorununu uygulamayı kapatarak çözüyor; bedeli, kopyalama süresi boyunca kesinti. ANCM'nin shadow copy özelliği bunu kısaltıyor: uygulamanın assembly'leri site klasöründen değil, başka bir klasördeki kopyalarından yükleniyor, site klasöründeki dosyalar kilitlenmiyor ve uygulama çalışırken üzerlerine yazılabiliyor. Özellik .NET 6'da deneysel olarak experimentalEnableShadowCopy adıyla geldi, .NET 7'den beri enableShadowCopy adıyla destekleniyor. Microsoft Learn'deki "Advanced configuration" sayfasına göre web.config'te handler ayarları olarak veriliyor.

Bu ayarları sunucudaki web.config'e elle yazmak bir sonraki publish'te kaybolmaları demek, çünkü web.config'i publish üretiyor. Doğru yer proje klasörü: projede bir web.config varsa SDK onu şablon olarak alıyor ve eksik kısımları dolduruyor. Bookstore.Api'nin proje klasörüne şunu koydum:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <location path="." inheritInChildApplications="false">
    <system.webServer>
      <handlers>
        <add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
      </handlers>
      <aspNetCore processPath="%LAUNCHER_PATH%" arguments="%LAUNCHER_ARGS%" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout">
        <handlerSettings>
          <handlerSetting name="enableShadowCopy" value="true" />
          <handlerSetting name="shadowCopyDirectory" value="../ShadowCopy/bookstore-api/" />
          <handlerSetting name="cleanShadowCopyDirectory" value="true" />
        </handlerSettings>
      </aspNetCore>
    </system.webServer>
  </location>
</configuration>

Publish çıktısındaki dosya:

$ dotnet publish -c Release -o out/pub-iis
$ cat out/pub-iis/web.config
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <location path="." inheritInChildApplications="false">
    <system.webServer>
      <handlers>
        <add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
      </handlers>
      <aspNetCore processPath="dotnet" arguments=".\Bookstore.Api.dll" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="inprocess">
        <handlerSettings>
          <handlerSetting name="enableShadowCopy" value="true" />
          <handlerSetting name="shadowCopyDirectory" value="../ShadowCopy/bookstore-api/" />
          <handlerSetting name="cleanShadowCopyDirectory" value="true" />
        </handlerSettings>
      </aspNetCore>
    </system.webServer>
  </location>
</configuration>

SDK, %LAUNCHER_PATH% ve %LAUNCHER_ARGS% yer tutucularını gerçek değerlerle değiştirdi, hostingModel'i ekledi ve handler ayarlarına dokunmadı. Shadow copy klasörüne app pool kimliğinin okuma, yazma ve silme izni olmalı ve her uygulamanın kendi klasörü olmalı; yukarıdaki yolu bu yüzden uygulama adıyla bitirdim. Dokümanın uyarısı net: shadow copy kesintisiz deploy için değil. IIS uygulamayı yine yeniden başlatıyor ve bazı istekler 503 alabiliyor; kesintiyi azaltıyor, sıfırlamıyor. Kesintisiz deploy için doküman blue-green deployment ya da Azure deployment slot'larını öneriyor; ilerideki Deployment Stratejileri serisinin konusu.

Recycle ve İlk İstek: Overlapped Recycle, Application Initialization, Idle Timeout

IIS, app pool'ları belirli koşullarda (varsayılan olarak periyodik bir süre dolduğunda, config değiştiğinde, bellek limiti aşıldığında) recycle ediyor: yeni bir worker process başlatıyor ve eskisini kapatıyor. Overlapped recycle'da yeni process istekleri almaya başlamadan eski kapanmıyor. Microsoft'un dokümanı burada da temkinli: overlapped recycle yardımcı oluyor ama kesintisiz deploy'u garanti etmiyor. ANCM, IIS recycle'ı bildirdikten sonra uygulamayı kapatmadan önce varsayılan olarak bir saniye bekliyor; yavaş makinelerde 503 olasılığını azaltmak için bu gecikme shutdownDelay handler ayarıyla (milisaniye) artırılabiliyor:

<aspNetCore processPath="dotnet" arguments=".\Bookstore.Api.dll" hostingModel="inprocess">
  <handlerSettings>
    <handlerSetting name="shutdownDelay" value="5000" />
  </handlerSettings>
</aspNetCore>

Recycle'ın uygulama için anlamı, process'in yeniden başlaması. IMemoryCache'teki her şey, in-memory session'lar ve process içinde tutulan sayaçlar gidiyor; Data Protection key'leri kalıcı bir yerde değilse kullanıcıların oturumu da düşüyor. Kalıcı olması gereken durumu process'in dışına taşı: dağıtık cache, veritabanı, kalıcı key ring.

Recycle ya da sunucu yeniden başladıktan sonra uygulama ilk istek gelene kadar başlamıyor; ilk kullanıcı JIT (Just-In-Time) derlemesini, DI (Dependency Injection) container'ının kurulmasını ve veritabanı bağlantı havuzunun ısınmasını bekliyor. IIS'in Application Initialization özelliği, app pool başlar başlamaz uygulamaya bir istek göndererek bunu önlüyor. Dokümana göre üç adım: IIS'te Application Initialization rol servisini etkinleştir, app pool'un Start Mode'unu AlwaysRunning, sitenin Preload Enabled ayarını True yap ya da web.config'e şu bloğu ekle:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <location path="." inheritInChildApplications="false">
    <system.webServer>
      <applicationInitialization doAppInitAfterRestart="true" />
    </system.webServer>
  </location>
</configuration>

IIS ilk isteği varsayılan olarak sitenin köküne (/) gönderiyor. Bookstore.Api'nin kökünde bir endpoint yok; isteği applicationInitialization içinde bir add initializationPage="/health" ile health endpoint'ine yönlendirmek, ısınmanın bir parçası olarak veritabanı bağlantısını da açıyor.

İkinci bekleme kaynağı idle timeout: app pool varsayılan olarak 20 dakika istek gelmezse kapanıyor. Gece trafiği olmayan bir iç uygulamada sabahki ilk kullanıcı her gün soğuk başlangıcı yaşıyor. In-process'te çözüm app pool'un "Idle Time-out (minutes)" değerini 0 yapmak. Out-of-process'te doküman, uygulamayı dışarıdan düzenli aralıklarla çağırmayı ya da uygulama yalnız background service barındırıyorsa onu IIS yerine Windows Service olarak çalıştırmayı öneriyor.

IIS'te Data Protection

Üçüncü yazıdaki Data Protection sorunu IIS'te de var ve recycle yüzünden daha sık görünüyor: key ring bellekte tutuluyorsa her recycle'da kullanıcılar oturumdan düşüyor ve antiforgery token'ları geçersiz oluyor. Microsoft'un dokümanı dört yol sayıyor: app pool için registry key'leri oluşturmak (tek sunucuda bunun için Provision-AutoGenKeys.ps1 script'i var), app pool'un "Load User Profile" ayarını True yapmak (key'ler %LOCALAPPDATA%\ASP.NET\DataProtection-Keys'te, DPAPI (Windows'un Data Protection application programming interface'i) ile korunarak saklanıyor), kodda dosya sistemini key ring deposu olarak verip X.509 certificate ile korumak ya da makine geneli bir policy tanımlamak.

Web farm'da, yani birden çok IIS sunucusunda, key ring'in bütün sunucuların erişebildiği bir paylaşımda (UNC (Universal Naming Convention) yolu, Redis, veritabanı) olması ve certificate ile korunması gerekiyor. Kod tarafı üçüncü yazıdaki PersistKeysToFileSystem ve SetApplicationName ile aynı; IIS'e özgü olan yalnız kimlik ve izinler.

Sub-Application: API'yi /api Altına Koymak

Bookstore'u IIS'te tek bir site altında yayınlamak isteyebilirsin: kökte Angular, /api'de ASP.NET Core. Dokümana göre IIS virtual directory'leri ASP.NET Core ile desteklenmiyor; doğru araç sub-application. API (Application Programming Interface) klasörünü site klasörünün altına koyuyorsun, IIS Manager'da klasöre sağ tıklayıp "Convert to Application" diyorsun ve ona kendi app pool'unu veriyorsun. In-process modelde sub-app için ayrı app pool zorunlu.

Burada dokuzuncu yazıda geçen bir satır anlam kazanıyor: publish'in ürettiği web.config, ayarları <location path="." inheritInChildApplications="false"> içine koyuyor. Kökteki uygulama da ASP.NET Core ise bu sayede onun aspNetCore handler'ı sub-app'e miras kalmıyor. Miras kalırsa iki handlers tanımı çakışıyor ve sub-app 500.19 veriyor; Microsoft'un hata referansı bu durumu ayrıca listeliyor. Sub-app'in path'i, uygulamanın PathBase'i oluyor; ANCM bunu uygulamaya kendisi bildirdiği için route'lar /api önekini görmüyor, link'ler ise önekiyle üretiliyor. Üçüncü yazıdaki X-Forwarded-Prefix ile Nginx'te elle kurduğumuz yapının IIS karşılığı bu.

Angular'ı IIS'te Yayınlamak: URL Rewrite ile SPA Fallback

Angular'ın statik dosyalarını IIS doğrudan sunabiliyor, ama altıncı yazıdaki SPA (Single Page Application) fallback'i, yani bilinmeyen path'lere index.html dönmek, IIS'in kendisinde yok. Bunun için ayrı kurulan IIS URL Rewrite modülü gerekiyor. Kural Angular projesinin public klasöründe bir web.config olarak duruyor:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <system.webServer>
    <rewrite>
      <rules>
        <rule name="Angular SPA fallback" stopProcessing="true">
          <match url=".*" />
          <conditions logicalGrouping="MatchAll">
            <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
            <add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
            <add input="{REQUEST_URI}" pattern="^/api/" negate="true" />
          </conditions>
          <action type="Rewrite" url="/index.html" />
        </rule>
      </rules>
    </rewrite>
  </system.webServer>
  <location path="index.html">
    <system.webServer>
      <staticContent>
        <clientCache cacheControlMode="DisableCache" />
      </staticContent>
    </system.webServer>
  </location>
</configuration>

Kural, Nginx'teki try_files $uri $uri/ /index.html'in karşılığı: istek bir dosyaya ya da klasöre denk gelmiyorsa ve /api/ ile başlamıyorsa index.html'e rewrite et. /api/ koşulu, sub-application olarak duran API'ye giden isteklerin SPA fallback'ine düşmemesi için. location path="index.html" bloğu altıncı yazıdaki "index.html cache'lenmesin" kuralını IIS'e taşıyor. public klasöründeki dosyalar build çıktısına olduğu gibi kopyalanıyor:

$ npx ng build
Output location: /work/bookstore-web/dist/bookstore-web
$ ls dist/bookstore-web/browser | grep -E "web.config|index.html"
index.html
web.config

Bu kuralın IIS'te çalıştığını bu yazı için denemedim. Altıncı yazıdaki tuzak burada da geçerli: kural eksik bir hash'li JavaScript dosyası için de index.html döner. Hash'li dosyalar için ayrı bir kural yazmak ya da eski build'in dosyalarını bir sürüm boyunca sunucuda bırakmak gerekiyor.

IIS Yoksa: Kestrel'i Windows Service Olarak Çalıştırmak

Windows sunucuda IIS istemiyorsan (önde zaten bir load balancer var, uygulama yalnız iç ağda, IIS yönetmek ekibin işi değil) Kestrel'i Windows Service olarak çalıştırabilirsin. Sekizinci yazıdaki AddSystemd()'nin Windows karşılığı:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// process Windows Service olarak çalışmıyorsa hiçbir şey yapmaz
builder.Services.AddWindowsService(options => options.ServiceName = "BookstoreApi");

WebApplication app = builder.Build();
app.MapGet("/api/books/1", () => new { Id = 1, Title = "Dune" });
app.Run();
$ dotnet build
    0 Warning(s)
    0 Error(s)
$ dotnet bin/Debug/net10.0/WinSvc.dll --urls http://127.0.0.1:18590 > run.log 2>&1 &
$ grep -E "listening|started" run.log
      Now listening on: http://127.0.0.1:18590
      Application started. Press Ctrl+C to shut down.
$ curl -s http://127.0.0.1:18590/api/books/1
{"id":1,"title":"Dune"}

Kod Mac'te değişmeden çalıştı; AddWindowsService process Windows Service olarak başlatılmadığında devreye girmiyor. Windows Service olarak çalıştığında content root'u AppContext.BaseDirectory'ye ayarlıyor. Bu önemli, çünkü dokümana göre bir Windows Service'in çalışma klasörü C:\WINDOWS\system32; Directory.GetCurrentDirectory() ile dosya arayan kod orada arıyor. Service'i kaydetmek için dokümandaki PowerShell komutu (bu Mac'te çalıştırılmadı):

New-Service -Name BookstoreApi -BinaryPathName "C:\services\bookstore-api\Bookstore.Api.exe --contentRoot C:\services\bookstore-api" -Credential "BOOKSTORE\svc-bookstore" -Description "Bookstore API" -DisplayName "Bookstore API" -StartupType Automatic

Bu modelde TLS (Transport Layer Security) Kestrel'de bitiyor ya da önündeki load balancer'da; ikinci yazıdaki certificate ve endpoint ayarları aynen geçerli. Windows'ta Kestrel'in yerine Windows çekirdeğindeki HTTP (Hypertext Transfer Protocol) sürücüsünü kullanan HTTP.sys sunucusunu kullanmak da bir seçenek (port paylaşımı ve Windows authentication için), ama o ayrı bir konu. Background job barındıran Worker Service'lerin Windows Service olarak çalıştırılması Background Job'lar ve Scheduled Job'lar serisinin "Worker Service ve In-Process Job Queue" yazısında.

Üretimde Dikkat

Deploy script'ini tek bir yerde tut ve her deploy'da aynısını çalıştır: app_offline.htm, kopyalama, health check, app_offline.htm'i kaldırma. Elle yapılan deploy, yarım kalan deploy'un en yaygın sebebi.

Recycle ayarlarını bilinçli seç. Varsayılan periyodik recycle, iş saatinin ortasında oturumları düşürebiliyor; gerekmiyorsa kapat ya da gece bir saate sabitle. Bellek limitine göre recycle bir bellek sızıntısını gizler; sızıntıyı bul, limiti bir güvenlik ağı olarak bırak.

Sunucudaki web.config'e elle dokunma. Her ayar projedeki web.config'te ya da proje dosyasında (AspNetCoreHostingModel, EnvironmentName) dursun; publish'in ürettiği dosya sunucuda tek doğru kaynak olsun.

Sık Düşülen Tuzaklar

Çalışan uygulamanın üzerine dosya kopyalamak. DLL'ler kilitli, kopyalama yarıda kalıyor, web.config değiştiyse uygulama karışık dosyalarla yeniden başlıyor.

Sunucudaki web.config'i elle düzenlemek. Shadow copy, stdout log ya da ortam değişkeni ayarı bir sonraki publish'te sessizce kayboluyor.

App pool recycle'ının in-memory cache ve session'ı sildiğini unutmak. Sabah 03:00'te değil, Salı öğlen 14:17'de recycle olan bir pool, kullanıcıların sepetini boşaltıyor.

Virtual directory ile ASP.NET Core yayınlamaya çalışmak. Desteklenmiyor; sub-application kullan ve ona kendi app pool'unu ver.

Angular için URL Rewrite modülünü kurmamak. Kural web.config'te dursa bile modül yoksa IIS onu tanımıyor; sonuç derin linklerde 404 ya da bilinmeyen config bölümü yüzünden 500.19.

Küçük Bir Deneme

★☆☆ Kendi projene shadow copy ayarlarıyla bir web.config ekle, publish al ve çıktıdaki dosyayı projedekiyle karşılaştır. SDK neleri değiştirdi, neye dokunmadı? AspNetCoreHostingModel'i OutOfProcess yapınca handler ayarları korunuyor mu?

★★☆ Yukarıdaki deploy script'ine bir health check adımı ekle: app_offline.htm kaldırıldıktan sonra /health'i 30 saniye boyunca dene, başarısız olursa önceki sürümün yedeğini geri kopyala ve hatayla çık. Sekizinci yazıdaki releases/ ve current fikrini IIS'te sitenin fiziksel yolunu değiştirerek nasıl uygularsın?

★★★ Bir Windows makinende Bookstore'u tek sitede kur: kökte Angular (URL Rewrite kuralıyla), /api'de ayrı app pool'lu bir sub-application. /api/whoami'nin döndürdüğü pathBase ve link değerlerine bak. Sonra API'nin web.config'inden inheritInChildApplications="false"'u kaldırıp kökteki uygulamayı ASP.NET Core yaptığında ne oluyor?

Linux ve Windows'ta uygulamayı ayağa kaldırmanın bütün parçalarını gördük. Bir sonraki yazıda geri çekilip seçim yapıyoruz: Linux VM (virtual machine) ve Nginx, Windows ve IIS, Caddy, Traefik, YARP (Yet Another Reverse Proxy), container ve PaaS (Platform as a Service) arasında nasıl karar verilir ve hangisini seçersen seç production'a çıkmadan önce kontrol edilecek liste.