AppHost ile Orchestration: Resource'lar, WithReference, WaitFor, Parameter'lar ve Kalıcı Container'lar
AppHost'ta her parça bir resource: project, container ya da executable; WithReference connection string'i ve connection property'lerini environment variable olarak veriyor, WaitFor sağlıklı olmayı, WaitForCompletion bir işin 0 koduyla bitmesini bekliyor. Yazı Bookstore'a SQL Server, Redis ve migration worker'ı ekliyor; exception fırlatan worker'ın 0 koduyla çıkıp API'yi başlattığı tuzağı, secret parameter'ları ve kalıcı container'ları gerçek çıktılarla gösteriyor.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6, Entity Framework Core 10 ve Angular 22 ile derlenip denenmiştir; container'lar SQL Server 2025, Redis 8.6 ve Mailpit 1.33.
Önce Tahmin Et
Bu yazıda veritabanı migration'larını ayrı bir worker process'inde çalıştırıyoruz ve API'yi WaitForCompletion(migrations) ile o process'in bitmesine bağlıyoruz. Worker'ın ExecuteAsync method'u yakalanmamış bir exception fırlatırsa ne olur? Worker durur, buna şüphe yok. Peki API başlar mı? Aspire, worker'ın başarısız olduğunu neye bakarak anlıyor? Cevap, Aspire'ın dokümanındaki migration örneğini kopyalayan pek çok projede gizli kalan bir hata.
Bookstore'un Yeni AppHost'u
01'de sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'sini ve Angular uygulamasını Aspire'a bağladık; kitaplar bellekteydi. Bu yazıda kitaplar SQL Server'a, liste cevapları Redis'teki output cache'e taşınıyor ve şema bir migration worker'ı ile kuruluyor. Önce bütün modeli görelim, sonra satır satır açalım:
IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);
IResourceBuilder<ParameterResource> sqlPassword = builder.AddParameter("sql-password", secret: true);
IResourceBuilder<SqlServerServerResource> sql = builder.AddSqlServer("sql", sqlPassword)
.WithImageTag("2025-latest")
.WithContainerName("aspire-sql")
.WithDataVolume("aspire-sql-data")
.WithLifetime(ContainerLifetime.Persistent);
IResourceBuilder<SqlServerDatabaseResource> bookstoreDb = sql.AddDatabase("bookstoredb", "BookstoreDb");
IResourceBuilder<RedisResource> redis = builder.AddRedis("redis")
.WithContainerName("aspire-redis");
IResourceBuilder<ProjectResource> migrations = builder.AddProject<Projects.Bookstore_MigrationService>("migrations")
.WithReference(bookstoreDb)
.WaitFor(bookstoreDb);
IResourceBuilder<ProjectResource> catalog = builder.AddProject<Projects.Bookstore_Api>("catalog")
.WithReference(bookstoreDb)
.WithReference(redis)
.WaitForCompletion(migrations)
.WaitFor(redis)
.WithHttpHealthCheck("/health");
// Sipariş e-postalarını lokalde yakalar; yalnız gerektiğinde dashboard'dan başlatılır
builder.AddContainer("mailpit", "axllent/mailpit", "v1.33")
.WithContainerName("aspire-mailpit")
.WithHttpEndpoint(targetPort: 8025, name: "http")
.WithEndpoint(targetPort: 1025, name: "smtp")
.WithExplicitStart();
builder.AddJavaScriptApp("web", "../../web/bookstore-web", "start")
.WithHttpEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
.WithReference(catalog)
.WaitFor(catalog);
builder.Build().Run();
AppHost'a iki hosting integration paketi eklendi: Aspire.Hosting.SqlServer ve Aspire.Hosting.Redis. Service tarafında ise client integration'lar var: API ve worker Aspire.Microsoft.EntityFrameworkCore.SqlServer, API ayrıca Aspire.StackExchange.Redis.OutputCaching kullanıyor. Client integration'ların ne eklediği 05'in konusu; burada yalnız AppHost tarafına bakıyoruz.
Resource Türleri: Project, Container ve Executable
Modelde dokuz resource var ama hepsi üç temel türden birine giriyor; aspire describe'ın Type sütunu da bunu gösteriyor.
Project: AddProject<T> ile eklenen .NET projeleri. Aspire bunları dotnet run --project ... --no-build ile başlatıyor, launch profile'daki port'u proxy'ye veriyor, process'e environment variable'larla ayar geçiyor. catalog ve migrations bu türde.
Container: Docker (ya da Podman) ile çalışan image'lar. AddSqlServer ve AddRedis aslında container resource'u üreten hazır method'lar; image'ı, port'u, parolayı, health check'i ve connection string'in biçimini integration biliyor. Integration'ı olmayan bir image için AddContainer(name, image, tag) var. Mailpit'i bu şekilde ekledik. Mailpit, gönderilen e-postaları yakalayan lokal bir SMTP (e-posta gönderme protokolü) sunucusu ve web arayüzü; image'ı, tag'i ve iki endpoint'ini (8025 arayüz, 1025 SMTP) kendimiz tarif ettik. WithExplicitStart() onu AppHost'la birlikte başlatmıyor; dashboard'dan ya da aspire resource mailpit start ile istediğin anda başlatıyorsun. Her gün ihtiyaç duymadığın bir container'ın bellek harcamaması için iyi bir seçenek.
Executable: diskteki herhangi bir programı çalıştıran resource. AddExecutable(name, command, workingDirectory, args) genel biçimi; AddJavaScriptApp da arka planda bir executable resource üretiyor (komut npm, argümanlar run start). 01'de gördüğümüz web-installer da bir executable: npm install'ı çalıştırıp bitiyor.
sql-password ve bookstoredb ise process değil. Parameter bir değer taşıyor; database, SQL Server container'ının alt resource'u ve kendine ait bir connection string'i var. Dashboard'da bookstoredb, sql'in altında girintili görünüyor.
Endpoint'ler ve Sabit Port Gereken Durumlar
Varsayılanda Aspire her endpoint'e rastgele bir host port'u veriyor. Redis bir çalıştırmada localhost:60601'deydi, bir sonrakinde localhost:62075'te. Service'ler bu adresi environment variable'dan okuduğu için port'un değişmesi bir şeyi bozmuyor. Sabit port'un gerçekten gerektiği yerler az: dışarıdaki bir aracın kayıtlı adresi (SQL Server Management Studio'daki bir bağlantı için WithHostPort(14330)), bir identity provider'a kaydedilen redirect URI'si (Angular'a WithHttpEndpoint(port: 4200, env: "PORT")) ya da port'a bağlı cookie'ler. Persistent container'larda Aspire, atadığı port'u user secrets'a (Resources:sql:tcp:port) yazıp bir sonraki açılışta aynı port'u kullanıyor; SQL Server bu yüzden iki çalıştırmada da 29586'daydı.
WithExternalHttpEndpoints() ise lokal çalışmayı değiştirmiyor. Deploy sırasında bu endpoint'in dışarıya açık olacağını, diğerlerinin yalnız iç ağda kalacağını söylüyor; 07'de Container Apps ve Kubernetes çıktılarında karşılığını göreceğiz.
WithReference: Service'e Ne Gidiyor?
WithReference(bookstoreDb) ve WithReference(redis) satırlarının catalog'da ürettiği değişkenler (aspire describe catalog --format Json, parolaları gizledik):
ConnectionStrings__bookstoredb = Server=127.0.0.1,29586;User ID=sa;Password=<password>;TrustServerCertificate=true;Initial Catalog=BookstoreDb
BOOKSTOREDB_HOST = localhost
BOOKSTOREDB_PORT = 29586
BOOKSTOREDB_USERNAME = sa
BOOKSTOREDB_PASSWORD = <password>
BOOKSTOREDB_DATABASENAME = BookstoreDb
BOOKSTOREDB_URI = mssql://sa:<password>@localhost:29586/BookstoreDb
BOOKSTOREDB_JDBCCONNECTIONSTRING = jdbc:sqlserver://localhost:29586;databaseName=BookstoreDb;trustServerCertificate=true
ConnectionStrings__redis = localhost:62075,password=<password>
REDIS_HOST = localhost
REDIS_PORT = 62075
REDIS_PASSWORD = <password>
REDIS_URI = redis://:<password>@localhost:62075
İlk satır .NET'in beklediği biçim: builder.AddSqlServerDbContext<BookstoreContext>("bookstoredb") çağrısındaki ad, ConnectionStrings:bookstoredb'yi okuyor. Geri kalanlar 13.0 ile gelen connection property'leri: host, port, kullanıcı ve parola ayrı ayrı, ayrıca URI ve JDBC (Java uygulamalarının veritabanı bağlantı standardı) biçimleri. .NET dışındaki bir service'in (Python, Node.js, Java) connection string'i parse etmesine gerek kalmıyor. Değişken adlarının ön eki resource adından geliyor (bookstoredb → BOOKSTOREDB_). Database'in kendisinin adı ise AddDatabase'in ikinci parametresi: resource adı bookstoredb, SQL Server'daki database BookstoreDb.
Migration worker'a yalnız bookstoredb referansı verdik, Redis'i görmüyor. Bu kasıtlı: WithReference satırları, hangi parçanın hangisine erişebileceğini gösteren bir bağımlılık listesi.
WaitFor, WaitForCompletion ve Bir Exit Code Tuzağı
WaitFor(x), x'in "Running" durumuna geçmesini ve health check'i varsa "Healthy" olmasını bekliyor. SQL Server container'ı birkaç saniyede "Running" oluyor ama bağlantı kabul etmesi daha uzun sürüyor; integration'ın health check'i bu farkı kapatıyor. WaitForCompletion(x) ise x'in bitmesini ve 0 koduyla çıkmasını bekliyor; kısa ömürlü işler için doğru method bu. Beklemenin izi resource'un console log'unda kalıyor:
[migrations] Waiting for resource 'sql' to enter the 'Running' state.
[migrations] Waiting for resource 'bookstoredb' to enter the 'Running' state.
[migrations] Waiting for resource 'sql' to become healthy.
[migrations] Waiting for resource 'bookstoredb' to become healthy.
[migrations] Finished waiting for resource 'sql'.
[migrations] Finished waiting for resource 'bookstoredb'.
İlk açılışta (boş volume) aspire describe --format Json'dan okuduğumuz başlama saatleri sırayı net gösteriyor: SQL Server container'ı 15:19:22.03'te başladı, worker 17 saniye sonra, SQL Server sağlıklı olunca 15:19:39.08'de başladı ve 15:19:40.82'de bitti; catalog 15:19:41.34'te, web de catalog "Healthy" olduktan sonra 15:19:43.06'da başladı. Sıra doğru. Şimdi worker'ın koduna bakalım. Aspire'ın Entity Framework Core (EF Core) migration örneğinin yapısını izleyen ilk hâli şöyleydi:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using Activity? activity = activitySource.StartActivity("Migrate BookstoreDb");
await using AsyncServiceScope scope = services.CreateAsyncScope();
BookstoreContext db = scope.ServiceProvider.GetRequiredService<BookstoreContext>();
string[] pending = [.. await db.Database.GetPendingMigrationsAsync(stoppingToken)];
await db.Database.MigrateAsync(stoppingToken);
logger.LogInformation("Applied {Count} migration(s)", pending.Length);
int added = await SampleBooks.SeedAsync(db, stoppingToken);
logger.LogInformation("Seeded {Added} book(s)", added);
lifetime.StopApplication();
}
Önce Tahmin Et'teki soruyu denemek için seed adımının önüne geçici bir throw new InvalidOperationException("Simulated seed failure") koyduk. Worker'ın log'u beklendiği gibi:
[migrations] fail: Microsoft.Extensions.Hosting.Internal.Host[9]
[migrations] BackgroundService failed
[migrations] System.InvalidOperationException: Simulated seed failure
[migrations] The HostOptions.BackgroundServiceExceptionBehavior is configured to StopHost. A BackgroundService has thrown an unhandled exception, and the
IHost instance is stopping.
Ama aspire describe'da catalog "Running" ve "Healthy" idi. Worker'ı Aspire dışında aynı connection string'le çalıştırıp exit code'a baktık:
dotnet run --project src/Bookstore.MigrationService --no-build
echo $?
0
Sebep .NET Generic Host'un davranışı: BackgroundService'teki yakalanmamış exception host'u durduruyor, ama Program.cs'teki builder.Build().Run() normal şekilde dönüyor ve process 0 koduyla çıkıyor. WaitForCompletion yalnız exit code'a baktığı için migration "başarılı" sayıldı ve API yarım kalmış bir veritabanına karşı açıldı. Düzeltme, hatayı yakalayıp exit code'u kendimiz belirlemek:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using Activity? activity = activitySource.StartActivity("Migrate BookstoreDb");
try
{
await using AsyncServiceScope scope = services.CreateAsyncScope();
BookstoreContext db = scope.ServiceProvider.GetRequiredService<BookstoreContext>();
string[] pending = [.. await db.Database.GetPendingMigrationsAsync(stoppingToken)];
await db.Database.MigrateAsync(stoppingToken);
logger.LogInformation("Applied {Count} migration(s)", pending.Length);
int added = await SampleBooks.SeedAsync(db, stoppingToken);
logger.LogInformation("Seeded {Added} book(s)", added);
}
catch (Exception ex)
{
// BackgroundService'te yakalanmamış exception process'i yine de 0 koduyla bitirir
activity?.AddException(ex);
logger.LogError(ex, "Database migration failed");
Environment.ExitCode = 1;
}
finally
{
lifetime.StopApplication();
}
}
Aynı simüle hatayla tekrar çalıştırdığımızda process 1 koduyla çıktı ve catalog hiç başlamadı:
[catalog] Waiting for resource 'migrations' to complete.
[catalog] Resource 'migrations' has entered the 'Finished' state with exit code '1' expected '0'.
[catalog] Failed to create resource catalog
aspire wait catalog --timeout 15
Waiting for resource 'catalog' to be healthy...
❌ Resource 'catalog' entered a failed state: Stopped waiting for resource 'catalog' to become healthy because it failed to start.
echo $?
18
Dashboard'da catalog "FailedToStart", web ise catalog'u beklediği için "Waiting" durumunda kaldı. aspire wait'in 18 koduyla çıkması script'lerde ve CI'da işe yarıyor: ortamın gerçekten ayağa kalkıp kalkmadığını tek komutla kontrol edebiliyorsun. Bu tuzak Aspire'a özgü değil; Kubernetes'te bir init container ya da Job olarak çalışan migration'da da exit code 0 ise işlem başarılı sayılır.
Parameter'lar ve Secret'lar
AddParameter("sql-password", secret: true) değeri koddan değil, configuration'dan gelen bir parameter tanımlıyor. Aspire değeri Parameters:sql-password anahtarında arıyor: AppHost'un user secrets'ı, appsettings.json ya da Parameters__sql-password environment variable'ı. secret: true değerin dashboard'da ve aspire describe'da maskelenmesini sağlıyor. İlk çalıştırmada değeri hiçbir yere yazmamıştık:
aspire describe
Name Type State Health
bookstoredb SqlServerDatabaseResource Unknown -
catalog Project Waiting -
migrations Project Waiting -
redis Container Running Healthy
sql Container Unknown -
sql-password Parameter ValueMissing -
web Executable Waiting -
AppHost hata verip kapanmadı. Parameter'a bağlı olmayan Redis çalışmaya başladı, SQL Server'a bağlı her şey bekledi. Dashboard'un üstünde "Unresolved parameters" uyarısı ve bir "Enter values" düğmesi çıktı; düğme sql-password için bir giriş alanı ve "Save to user secrets" seçeneği olan bir pencere açıyor. Değeri girince SQL Server container'ı başladı ve zincir yukarıdaki sırayla ilerledi. Aynı işi CLI'dan yapmak için:
aspire secret set Parameters:sql-password "<password>"
aspire secret list
Key Value
AppHost:DashboardApiKey ...
AppHost:OtlpApiKey ...
Parameters:redis-password ...
Parameters:sql-password ...
Resources:sql:tcp:port 29586
Listede bizim yazmadığımız anahtarlar da var. Redis'e parola vermedik; integration bir tane üretip Parameters:redis-password'a kaydetti. SQL Server'a parameter vermeseydik onun için de aynısını yapacaktı. Parolayı bizim belirlememizin sebebi, persistent container'ın volume'unda kalacak admin parolasını bilinçli olarak yönetmek. User secrets dosyası repo'da değil, kullanıcı profilinde (Windows'ta %APPDATA%\Microsoft\UserSecrets\<UserSecretsId>\secrets.json) duruyor; her geliştiricinin parolası farklı oluyor ve hiçbiri commit'lenmiyor. Secret yönetiminin genel stratejisi Configuration ve Secret Management serisinde.
Kalıcı Container'lar, Volume'lar ve Temizlik
Container'ların varsayılan ömrü AppHost'unkiyle aynı (session): aspire stop deyince container siliniyor. SQL Server'ın her açılışta sıfırdan kurulması, ilk açılışta 17 saniye sürüyordu. WithLifetime(ContainerLifetime.Persistent) container'ı AppHost'tan bağımsız yaşatıyor, WithDataVolume("aspire-sql-data") veriyi named volume'a koyuyor. AppHost'u durdurduktan sonra:
docker ps --filter name=aspire- --format "{{.Names}} {{.Status}}"
aspire-sql Up 4 minutes
Redis gitti, SQL Server kaldı. İkinci açılışı arka planda başlatıp bütün resource'ların hazır olmasını bekledik:
aspire run --detach -lp http
✅ AppHost started successfully.
aspire wait web --timeout 120
✅ Resource 'web' is healthy. (3.7s)
İki komutun toplamı 9 saniye sürdü. Worker bu kez Applied 0 migration(s) ve Seeded 0 book(s) yazdı: şema ve kitaplar zaten volume'da. --detach (ya da kısa yolu aspire start) terminali serbest bırakıyor; çalışan AppHost'ları aspire ps, resource'ları aspire describe, log'ları aspire logs catalog gösteriyor, tek bir resource'u aspire resource redis stop ve start ile durdurup başlatabiliyorsun. Redis'i bu şekilde durdurduğumuzda catalog'un /health'i 503 döndü, /api/books ise 11,5 saniye bekleyip 200 döndü; client integration'ların hata davranışını 05'te inceleyeceğiz.
Persistent container'ların bir bedeli var: unutulursa bilgisayar kapanana kadar bellek tüketiyorlar. Hepsini volume'larıyla birlikte silmek için 13.6'da gelen seçenek:
aspire stop --force --volumes
✅ src/Bookstore.AppHost/Bookstore.AppHost.csproj stopped successfully.
⚙️ Cleaning up persistent resources for Bookstore.AppHost.csproj...
✅ Persistent resources for Bookstore.AppHost.csproj cleaned up.
Ardından docker ps -a ve docker volume ls bu AppHost'a ait hiçbir şey göstermedi. --force yalnız başına container'ları siliyor, volume'ları bırakıyor; --volumes yalnız --force ile kullanılabiliyor. User secrets'taki parola ve port kayıtları duruyor, bir sonraki açılışta container aynı parolayla sıfırdan kuruluyor.
TypeScript AppHost'a Kısa Bir Not
AppHost'u TypeScript ile yazmak 13.2'de preview olarak geldi, 1 Haziran 2026'da çıkan 13.4 ile GA (genel kullanıma açık, kararlı sürüm) oldu. Giriş dosyası apphost.mts; aspire init --language typescript var olan bir JavaScript projesine ekleyebiliyor ve method'lar addRedis, withReference gibi camelCase karşılıklarla aynı modeli kuruyor. Ekibin çoğunluğu Angular tarafındaysa düşünülebilir. Bu seride örnekler C#; resource modeli iki dilde aynı olduğu için anlattıklarımız TypeScript AppHost için de geçerli.
Üretimde Dikkat
Migration worker'ın exit code'u, production'daki deploy sırasının da temeli. Aspire'ın WaitForCompletion'ı, Docker Compose'daki service_completed_successfully ve Kubernetes'teki Job'ın tamamlanma koşulu aynı sinyale bakıyor: 0 başarı, diğer her şey hata. Worker'ı bir kez bilerek bozup sonraki adımın başlamadığını görmek, bu sinyalin çalıştığından emin olmanın en kısa yolu.
AppHost'taki parameter'lar deploy'da da parameter. aspire publish, sql-password'ü çıktıda bir yer tutucu olarak bırakıyor ve değeri deploy anında istiyor (07). Lokal user secrets'taki değer production'a gitmiyor; gitmemeli de.
Persistent container'ı production benzeri bir veri saklama yöntemi sanma. Volume, geliştirme verisini AppHost yeniden başlarken korumak için var. Yedek, yükseltme ve parola rotasyonu gibi konular production veritabanının işi.
Sık Düşülen Tuzaklar
Worker'ın exit code'unu kontrol etmemek. Yukarıda gördüğümüz gibi BackgroundService'teki exception process'i 0 koduyla bitirebiliyor. Hata yolunda Environment.ExitCode'u açıkça ayarla.
Kısa ömürlü iş için WaitFor kullanmak. WaitFor(migrations) worker "Running" olur olmaz API'yi başlatır; migration daha bitmemiştir. Bitmesi beklenen iş için WaitForCompletion.
AppHost'a business mantığı koymak. "Açılışta indirimli kitapları hesapla", "admin kullanıcısını oluştur" gibi işler AppHost'ta yazılınca yalnız lokal ortamda çalışır ve production'da kimse onları yapmaz. AppHost yalnız neyin, nasıl bağlanıp çalışacağını tarif etmeli; seed ve migration gibi işler ayrı bir resource'ta, production'da da çalışabilecek kodda durmalı.
Veritabanını session ömründe ve volume'suz bırakmak. Her aspire run boş bir SQL Server demek: veri gidiyor, ilk kurulum süresi her seferinde baştan ödeniyor. Bu bazen istenen bir şey (her çalıştırmada temiz test verisi), ama bilinçli bir seçim olmalı.
Persistent container'ın parolasını değiştirip volume'u unutmak. SQL Server admin parolasını ilk açılışta volume'a yazıyor. Parameter'ı değiştirirsen Aspire yeni parolayla bağlanmaya çalışır ve login hatası alırsın; parola değişikliği aspire stop --force --volumes demek.
Küçük Bir Deneme
★☆☆ Mailpit'i aspire resource mailpit start ile başlat ve aspire describe mailpit'te iki endpoint'in adreslerine bak. SMTP endpoint'inin URL'i neden http:// değil de tcp:// ile başlıyor?
★★☆ catalog'daki WaitForCompletion(migrations)'ı WaitFor(migrations) yap, volume'u silip (aspire stop --force --volumes) yeniden başlat. aspire describe --format Json'dan catalog'un ve worker'ın başlama/bitiş saatlerini karşılaştır; catalog'un ilk /api/books isteği ne dönüyor?
★★★ Worker'a ikinci bir iş ekle: migration'dan sonra kitap sayısını kontrol edip 0 ise hata ver. Environment.ExitCode'u 2 yap ve catalog'un log'undaki exit code '2' expected '0' satırını gör. Aynı worker'ı Kubernetes'te bir Job olarak çalıştırsaydın bu kod nerede görünürdü?
Bookstore'un veri katmanı hazır, ama hâlâ tek bir API var ve Angular adresini CATALOG_HTTP'den okuyor. Bir sonraki yazıda ikinci bir service ekliyoruz: Ordering, Catalog'u https+http://catalog adresiyle çağırıyor. Bu adresin nasıl çözüldüğünü, ServiceDefaults'un içinde neler olduğunu ve YARP (Yet Another Reverse Proxy; Microsoft'un .NET ile yazılmış reverse proxy library'si) ile bir gateway resource'u kurmayı göreceğiz.