Mert Özen Her Satırda Daha İleri
DevOps Cloud Temelleri — #05

Bookstore'u Buluta Almak: App Service'te ASP.NET Core, Container Apps'te Container ve Angular için Static Hosting

Mert Özen 15 Kas 2026 14 dk 7 görüntülenme
Bookstore'u Buluta Almak: App Service'te ASP.NET Core, Container Apps'te Container ve Angular için Static Hosting

Bookstore.Api'yi App Service'e kod, Container Apps'e container olarak almak birkaç CLI komutudur; asıl iş health check, forwarded header ve SPA fallback gibi platforma uyum ayarlarıdır. Plan ve app ayrımını, app setting ve connection string öneklerini, /health ve /health/live endpoint'lerini, X-Forwarded-Proto'nun yanlış Location ürettiği durumu ve testini, revision ile trafik bölmeyi ve Angular için Static Web Apps fallback'ini gerçek çıktılarla anlatıyoruz.

Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.404), Angular 22.2, Static Web Apps CLI 2.1.0, Docker Engine 29, SQL Server 2025 container'ı ve Azure CLI 2.92.0 ile denenmiştir. Azure'a deploy yapılmadı; App Service, Container Apps ve Static Web Apps davranışları Microsoft Learn'den (Kasım 2026), CLI parametreleri yerelde --help çıktısından doğrulandı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'si App Service'te, https://app-bookstore-dev-weu.azurewebsites.net adresinde çalışıyor. Angular uygulaması yeni bir sepet açmak için POST /api/carts çağırıyor; API 201 Created ile birlikte sepetin adresini Location header'ında dönüyor. Bu adres https:// ile mi başlar, http:// ile mi? İkinci soru: kullanıcı tarayıcıda /books/9780451524935 adresini yenilediğinde Static Web Apps 200 mü döner, 404 mü? İkisini de yerelde gerçek çıktılarla gördük.

App Service: Plan, App ve .NET 10

App Service'te iki kaynak var. App Service planı makineyi tanımlar: işletim sistemi, boyut (B1, P0v3 gibi) ve instance sayısı. Web app ise o planın üzerinde çalışan uygulamadır; bir planda birden çok app olabilir ve hepsi aynı instance'ları paylaşır. Bookstore.Api'yi Linux üzerinde, .NET 10 runtime'ıyla, kod olarak deploy eden script:

#!/usr/bin/env bash
# App Service'te Bookstore.Api: Linux plan, .NET 10 runtime, zip dosyasından kod deploy'u.
set -euo pipefail

: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
ENVIRONMENT="${1:?Usage: 05-app-service.sh <dev|staging|prod>}"
SKU="${SKU:-B1}"
RG="rg-bookstore-${ENVIRONMENT}-weu"
PLAN="asp-bookstore-${ENVIRONMENT}-weu"
APP="app-bookstore-${ENVIRONMENT}-weu"
IDENTITY="id-bookstore-${ENVIRONMENT}-weu"
STORAGE="stbookstore${ENVIRONMENT}weu"
SQL_SERVER="sql-bookstore-${ENVIRONMENT}-weu"

az account set --subscription "$AZURE_SUBSCRIPTION_ID"
IDENTITY_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query id --output tsv)
CLIENT_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query clientId --output tsv)

az appservice plan create --name "$PLAN" --resource-group "$RG" --is-linux --sku "$SKU"

az webapp create --name "$APP" --resource-group "$RG" --plan "$PLAN" \
  --runtime "DOTNETCORE:10.0" --assign-identity "$IDENTITY_ID" \
  --https-only true --basic-auth Disabled

az webapp config set --name "$APP" --resource-group "$RG" \
  --always-on true --min-tls-version 1.2 --ftps-state Disabled \
  --generic-configurations '{"healthCheckPath": "/health"}'

# App setting'ler environment variable olur; Linux'ta section ayracı "__"
az webapp config appsettings set --name "$APP" --resource-group "$RG" --settings \
  ASPNETCORE_ENVIRONMENT=Production \
  "Proxy__KnownNetworks=10.0.0.0/8;172.16.0.0/12;192.168.0.0/16" \
  "BlobStorage__ServiceUri=https://${STORAGE}.blob.core.windows.net" \
  "BlobStorage__ManagedIdentityClientId=${CLIENT_ID}"

# Parolasız: identity Azure SQL'e kendisi bağlanır (veritabanı kullanıcısı managed database yazısında açılıyor)
az webapp config connection-string set --name "$APP" --resource-group "$RG" \
  --connection-string-type SQLAzure --settings \
  "BookstoreDb=Server=tcp:${SQL_SERVER}.database.windows.net,1433;Database=BookstoreDb;Authentication=Active Directory Managed Identity;User Id=${CLIENT_ID};Encrypt=True"

dotnet publish src/Bookstore.Api --configuration Release --output publish
(cd publish && zip -qr ../bookstore-api.zip .)
az webapp deploy --name "$APP" --resource-group "$RG" --src-path bookstore-api.zip --type zip

Learn'deki quickstart da --runtime "DOTNETCORE:10.0" biçimini kullanıyor. Script'te dikkat edilecek ayarlar: --https-only true HTTP isteklerini HTTPS'e yönlendirir; --basic-auth Disabled, FTP (dosya aktarım protokolü) ve SCM (Kudu adlı deploy ve yönetim sitesi) için kullanıcı adı ve parolayla deploy'u kapatır, böylece deploy yalnız Entra ID ile yapılabilir; --assign-identity, 3. yazıda oluşturduğumuz user-assigned identity'yi bağlar.

App setting'ler uygulamaya environment variable olarak gelir. Linux'ta section ayracı __: BlobStorage__ServiceUri, appsettings.json'daki BlobStorage:ServiceUri'nin üzerine yazar. Connection string'ler ise ayrı bir bölümde tutulur ve türüne göre bir önekle gelir: SQLAzure türündeki BookstoreDb, uygulamada SQLAZURECONNSTR_BookstoreDb olarak görünür. ASP.NET Core'un environment variable provider'ı bu önekleri tanır ve değeri ConnectionStrings:BookstoreDb'ye koyar. Bunu Azure olmadan da görebilirsin; API'yi ConnectionStrings__BookstoreDb olmadan, yalnız App Service'in vereceği değişkenle başlattık:

$ export ASPNETCORE_ENVIRONMENT=Production
$ export SQLAZURECONNSTR_BookstoreDb="Server=127.0.0.1,1433;Database=BookstoreDb;User Id=sa;Password=<password>;TrustServerCertificate=True"
$ export ASPNETCORE_URLS=http://127.0.0.1:5191
$ dotnet src/Bookstore.Api/bin/Release/net10.0/Bookstore.Api.dll &
$ curl -s -w " %{http_code}\n" http://127.0.0.1:5191/health
Healthy 200

Script'teki connection string'de parola yok: Authentication=Active Directory Managed Identity ve identity'nin client ID'si, SqlClient'a token'ı managed identity'den almasını söyler. Bunun çalışması için veritabanında o identity için bir kullanıcı açılması gerekiyor; bu adım ve bağlantı dayanıklılığı 8. yazıda.

Health Check ve Always On

App Service'in health check özelliği, verdiğin yolu her instance'ta dakikada bir çağırır. Learn'e göre bir instance 10 denemede (ayarla en az 2'ye indirilebilir) 200 ile 299 arasında cevap vermezse load balancer'dan çıkarılır, bir saat boyunca sağlıksız kalırsa yerine yenisi açılır. Bunu doğru kullanabilmek için Bookstore.Api'ye iki endpoint ekledik:

using System.Net;
using Bookstore.Data;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.HttpOverrides;

namespace Bookstore.Hosting;

public static class CloudHostingExtensions
{
    // App Service ve Container Apps TLS'i uygulamanın önünde sonlandırır ve X-Forwarded-* header'larını ekler.
    // Bunlara yalnız Proxy:KnownNetworks'te listelenen ağlardan (noktalı virgülle ayrılmış CIDR aralıkları) gelirse güven.
    public static IServiceCollection AddCloudHosting(this IServiceCollection services, IConfiguration configuration)
    {
        services.Configure<ForwardedHeadersOptions>(options =>
        {
            options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
            options.KnownIPNetworks.Clear();
            options.KnownProxies.Clear();
            options.KnownProxies.Add(IPAddress.Loopback);
            options.KnownProxies.Add(IPAddress.IPv6Loopback);
            foreach (string cidr in (configuration["Proxy:KnownNetworks"] ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries))
            {
                options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(cidr.Trim()));
            }
        });

        services.AddHealthChecks()
            .AddDbContextCheck<BookstoreContext>("database", tags: ["ready"]);
        return services;
    }

    // /health/live: süreç cevap veriyor. /health: API, BookstoreDb'ye de ulaşabiliyor.
    public static WebApplication MapCloudHealthChecks(this WebApplication app)
    {
        app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
        app.MapHealthChecks("/health", new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready") });
        return app;
    }
}

/health/live hiçbir kontrol çalıştırmaz, yalnız sürecin ayakta olduğunu söyler. /health, ready tag'li kontrolleri, yani AddDbContextCheck ile eklenen veritabanı kontrolünü çalıştırır. Program.cs'te AddCloudHosting, app.UseForwardedHeaders() (pipeline'ın en başında) ve app.MapCloudHealthChecks() eklendi. Container'ı bir kez erişilebilir bir veritabanıyla, bir kez de olmayan bir adresle başlatınca:

$ curl -s -w "  -> %{http_code} in %{time_total}s\n" http://localhost:18081/health/live
Healthy  -> 200 in 0.001586s
$ curl -s -w "  -> %{http_code} in %{time_total}s\n" http://localhost:18081/health
Healthy  -> 200 in 0.466396s

# veritabanı erişilemezken
$ curl -s -w "  -> %{http_code} in %{time_total}s\n" http://localhost:18081/health/live
Healthy  -> 200 in 0.001606s
$ curl -s -w "  -> %{http_code} in %{time_total}s\n" http://localhost:18081/health
Unhealthy  -> 503 in 4.831722s

Health check yolu olarak /health'i veriyoruz, çünkü Learn de kritik bağımlılıkların kontrol edilmesini öneriyor: veritabanına ulaşamayan bir instance'ın trafik alması anlamsız. Learn'deki iki kural bu kararın riskini sınırlıyor: aynı anda en fazla instance'ların yarısı (varsayılan yüzde 50) dışarı alınır ve bütün instance'lar sağlıksızsa hiçbiri çıkarılmaz. Yani veritabanı beş dakika düştüğünde App Service bütün instance'ları değiştirmeye kalkmaz. Tahmin sorusunun bu kısmının cevabı hayır. Yine de veritabanı kontrolünün bağlantı zaman aşımı kısa tutulmalı; ölçümdeki 4,8 saniye, bağlantı süresinin dolmasını bekliyor.

Always On, uygulamanın boşta kaldığında bellekten atılmasını engeller; health check'in ve zamanlanmış işlerin düzgün çalışması için açık olmalı. Free katmanda yok.

Forwarded Headers: TLS Platformda Biter

App Service'te HTTPS bağlantısı platformun ön tarafındaki load balancer'da sonlanır; uygulamaya istek düz HTTP olarak gelir, orijinal şema ve istemci IP'si X-Forwarded-Proto ve X-Forwarded-For header'larında taşınır. Container Apps'in ingress'i de aynısını yapar. Uygulama bu header'lara güvenmezse kendini HTTP'de sanır. Tahmin sorusunu bu durumda yerelde canlandırdık: App Service'in göndereceği header'larla container'daki API'ye istek attık, uygulamada forwarded header ayarı yokken:

$ curl -s -i -X POST http://localhost:18081/api/carts \
    -H "Host: app-bookstore-dev-weu.azurewebsites.net" \
    -H "X-Forwarded-Proto: https" -H "X-Forwarded-For: 203.0.113.7" | grep Location
Location: http://app-bookstore-dev-weu.azurewebsites.net/api/carts/01a1db9e-8afb-739b-b001-f62a791434c4

Cevap: http://. Angular uygulaması bu adresi takip ederse --https-only yüzünden bir yönlendirme daha yer; OpenAPI dokümanındaki server adresi, mutlak link üreten her endpoint ve istemci IP'sine bakan rate limiting de aynı hatayı yapar. Çözüm CloudHostingExtensions'taki ForwardedHeadersOptions: header'lara yalnız Proxy:KnownNetworks'te listelenen ağlardan gelirse güveniyoruz. App Service'in Learn'deki önerisi özel IP aralıkları (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16); script bunu Proxy__KnownNetworks app setting'iyle veriyor. Dokümandaki örnek eski KnownNetworks property'sini kullanıyor; .NET 10'da yerine KnownIPNetworks geldi. Kuralın üç durumunu bir testle sabitledik:

using System.Net;
using Bookstore.Hosting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace Bookstore.Api.Tests;

public class ForwardedHeadersTests
{
    [Theory]
    [InlineData("10.0.0.5", "https")]          // App Service'in özel bir adresteki front end'i
    [InlineData("::ffff:10.0.0.5", "https")]   // aynı adres, Kestrel'in IPv4/IPv6 birlikte dinlerken gördüğü biçimde
    [InlineData("203.0.113.9", "http")]        // başka biri: header yok sayılır
    public async Task X_Forwarded_Proto_is_trusted_only_from_known_networks(string remoteIp, string expectedScheme)
    {
        IConfiguration configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?> { ["Proxy:KnownNetworks"] = "10.0.0.0/8;172.16.0.0/12;192.168.0.0/16" })
            .Build();
        ServiceCollection services = new();
        services.AddLogging();
        services.AddCloudHosting(configuration);
        using ServiceProvider provider = services.BuildServiceProvider();

        IApplicationBuilder app = new ApplicationBuilder(provider);
        app.UseForwardedHeaders(provider.GetRequiredService<IOptions<ForwardedHeadersOptions>>().Value);
        app.Run(_ => Task.CompletedTask);
        RequestDelegate pipeline = app.Build();

        DefaultHttpContext context = new();
        context.Connection.RemoteIpAddress = IPAddress.Parse(remoteIp);
        context.Request.Scheme = "http";
        context.Request.Headers["X-Forwarded-Proto"] = "https";
        context.Request.Headers["X-Forwarded-For"] = "198.51.100.20";

        await pipeline(context);

        Assert.Equal(expectedScheme, context.Request.Scheme);
    }
}
$ dotnet test
Test run summary: Passed!
  total: 18
  failed: 0
  succeeded: 18
  skipped: 0
  duration: 1s 971ms

İkinci satır önemli: Kestrel, IPv4 ve IPv6'yı birlikte dinlediğinde istemci adresini ::ffff:10.0.0.5 biçiminde görür ve .NET 10'daki middleware bunu IPv4 aralığıyla eşleştiriyor. Web Server'lar ve Hosting serisinin "Reverse Proxy Arkasında ASP.NET Core: Forwarded Headers, KnownIPNetworks ve Doğru Client IP" yazısı, ASPNETCORE_FORWARDEDHEADERS_ENABLED=true kısayolunun neden herkese güvendiğini ve proxy zincirlerinin nasıl ele alındığını ayrıntılı anlatıyor. Container Apps'te ingress'in adres aralığı environment'ın ağına bağlı; Logging__LogLevel__Microsoft.AspNetCore.HttpOverrides=Debug ile middleware'in "Unknown proxy" diye reddettiği adresi log'da görüp listeyi ona göre ayarlayabilirsin.

Deploy, Log Stream ve Deployment Slot

Script'in son üç satırı kod deploy'u: dotnet publish çıktısını zip'leyip az webapp deploy --type zip ile göndermek. Yerelde aynı adımlar:

$ dotnet publish src/Bookstore.Api --configuration Release --output publish
  Bookstore.Api -> src/Bookstore.Api/bin/Release/net10.0/Bookstore.Api.dll
  Bookstore.Api -> publish/
$ (cd publish && zip -qr ../bookstore-api.zip .)

Framework-dependent yayımlandığı için zip 28 MB civarında; runtime App Service'te zaten var. Uygulamanın konsol çıktısını canlı izlemek için önce container log'larını açar (az webapp log config --docker-container-logging filesystem), sonra az webapp log tail çalıştırırsın. Log'larda /robots933456.txt için 404 görürsen endişelenme: App Service, container'ın istek karşılayıp karşılamadığını bu sahte yolla yokluyor.

Bu yazıda deploy'u elle yapıyoruz; gerçek ekipte bu adım bir pipeline'a ait. GitHub Actions serisinin 10. yazısı aynı App Service'e OIDC (OpenID Connect; kimliği imzalı token'la kanıtlayan açık standart) ile şifresiz deploy'u, Azure DevOps Pipelines serisi de Azure DevOps tarafını anlatıyor. Kesintisiz geçiş için deployment slot'ları (Standard ve üstü planlarda) yeni sürümü ayrı bir slot'ta ısıtıp sonra production ile yer değiştirmeni sağlar; Deployment Stratejileri serisinin 4. yazısı blue-green deployment'ı App Service slot'larıyla kuruyor.

Container Apps: Environment, Revision, Ingress ve Scale Kuralları

Aynı API'yi container olarak Container Apps'e koymak için image'ın bir registry'de olması gerekiyor. 4. yazıda dotnet publish /t:PublishContainer ile ürettiğimiz image'ı GitHub Container Registry'ye (GHCR) göndermek pipeline'ın işi; burada registry'deki bir image'ı kullanıyoruz:

#!/usr/bin/env bash
# Container Apps'te Bookstore.Api: aynı API, GitHub Container Registry'deki bir container image olarak.
set -euo pipefail

: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
ENVIRONMENT="${1:?Usage: 05-container-apps.sh <dev|staging|prod> <image>}"
IMAGE="${2:?Usage: 05-container-apps.sh <dev|staging|prod> ghcr.io/<owner>/bookstore-api:<tag>}"
RG="rg-bookstore-${ENVIRONMENT}-weu"
CAE="cae-bookstore-${ENVIRONMENT}-weu"
APP="ca-bookstore-${ENVIRONMENT}-weu"
IDENTITY="id-bookstore-${ENVIRONMENT}-weu"
STORAGE="stbookstore${ENVIRONMENT}weu"
SQL_SERVER="sql-bookstore-${ENVIRONMENT}-weu"

az account set --subscription "$AZURE_SUBSCRIPTION_ID"
IDENTITY_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query id --output tsv)
CLIENT_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query clientId --output tsv)

az containerapp env create --name "$CAE" --resource-group "$RG" --location westeurope

az containerapp create --name "$APP" --resource-group "$RG" --environment "$CAE" \
  --image "$IMAGE" --target-port 8080 --ingress external \
  --cpu 0.5 --memory 1.0Gi --min-replicas 0 --max-replicas 5 \
  --scale-rule-name http --scale-rule-type http --scale-rule-http-concurrency 50 \
  --user-assigned "$IDENTITY_ID" --revisions-mode multiple \
  --env-vars ASPNETCORE_ENVIRONMENT=Production \
    "Proxy__KnownNetworks=10.0.0.0/8;172.16.0.0/12;192.168.0.0/16" \
    "BlobStorage__ServiceUri=https://${STORAGE}.blob.core.windows.net" \
    "BlobStorage__ManagedIdentityClientId=${CLIENT_ID}" \
    "ConnectionStrings__BookstoreDb=Server=tcp:${SQL_SERVER}.database.windows.net,1433;Database=BookstoreDb;Authentication=Active Directory Managed Identity;User Id=${CLIENT_ID};Encrypt=True"

az containerapp show --name "$APP" --resource-group "$RG" --query properties.configuration.ingress.fqdn --output tsv

Container Apps environment, aynı ağı ve log hedefini paylaşan container app'lerin sınırıdır. --target-port 8080, .NET 8'den beri resmi ASP.NET Core image'larının varsayılan portu. --min-replicas 0 ve --scale-rule-http-concurrency 50, 4. yazıda ölçtüğümüz cold start'ı kabul eden bir dev ayarı; prod'da minimum 1 olur. --revisions-mode multiple ile her deploy yeni bir revision açar ve trafiği elle bölersin: az containerapp ingress traffic set --revision-weight <old>=90 <new>=10 (eski revision'a yüzde 90, yenisine yüzde 10). Single modda yeni revision hazır olunca trafik tamamen ona geçer, eskisi kapatılır.

GHCR'daki image özelse Container Apps'e kullanıcı adı ve token vermen gerekir, çünkü GHCR Azure managed identity'yi tanımaz. Secret saklamak istemiyorsan alternatif ACR (Azure Container Registry; Azure'ın özel container registry'si): container app'in managed identity'sine registry üzerinde "AcrPull" rolü verilir ve --registry-identity ile parolasız çekilir. Learn'in scale sayfası bir de şunu hatırlatıyor: .NET uygulamaları birden çok replica'da çalışırken ASP.NET Core Data Protection anahtarlarının paylaşılması gerekir; Bookstore.Api cookie ya da antiforgery kullanmadığı için bugün etkilenmiyor ama bir Razor arayüzü eklendiği gün bu iş gündeme gelir.

Angular: Static Web Apps ve SPA Fallback

Angular uygulaması derlendiğinde birkaç statik dosyadan ibaret; bunları bir web sunucusunda çalıştırmak gereksiz. Azure'da iki yol var: Static Web Apps ya da Blob Storage'ın static website özelliğinin önüne bir CDN (içeriği kullanıcıya yakın sunuculardan dağıtan ağ) veya Front Door koymak (bu ikincisi Domain, DNS ve CDN serisinin konusu). Static Web Apps'in Learn'e göre bugün iki planı var: kişisel projeler için Free ve production için Standard. Dedicated plan 31 Ekim 2025'te emekliye ayrıldı. Standard; SLA (Service Level Agreement; sağlayıcının erişilebilirlik taahhüdü), özel domain sayısı, private endpoint ve kendi API'ni bağlama (linked backend) için gerekiyor.

SPA'nın (Single Page Application; tarayıcıda çalışan tek sayfalık uygulama) asıl sorunu routing: /books/9780451524935 tarayıcıda Angular router'ın ürettiği bir adres, sunucuda böyle bir dosya yok. Kullanıcı sayfayı yenilediğinde sunucu index.html'i dönmezse 404 alır. Static Web Apps'te bunu staticwebapp.config.json çözer; dosyayı Angular'ın public/ klasörüne koyduk ki build çıktısının köküne kopyalansın:

{
  "navigationFallback": {
    "rewrite": "/index.html",
    "exclude": [
      "/api/*",
      "/*.{js,css,ico,png,jpg,svg,webp,woff2,map,json}"
    ]
  },
  "routes": [
    {
      "route": "/index.html",
      "headers": {
        "Cache-Control": "no-cache"
      }
    }
  ],
  "globalHeaders": {
    "X-Content-Type-Options": "nosniff"
  }
}

navigationFallback, karşılığı olmayan her isteğe /index.html döner; exclude ise iki şeyi korur: /api/* istekleri API'ye gider ve eksik bir JS ya da CSS dosyası 404 olarak kalır, HTML'e dönüşüp tarayıcıda anlaşılmaz bir hataya yol açmaz. Bunu Static Web Apps CLI'ın emülatörüyle denedik; derlenmiş Angular uygulamasını sunarken /api isteklerini yerelde çalışan Bookstore.Api'ye yönlendiriyor:

$ npx ng build
$ swa start dist/bookstore-web/browser --api-devserver-url http://127.0.0.1:5191 --port 4291
/                            200 text/html
/books/9780451524935         200 text/html
/cart                        200 text/html
/main-missing.js             404 text/html; charset=UTF-8
/api/books?pageSize=1        200 application/json; charset=utf-8
/api/nothing                 404 application/problem+json

Tahminin ikinci kısmının cevabı: fallback ayarlıysa 200 ve Angular uygulaması. Emülatör uyarısını da not et: CLI, ortamın buluttakiyle birebir aynı olmadığını ilk satırda yazıyor. Production'da /api'nin aynı origin'den çalışması için Standard planda App Service'i linked backend olarak bağlıyoruz; Free planda ise Angular'ın API'yi mutlak adresle çağırması ve API'de CORS (tarayıcının başka origin'e istek atmasına izin veren header kuralları) açılması gerekirdi:

#!/usr/bin/env bash
# Static Web Apps'te bookstore-web (Standard plan); /api, App Service'teki API'ye bağlı.
set -euo pipefail

: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
ENVIRONMENT="${1:?Usage: 05-static-web-app.sh <dev|staging|prod>}"
RG="rg-bookstore-${ENVIRONMENT}-weu"
SWA="stapp-bookstore-${ENVIRONMENT}-weu"
API="app-bookstore-${ENVIRONMENT}-weu"

az account set --subscription "$AZURE_SUBSCRIPTION_ID"

az staticwebapp create --name "$SWA" --resource-group "$RG" --location westeurope --sku Standard

# Linked backend: /api/* istekleri API'ye gider; aynı origin, CORS yok
API_ID=$(az webapp show --name "$API" --resource-group "$RG" --query id --output tsv)
az staticwebapp backends link --name "$SWA" --resource-group "$RG" \
  --backend-resource-id "$API_ID" --backend-region westeurope

(cd web/bookstore-web && npm ci && npx ng build)
TOKEN=$(az staticwebapp secrets list --name "$SWA" --resource-group "$RG" --query properties.apiKey --output tsv)
npx @azure/static-web-apps-cli@2.1.0 deploy web/bookstore-web/dist/bookstore-web/browser \
  --deployment-token "$TOKEN" --env production

İki Yolun Karşılaştırması ve AWS Karşılıkları

Tablonun son sütunu AWS (Amazon Web Services; Amazon'un sunduğu bulut platformu) karşılıkları; oradaki ECS (Elastic Container Service; AWS'nin container çalıştırma servisi), EC2 (Elastic Compute Cloud; AWS'de sanal makine çalıştıran servis) ve S3 (Simple Storage Service; AWS'nin object storage servisi) kısaltmaları serinin önceki yazılarında da geçti.

KonuApp Service (kod)Container Apps (container)AWS karşılığı
Teslim edilenZip, runtime platformdaImage, runtime image'daBeanstalk'a zip ya da ECS Express Mode'a image
FaturaPlan instance saativCPU ve GiB saniyesi, istek sayısıEC2 saati ya da Fargate vCPU ve GB saati
Sıfıra inmeHayırEvet, minimum replica 0Fargate'te hayır, Lambda'da evet
Kesintisiz geçişDeployment slot, swapRevision ve trafik bölmeBeanstalk blue/green, ECS deployment
Runtime yamasıPlatform yaparImage'ı yeniden üretmen gerekirBeanstalk platform güncellemesi, image'ı yeniden üretmek
AngularStatic Web AppsStatic Web AppsS3 ve CloudFront ya da Amplify Hosting

Bookstore için bugünkü öneri App Service: tek bir API için plan modeli basit, slot'lar hazır ve runtime yamalarını platform yapıyor. Kurumsal Sipariş'in ayrı bir servis olarak ayrıldığı ya da kampanya yükünün çok dalgalandığı gün Container Apps'e geçmek kolay, çünkü API zaten bir image olarak üretilebiliyor.

Üretimde Dikkat

Health check'i yalnız deploy'dan sonra açma. Learn, health check ayarının değişmesinin uygulamayı yeniden başlattığını söylüyor; ayarı ilk kurulumda yap, sonra dokunma.

Container image'ını sabit bir tag ya da digest ile deploy et. latest kullanan bir revision, image yeniden çekildiğinde başka bir koda dönebilir; geri dönmek istediğinde hangi sürümün çalıştığını bilemezsin.

Static Web Apps'e giden build'de environment'a özel ayar bırakma. Angular build'i statik dosyadır; prod API adresi ya da anahtar build'in içine gömülür ve herkes okuyabilir. Linked backend ile aynı origin'den /api çağırmak bu yüzden de daha temiz.

Sık Düşülen Tuzaklar

Uygulamanın dinlediği portu kontrol etmemek. ASP.NET Core image'ları 8080'i dinler; Container Apps'te --target-port, App Service'te özel container kullanıyorsan WEBSITES_PORT bununla aynı olmalı, yoksa platform container'ı ayakta göremez.

Forwarded header'ları yok saymak ya da herkese güvenmek. İlki yanlış şemayla link ürettirir, ikincisi istemci IP'sini taklit etmeyi mümkün kılar.

Scale-to-zero'yu prod'da varsayılan bırakmak. Container Apps'in varsayılan minimum replica sayısı 0; ilk isteğin cold start'ını kabul etmiyorsan bunu bilerek 1 yap.

SPA fallback'ten /api'yi ve statik dosyaları hariç tutmamak. Eksik bir JS dosyası için index.html dönmesi, tarayıcıda "Unexpected token <" gibi asıl sorunu gizleyen bir hataya yol açar.

Küçük Bir Deneme

★☆☆ staticwebapp.config.json'dan exclude satırını kaldırıp emülatörü yeniden çalıştır; /main-missing.js isteğinin cevabı nasıl değişiyor?

★★☆ ForwardedHeadersTests'e, X-Forwarded-For başlığında iki adres taşıyan (istemci ve bir ara proxy) bir durum ekle. ForwardLimit varsayılanında RemoteIpAddress hangi adres oluyor?

★★★ 05-container-apps.sh'i ACR ve managed identity ile image çekecek şekilde değiştir: registry'yi oluştur, identity'ye AcrPull rolünü ver, --registry-identity kullan. Hangi adımın kontrol düzleminde, hangisinin veri düzleminde olduğunu yaz.

Bir sonraki yazıda Bookstore'a ilk serverless parçayı ekliyoruz: Azure Functions isolated worker modeli, Flex Consumption planı ve yeni bir kapak resmi yüklendiğinde küçük resmini üreten, Event Grid tetiklemeli bir function.