C# w chmurze Azure: jak pisać aplikacje, które łatwo skalować i utrzymać

0
12
Rate this post

Nawigacja:

Od monolitu do chmury: jak myśleć o aplikacji C# w Azure

Różnica między on‑premise a chmurą: stan, skalowanie, awarie

Środowisko on‑premise często premiuje aplikacje stanowe: sesja w pamięci serwera, pliki na lokalnym dysku, singletony trzymające cache w RAM. W chmurze Azure takie podejście bardzo szybko psuje skalowalność i komplikuje utrzymanie.

Podstawowa zmiana myślenia: skalowanie w poziomie, nie w górę. Zamiast jednego mocnego serwera – wiele lekkich instancji, które można łatwo dodać lub usunąć. Warunek: instancje muszą być stateless, czyli nie przechowywać krytycznego stanu lokalnie.

W Azure trzeba założyć, że:

  • instancja aplikacji może zniknąć w dowolnym momencie,
  • kolejne żądania użytkownika mogą trafić na inne instancje,
  • warstwa infrastruktury będzie skalować zasoby zgodnie z metrykami (CPU, czas odpowiedzi, kolejki).

Dobrze zaprojektowana aplikacja C# w Azure traktuje maszynę/instancję jak przelotny kontener. Trwałe dane trzyma w usługach: bazie, storage, cache, kolejkach. To rozdzielenie pozwala później podmienić infrastrukturę bez przepisywania logiki biznesowej.

Oddzielenie logiki domenowej od infrastruktury Azure

Kod aplikacji łatwy w utrzymaniu i skalowaniu ma jasny podział na odpowiedzialności. Logika biznesowa powinna być niezależna od szczegółów platformy (Azure, baza danych, kolejki). Pomagają w tym podejścia typu ports and adapters (hexagonal architecture) lub proste, konsekwentne warstwowanie.

Praktyczny schemat dla C#/.NET:

  • Warstwa domenowa – klasy, encje, serwisy domenowe, logika reguł biznesowych, brak odwołań do Azure SDK, brak EF DbContext.
  • Warstwa aplikacyjna – use-case’y (np. handlers w MediatR), orkiestracja domeny, interfejsy portów (np. IOrderRepository, IEmailSender).
  • Warstwa infrastruktury – implementacje portów: repozytoria w oparciu o EF Core, klienci Service Bus, Azure Blob Storage, logowanie.
  • Warstwa prezentacji – ASP.NET Core controllers/minimal APIs lub Azure Functions, mapujące HTTP / eventy na wywołania warstwy aplikacyjnej.

Taka struktura sprawia, że przejście z Azure App Service na Azure Functions, z Azure SQL na Cosmos DB albo z kolejki Storage na Service Bus wymaga zmian głównie w warstwie infrastruktury. Domena i use-case’y pozostają stabilne, co redukuje koszty utrzymania i ryzyko błędów.

Gdzie C# i .NET pasują w ekosystemie Azure

C# i .NET są w Azure obsługiwane natywnie w wielu usługach. Najważniejsze opcje dla aplikacji biznesowych to:

  • Azure App Service – hostowanie aplikacji ASP.NET Core (API, MVC, Razor Pages). Dobre do klasycznych serwisów HTTP, prostych background tasks (WebJobs).
  • Azure Functions – funkcje serverless w C#. Idealne do event‑driven, krótkotrwałych operacji, integracji, przetwarzania w tle.
  • Azure Container Apps – kontenery (np. aplikacje .NET w obrazie Docker). Dobre do mikroserwisów bez pełnego Kubernetes.
  • Azure Kubernetes Service (AKS) – pełny Kubernetes. Opcja dla dużych, złożonych systemów wymagających zaawansowanej orkiestracji.
  • Worker Services w .NET – usługi w tle uruchamiane w kontenerach lub jako WebJobs, do przetwarzania kolejek, ETL, cronów.

Wybór usługi nie powinien dyktować architektury domeny. Projekt rozpoczyna się od modelu biznesowego, a dopiero później dobiera się hostowanie: App Service, Functions, kontenery. Taka kolejność ułatwia migracje i eksperymenty ze skalowaniem.

Przykład: etapowa migracja prostego monolitu do Azure

Praktyczny scenariusz: monolityczny system sprzedażowy napisany w ASP.NET MVC hostowany on‑premise. Całość korzysta z jednego SQL Servera, pliki są na dysku aplikacji, sesja w pamięci serwera.

Rozsądna migracja etapowa może wyglądać tak:

  1. Wyciągnięcie konfiguracji do appsettings.json, użycie IConfiguration. Zastąpienie odwołań do plików lokalnych abstrakcjami (np. IFileStorage).
  2. Wprowadzenie warstwy aplikacyjnej i domenowej – wyniesienie logiki z kontrolerów do serwisów.
  3. Przeniesienie bazy do Azure SQL, plików do Azure Blob Storage, sesji do rozproszonego cache (Azure Cache for Redis).
  4. Hostowanie monolitu w Azure App Service (jeden App Service Plan). Skalowanie pionowe (większa maszyna) na starcie.
  5. Dodanie skalowania poziomego (scale out) – testy, czy wiele instancji monolitu poprawnie obsługuje ruch.
  6. Stopniowe wydzielanie wybranych modułów (np. wysyłka maili, generowanie faktur) do Azure Functions lub osobnych mikroserwisów w Container Apps.

Każdy etap wnosi korzyść: poprawa dostępności, redukcja obciążeń, możliwość elastycznego skalowania. Kluczem jest stopniowe odcinanie zależności od lokalnego stanu i ciasnych powiązań technologicznych.

Wybór usług Azure pod aplikację C#: przegląd opcji i kompromisów

App Service, Functions, Container Apps, AKS – porównanie

Usług hostujących kod C# w Azure jest kilka, a ich wybór ma bezpośredni wpływ na skalowalność i koszty. Dobrze zestawić je obok siebie.

Usługa Charakterystyka Typowe zastosowanie Złożoność operacyjna
Azure App Service Managed PaaS dla aplikacji web/API API REST, aplikacje biznesowe, panele admina Niska
Azure Functions Serverless, uruchamiane na zdarzenia Integracje, przetwarzanie w tle, event-driven Niska–średnia
Azure Container Apps Kontenery bez zarządzania Kubernetes Mikroserwisy, API w kontenerach, background workers Średnia
AKS (Kubernetes) Pełny klaster Kubernetes Duże platformy, złożone systemy, multi-tenant Wysoka

Kilka praktycznych wskazówek:

  • Zacznij od App Service, jeśli budujesz klasyczne API lub panel webowy. To dobry balans kontroli i prostoty.
  • Dodaj Functions do zadań okazjonalnych, wsadowych, event‑driven (np. przetwarzanie kolejek, webhooks, cron).
  • Sięgnij po Container Apps, gdy chcesz mieć kontenery i większą elastyczność runtime (np. kilka języków, niestandardowe zależności systemowe), ale bez zarządzania Kubernetes.
  • AKS wybierz dopiero wtedy, gdy realnie potrzebujesz pełni możliwości Kubernetes (ingress, operators, skomplikowane zależności między serwisami).

Usługi danych: Azure SQL, Cosmos DB, Storage, Redis

Skalowalność aplikacji C# w Azure bardzo często kończy się na wąskim gardle w warstwie danych. Wybór odpowiedniego magazynu i wzorców dostępu jest równie ważny jak kod.

  • Azure SQL Database – relacyjna baza SQL Server jako usługa. Dobra dla aplikacji z silnymi relacjami, transakcjami ACID, raportowaniem. Skalowanie pionowe i pewne opcje poziome (read replicas, sharding logiczny).
  • Azure Cosmos DB – baza NoSQL, globalna dystrybucja, niskie opóźnienia. Nadaje się do scenariuszy o dużej liczbie odczytów i zapisów, luźno powiązanych danych, event sourcingu.
  • Azure Storage (Tables/Blobs/Queues) – bardzo tani i skalowalny storage. Tables do prostych danych klucz-wartość, Blobs do plików, Queues do kolejek pracy.
  • Azure Cache for Redis – rozproszony cache w pamięci. Odciąża bazę, skraca czas odpowiedzi, umożliwia scenariusze rate limiting, distributed locks.

Dobrym podejściem jest użycie kombinacji:

  • Azure SQL jako główne źródło prawdy dla danych transakcyjnych,
  • Redis jako cache najczęściej odczytywanych danych,
  • Blob Storage dla plików, raportów, eksportów,
  • w razie potrzeby Cosmos DB dla modułów o ekstremalnych wymaganiach wydajnościowych lub globalnej replikacji.

Usługi komunikacyjne: Service Bus, Event Grid, Queue Storage

Komunikacja asynchroniczna między komponentami systemu to fundament skalowalności. Azure oferuje kilka mechanizmów kolejek i zdarzeń:

  • Azure Service Bus – zaawansowana usługa kolejek i tematów (topics). Obsługuje sesje, dead-letter, transakcje, FIFO, pub/sub. Dobra do krytycznych procesów biznesowych.
  • Azure Queue Storage – prostsza kolejka powiązana z Azure Storage. Tania, ale bez zaawansowanych funkcji. Wystarcza do prostych zadań w tle.
  • Azure Event Grid – mechanizm zdarzeń w stylu publish/subscribe. Służy raczej do powiadamiania niż do kolejkowania z gwarancją przetworzenia.

Prosty schemat wyboru:

  • Jeśli potrzebujesz gwarancji dostarczenia, DLQ, transakcji – wybierz Service Bus.
  • Jeśli to tylko informacyjne eventy (np. „plik wrzucony do storage”, „subskrypcja wygasa za X dni”) – Event Grid.
  • Jeśli to proste, masowe zadania w tle, a budżet jest mocno ograniczony – Queue Storage.

Przykładowe kombinacje usług

Kilka konkretnych zestawów pod typowe typy aplikacji .NET w Azure:

  • Proste API biznesowe
    App Service (API), Azure SQL, Redis (cache), Blob Storage (pliki), Application Insights (monitoring).
  • System event‑driven
    Azure Functions (konsumpcja eventów), Service Bus (komendy i kolejki pracy), Event Grid (eventy domenowe), Cosmos DB (stan), Blob Storage (dokumenty).
  • Aplikacja wsadowa / ETL
    Worker Service w Container Apps lub Functions (timer trigger), Blob Storage (źródło/target plików), Azure SQL lub Data Lake, Service Bus (kolejkowanie zadań).

Świadome dobranie usług na starcie pozwala później skalować elementy niezależnie: osobno instancje API, osobno consumers kolejek, osobno warstwę danych.

Kolorowy kod C# na ekranie monitora w kontekście chmury Azure
Źródło: Pexels | Autor: Myburgh Roux

Projektowanie kodu C# pod skalowanie: stateless, idempotencja, zależności

Stateless w praktyce: co wolno, a czego nie wolno trzymać w pamięci

Stateless to nie zakaz używania pamięci, tylko zakaz traktowania jej jako trwałego stanu. Instancja może zniknąć, więc wszystko, co ma przetrwać restart, musi być na zewnątrz.

Dobre praktyki:

  • Brak sesji w pamięci serwera – sesję przenoś do tokenów JWT, Redis Session State lub po prostu projektuj API bezsesyjne.
  • Brak uploadowanych plików na dysk lokalny – od razu zapis do Blob Storage.
  • Cache danych aplikacyjnych w pamięci może być, ale traktowany jako best effort, możliwy do utraty.
  • Żadnych „globalnych liczników” w statycznych polach. Współdzielony stan liczbowy trzymaj w SQL / Redis z odpowiednią synchronizacją.

Prosty, rozsądny kompromis: używać IMemoryCache dla małych, lokalnych optymalizacji i Redis dla danych cache’owanych między instancjami. Przy skalowaniu poziomym lokalny cache i tak zostanie powielony, więc nie może być źródłem prawdy.

Idempotencja operacji: odporność na powtórzenia

W środowisku rozproszonym zawsze trzeba liczyć się z ponawianiem żądań: po timeoutach, błędach sieci, restarcie funkcji. Jeśli ta sama operacja zapisu jest wykonana dwa razy, nie może prowadzić do niespójności.

Idempotencja w C# i Azure to m.in.:

  • Idempotentne endpointy API – np. przy tworzeniu zamówienia klient wysyła RequestId, a serwer zapisuje go i nie tworzy duplikatów przy powtórzeniu.
  • Idempotentne consumers kolejek – wiadomość ma unikalny identyfikator, a consumer zapisuje w bazie log przetworzenia; przy kolejnym podejściu wykrywa, że już przetworzył.
  • Zastosowanie wzorca outbox – transakcja w bazie zapisuje zarówno dane domenowe, jak i eventy do wysłania; proces wysyłający eventy jest idempotentny.

Przykładowy szkic w C# dla tworzenia zamówienia z idempotency key:

Przykład implementacji idempotentnego endpointu

Najprostszy wariant to tabela z kolumnami IdempotencyKey, CreatedAt, ResultHash (opcjonalnie). Logika:

  1. Sprawdź, czy IdempotencyKey istnieje w bazie.
  2. Jeśli tak – zwróć istniejący wynik (lub kod 409/200 zgodnie z kontraktem).
  3. Jeśli nie – wykonaj operację w transakcji, zapisz wynik i klucz.

[HttpPost("orders")]
public async Task<IActionResult> CreateOrder(
    [FromBody] CreateOrderRequest request,
    [FromHeader(Name = "Idempotency-Key")] string idempotencyKey,
    CancellationToken ct)
{
    if (string.IsNullOrWhiteSpace(idempotencyKey))
        return BadRequest("Missing Idempotency-Key header.");

    var existing = await _db.IdempotencyKeys
        .FirstOrDefaultAsync(x => x.Key == idempotencyKey, ct);

    if (existing != null)
    {
        // Można np. odtworzyć wcześniej zwrócony wynik lub tylko 200/202
        return StatusCode(StatusCodes.Status200OK);
    }

    using var tx = await _db.Database.BeginTransactionAsync(ct);

    var order = new Order { /* mapowanie z requestu */ };
    _db.Orders.Add(order);

    _db.IdempotencyKeys.Add(new IdempotencyKey
    {
        Key = idempotencyKey,
        CreatedAt = DateTimeOffset.UtcNow
    });

    await _db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);

    return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}

Ten schemat można zaadaptować również do consumers Service Bus/Queue Storage, zapisując identyfikator wiadomości zamiast nagłówka HTTP.

Minimalizowanie zależności i kontraktów wewnętrznych

Silne sprzężenie między modułami zabija skalowalność. Im mniej bezpośrednich referencji projekt/namespace, tym łatwiej przenosić komponenty między usługami Azure.

Kilka prostych reguł:

  • Publiczne kontrakty komunikacji (DTO, eventy) trzymaj w osobnym projekcie, nie w warstwie domeny.
  • Nie udostępniaj innym modułom encji ORM. Ekspozycja powinna być przez wąskie modele read‑only.
  • Wstrzykuj interfejsy, nie implementacje. Implementacje powiąż z konkretną usługą (App Service, Functions) na krawędzi systemu.
  • Wspólne biblioteki ogranicz do cross‑cutting (logowanie, retry, autoryzacja), nie do logiki biznesowej.

W praktyce dobrze sprawdza się podział na projekty typu *.Api, *.Application, *.Domain, *.Infrastructure plus osobny *.Contracts dla komunikacji między serwisami.

Wzorce odporności w kodzie: retry, timeouty, circuit breaker

W Azure wielu problemów nie rozwiązuje się „większą maszyną”, tylko odpornością na chwilowe błędy. Tu pomagają podstawowe wzorce:

  • Retry z backoffem – ponawianie błędnych wywołań sieciowych/SQL.
  • Timeout – każde wywołanie zewnętrzne powinno mieć limit czasu.
  • Circuit breaker – odcięcie ruchu do komponentu, który ewidentnie ma awarię.

W .NET łatwo wdrożyć to z Polly:


services.AddHttpClient("external-api")
    .AddPolicyHandler(Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(5)))
    .AddPolicyHandler(Policy
        .Handle<HttpRequestException>()
        .OrResult<HttpResponseMessage>(r => (int)r.StatusCode >= 500)
        .WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))))
    .AddPolicyHandler(Policy
        .Handle<HttpRequestException>()
        .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));

Tak skonfigurowany klient nadaje się do użycia zarówno w App Service, jak i w Functions czy Container Apps.

Architektura aplikacji w Azure: monolit modularny, mikroserwisy i podejście hybrydowe

Monolit modularny jako rozsądny start

Dla większości zespołów i projektów pierwszy krok to dobrze poukładany monolit. Byle modularny.

Charakterystyka:

  • Jeden deployment (np. pojedynczy App Service).
  • Wyraźnie wydzielone moduły (np. Billing, Users, Catalog) w kodzie i bazie.
  • Brak „twardych” granic sieciowych, ale ograniczone zależności między modułami.

Taki monolit można skalować poziomo przez zwykłe zwiększenie liczby instancji App Service. Jednocześnie przygotowuje grunt pod ewentualne wydzielanie mikroserwisów.

Kiedy zacząć wydzielać mikroserwisy

Same koszty operacyjne mikroserwisów w Azure (więcej deploymentów, monitoringu, storage’u) są realne. Dobrze, gdy decyzję wymuszają faktyczne problemy:

  • Jeden moduł ma zupełnie inne profile obciążenia (np. batch nocny) i utrudnia skalowanie całości.
  • Moduł wymaga innych technologii (np. intensywny streaming, inna baza danych).
  • Potrzebna niezależna ścieżka wdrażania (częste releasy tylko jednego fragmentu).

W Azure przejście z modułu w monolicie do osobnego serwisu bywa proste, jeśli kontrakty (np. eventy) były od początku wydzielone.

Model hybrydowy: monolit + kilka serwisów wokoło

Częsta, praktyczna architektura to „monolit + satelity”:

  • Główny monolit w App Service (całe API biznesowe).
  • Osobny Worker/Functions do przetwarzania ciężkich zadań w tle.
  • Być może jeden krytyczny komponent jako osobny mikroserwis w Container Apps.

Wspólna jest domena i kontrakty, ale każdy element może skalować się osobno w Azure (np. Functions automatycznie w górę, App Service w poziom).

Granice kontekstów i komunikacja między nimi

Przy projektowaniu granic w systemie .NET + Azure dobrym punktem odniesienia jest podejście bounded context. Każdy kontekst ma:

  • Własną bazę (lub przynajmniej schemat) – unika się współdzielenia tabel.
  • Własny model domenowy i logikę aplikacyjną.
  • Publiczne kontrakty integracyjne (eventy, API).

W Azure wygodnie łączyć te konteksty przez Service Bus lub Event Grid, zamiast poprzez bezpośrednie wywołania HTTP do wnętrza innego modułu.

Fragment ekranu laptopa z niebiesko podświetlonym kodem programistycznym
Źródło: Pexels | Autor: Nemuel Sereti

Komunikacja i integracje w chmurze: HTTP, kolejki, zdarzenia

HTTP jako podstawowy kontrakt

Dla interfejsów zewnętrznych (fronty SPA, aplikacje mobilne, integracje B2B) HTTP/REST jest wciąż najbardziej praktycznym wyborem.

W Azure najczęstszy układ:

  • API w App Service lub Container Apps.
  • Azure API Management jako brama (wersjonowanie, limity, autoryzacja, transformacje).
  • Application Gateway/Front Door jako warstwa wejściowa z WAF.

Po stronie C# ułatwia to prosty model: kontroler/API minimalne + MediatR/handler w warstwie aplikacyjnej. Skalowanie odbywa się na poziomie instancji hosta.

Komunikacja asynchroniczna wewnątrz platformy

Dla komunikacji między komponentami wewnątrz środowiska Azure lepiej stawiać na asynchroniczność. Zmniejsza to sprzężenie i poprawia odporność.

Typowy schemat:

  • API zapisuje komendę/zlecenie do kolejki Service Bus.
  • Azure Functions lub Worker w Container Apps odbiera zadanie i je realizuje.
  • Po wykonaniu publikuje event domenowy (np. do tematu Service Bus albo Event Grid).
  • Inne moduły subskrybują eventy i reagują (np. wysyłka e‑mail, fakturowanie).

Dzięki temu każdy element ma swoje tempo przetwarzania, a skalowanie sprowadza się do zwiększenia liczby instancji consumers.

Wzorzec outbox i integracja przez eventy

Problem „zapisałem w bazie, ale event nie poleciał” rozwiązuje wzorzec outbox. W świecie C# + Azure wygląda to zazwyczaj tak:

  1. Transakcja w bazie zapisuje dane domenowe + rekordy outbox (eventy do wysłania).
  2. Osobny proces (np. Worker/Function) cyklicznie czyta outbox i publikuje eventy do Service Bus/Event Grid.
  3. Po sukcesie oznacza rekordy jako przetworzone.

public async Task Handle(CreateInvoiceCommand command, CancellationToken ct)
{
    using var tx = await _db.Database.BeginTransactionAsync(ct);

    var invoice = new Invoice(/* ... */);
    _db.Invoices.Add(invoice);

    var @event = new InvoiceCreatedEvent(invoice.Id, invoice.CustomerId);
    _db.OutboxMessages.Add(OutboxMessage.FromDomainEvent(@event));

    await _db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
}

Proces wysyłający eventy może działać jako IHostedService w App Service albo jako osobna Function (timer trigger + odczyt z bazy).

Integracja z systemami zewnętrznymi

Przy integracjach spoza Azure dobrze mieć bufor między światem zewnętrznym a logiką domenową. Najprostszy układ to:

  • Webhook/endpoint HTTP przyjmujący żądania.
  • Szybkie zwrócenie odpowiedzi (202/200) po zapisaniu żądania do Service Bus.
  • Asynchroniczne przetwarzanie żądania w tle.

W razie awarii zewnętrznego API (np. bramki płatności) ponowienie żądania następuje z kolejki z użyciem retry/circuit breaker, a nie z poziomu oryginalnego klienta.

Przechowywanie danych i cache: jak nie zabić skalowalności bazą

Modelowanie danych pod skalowanie poziome

Relacyjna baza SQL w Azure potrafi znieść sporo, ale ma swoje granice. Pomaga proste podejście:

  • Odczyty prostsze niż zapisy – projekcje read‑model, widoki materializowane, tabele „denormalizowane” pod konkretne ekrany.
  • Unikanie cross‑modułowych JOIN‑ów na gorącej ścieżce.
  • W miarę możliwości ograniczenie transakcji rozciągniętych na wiele tabel.

Jeśli dane naturalnie dzielą się po kliencie/organizacji, można wprowadzić sharding logiczny (np. TenantId jako klucz partycjonujący, osobne bazy dla dużych klientów).

Cache na różnych poziomach

W Azure da się skorzystać z kilku warstw cache’u:

  • Cache HTTP/Front Door – do statycznych zasobów, czasem do JSON‑ów read‑only.
  • IMemoryCache po stronie aplikacji – lokalny, szybki, ale nie współdzielony.
  • Azure Cache for Redis – współdzielony, rozproszony cache w pamięci.

Praktyczny wzorzec to cache‑aside: najpierw próba odczytu z cache, przy braku – z bazy, a potem zapis do cache. W .NET można to spiąć w prosty serwis:


public async Task<ProductDto> GetProductAsync(Guid id, CancellationToken ct)
{
    var cacheKey = $"product:{id}";

    var cached = await _redis.GetStringAsync(cacheKey);
    if (cached != null)
        return JsonSerializer.Deserialize<ProductDto>(cached)!;

    var product = await _db.Products.FindAsync(new object[] { id }, ct);
    if (product == null) return null;

    var dto = Map(product);
    await _redis.SetStringAsync(cacheKey,
        JsonSerializer.Serialize(dto),
        new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
        });

    return dto;
}

Dla danych silnie dynamicznych lepiej skrócić TTL albo całkowicie pominąć cache i skupić się na optymalizacji zapytań.

Separacja magazynów: pliki, logi, dane analityczne

Trzymanie wszystkiego w jednej bazie SQL (pliki, logi, dane transakcyjne) szybko kończy się problemami. Dobry, prosty podział:

  • Azure SQL/Cosmos DB – dane biznesowe.
  • Azure Blob Storage – pliki, eksporty, załączniki.
  • Application Insights/Log Analytics – logi i metryki.
  • Data Lake / Synapse – cięższe analizy, raporty przekrojowe.

Aplikacja C# powinna mieć tylko tyle logiki, ile potrzebne do zapisania poprawnych danych. Cięższe raportowanie łatwiej przerzucić do dedykowanych usług analitycznych Azure.

Dłoń trzymająca naklejkę DevOps w otwartej przestrzeni na zewnątrz
Źródło: Pexels | Autor: RealToughCandy.com

Niezawodność, odporność i wzorce awaryjne w C# na Azure

Projektowanie pod awarie częściowe

W chmurze zakłada się, że pojedyncze komponenty będą padać: instancje App Service, workers, połączenia do SQL, consumers kolejek.

Kilka praktycznych zasad:

  • Każda operacja zewnętrzna powinna móc się nie udać bez wywrócenia całego procesu (fallback, retry, DLQ).
  • Procesy w tle muszą być reentrant i idempotentne.
  • Endpointy API powinny jasno komunikować stan: przy długich procesach zwrot 202 + URL do statusu.

Wiele przypadków da się uratować, jeśli system jest w stanie „nadrobić” zaległe eventy z kolejki po przejściowej awarii.

Dead‑letter i kompensacje

Service Bus i Event Grid potrafią wysyłać trudne wiadomości do DLQ. Dobrze mieć w C# osobny proces do ich obsługi:

  • Dashboard do podglądu zawartości DLQ.
  • Obsługa DLQ w praktyce

    Przy obsłudze DLQ przydaje się osobny komponent techniczny, który nie jest częścią głównego przepływu domenowego. Może to być prosta aplikacja konsolowa (.NET Worker) uruchamiana ręcznie lub w harmonogramie, albo Function z timerem.

  • Odczyt wiadomości z DLQ (Service Bus, Event Grid).
  • Próba ponownego przetworzenia z dodatkowym logowaniem.
  • Jeśli się nie uda – oznaczenie jako „do ręcznej analizy” i zapis w osobnym magazynie.

public async Task ProcessDlqAsync(CancellationToken ct)
{
    await foreach (var message in _dlqReceiver.ReceiveMessagesAsync(ct))
    {
        try
        {
            await _handler.HandleAsync(message, ct);
            await _dlqReceiver.CompleteMessageAsync(message, ct);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to process DLQ message {Id}", message.MessageId);
            // Przeniesienie do osobnego magazynu, np. Blob Storage
            await _failedStore.SaveAsync(message, ex, ct);
            await _dlqReceiver.CompleteMessageAsync(message, ct);
        }
    }
}

Ważne, aby DLQ nie był „czarną dziurą”. Nawet prosty dashboard w Azure Dashboard lub panel w grafanie dużo ułatwia.

Wzorce retry, circuit breaker i timeouts w .NET

Interakcje z zewnętrznymi usługami (SQL, HTTP, Service Bus) powinny mieć spójne polityki retry. W ekosystemie .NET wygodnie robi się to Polly lub wbudowanymi handlerami IHttpClientFactory.


services.AddHttpClient("Payments", client =>
    {
        client.BaseAddress = new Uri(configuration["Payments:BaseUrl"]);
        client.Timeout = TimeSpan.FromSeconds(10);
    })
    .AddTransientHttpErrorPolicy(p => p
        .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))))
    .AddTransientHttpErrorPolicy(p => p
        .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));

Łączenie retry + timeouts + circuit breaker ogranicza kaskadowe awarie. Lepszy szybki błąd niż wieszające się wątki i wyczerpane pule połączeń.

Idempotentne przetwarzanie komunikatów

W świecie kolejek i eventów komunikat może pojawić się więcej niż raz. Logika C# powinna wytrzymać ponowne przetworzenie bez skutków ubocznych.

Przykładowe podejście:

  • Tabela „ProcessedMessages” z kluczem komunikatu.
  • Sprawdzenie przed wykonaniem logiki, czy komunikat już przetworzony.
  • Transakcja obejmująca zmianę stanu + zapis ID komunikatu.

public async Task HandleAsync(OrderPaidEvent message, CancellationToken ct)
{
    if (await _db.ProcessedMessages.AnyAsync(x => x.Id == message.Id, ct))
        return;

    using var tx = await _db.Database.BeginTransactionAsync(ct);

    var order = await _db.Orders.FindAsync(new object[] { message.OrderId }, ct);
    order.MarkAsPaid();

    _db.ProcessedMessages.Add(new ProcessedMessage { Id = message.Id });

    await _db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
}

Takie rozwiązanie jest proste, ale skutecznie chroni przed dublem w przypadku powtórnego dostarczenia wiadomości.

Sagi i kompensacje dla dłuższych procesów

Dłuższe procesy biznesowe (np. zamówienie, rezerwacja, onboarding) można modelować jako sagę. Zamiast jednej dużej transakcji – sekwencja kroków ze stanem i operacjami kompensującymi.

  • Każdy krok ma własny event „sukces/porażka”.
  • Saga trzyma stan w jednej tabeli (np. „OrderSagaStates”).
  • Dla krytycznych akcji istnieje kompensacja (np. zwrot płatności, anulowanie rezerwacji).

W .NET sagę da się zrealizować czystym kodem (handler + tabela stanu) lub użyć gotowych bibliotek (np. MassTransit z obsługą sag).

Monitorowanie, logowanie i obserwowalność aplikacji .NET w Azure

Podstawy telemetry w Azure dla .NET

Minimalny zestaw telemetry dla aplikacji C# w Azure to:

  • logi aplikacyjne (strukturalne),
  • metryki techniczne (CPU, pamięć, liczba żądań),
  • traces i dependency telemetry (HTTP, SQL, Service Bus),
  • distributed tracing między usługami.

W praktyce większość tego zapewnia Application Insights z prawidłową konfiguracją i integracją z ASP.NET Core.

Konfiguracja Application Insights i strukturalne logowanie

W ASP.NET Core integracja jest prosta – wystarczy dodać pakiet i włączyć telemetry:


builder.Services.AddApplicationInsightsTelemetry();

builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddApplicationInsights();

Kluczowe jest strukturalne logowanie, zamiast „gołych” stringów. Ułatwia to późniejsze zapytania Kusto:


_logger.LogInformation("Order {OrderId} placed for customer {CustomerId}",
    order.Id, order.CustomerId);

Taki log da się później łatwo przefiltrować w Log Analytics po polach OrderId i CustomerId.

Distributed tracing i korelacja żądań

Rozproszone systemy bez korelacji logów szybko stają się nieczytelne. Trzeba dopilnować, aby:

  • nagłówki korelacyjne (np. traceparent, Request-Id) były przekazywane między usługami,
  • każde żądanie HTTP/komunikat z kolejki miało swój operation_Id,
  • logi, metryki i traces łączyły się po tym samym identyfikatorze.

W ASP.NET Core z Application Insights większość robi się automatycznie, jeśli stosuje się IHttpClientFactory i domyślne middleware. Przy własnym kliencie HTTP trzeba dopilnować kopiowania nagłówków.

Metryki biznesowe i techniczne

Obok metryk technicznych (czas odpowiedzi, błędy 5xx/4xx) przydatne są metryki biznesowe: liczba złożonych zamówień, konwersje, średni czas procesu.

W .NET można wysyłać własne metryki do Application Insights:


public class OrderMetrics
{
    private readonly TelemetryClient _telemetry;

    public OrderMetrics(TelemetryClient telemetry)
    {
        _telemetry = telemetry;
    }

    public void TrackOrderPlaced(decimal totalAmount)
    {
        _telemetry.GetMetric("orders_placed_count").TrackValue(1);
        _telemetry.GetMetric("orders_total_amount").TrackValue((double)totalAmount);
    }
}

Takie metryki łączą się z technicznymi danymi i pomagają zauważyć, że np. spadek konwersji koreluje z gorszym czasem odpowiedzi API.

Alerty i dashboardy dla zespołu

Same logi nie wystarczą – trzeba je przełożyć na alerty i dashboardy, z których zespół faktycznie korzysta. Typowy zestaw:

  • Alert na błędy 5xx powyżej progu (np. >1% żądań).
  • Alert na wzrost czasu odpowiedzi powyżej uzgodnionego SLO.
  • Alert na długość kolejki Service Bus lub liczbę dead-letterów.

Dashboardy warto przygotować osobno dla backendu, jobów w tle i bazy danych. Inny zestaw metryk będzie interesował deweloperów API, a inny osoby dbające o ETL.

Logowanie domenowe i korelacja z kontekstem biznesowym

Logi techniczne (stack trace, status code) mają sens tylko w połączeniu z kontekstem biznesowym: ID klienta, ID zamówienia, klucz tenant’a. Dobrze jest wprowadzić w C# obiekt kontekstu żądania:


public interface IRequestContext
{
    Guid? TenantId { get; }
    Guid? UserId { get; }
    string CorrelationId { get; }
}

Middleware może ten kontekst wypełniać z JWT/nagłówków i udostępniać w DI. Logger używa kontekstu do dodawania właściwości do każdego wpisu (np. poprzez ILogger.BeginScope).

Obserwowalność workerów i Functions

Przy Functions i workerach tłem jest zazwyczaj Service Bus, Event Hub albo Timer. Typowe problemy:

  • ukryte wyjątki „zjadane” przez runtime,
  • brak informacji o „gorących” komunikatach, które powodują dużo retry,
  • brak metryk opóźnienia przetwarzania (lag kolejki).

Dla każdej Function warto dodać:

  • structured log z ID komunikatu i typem,
  • metrykę czasu przetwarzania jednostkowego,
  • metrykę liczby przetworzonych komunikatów na jednostkę czasu.

W praktyce często wychodzi, że jeden typ komunikatu powoduje większość problemów. Bez metryk i logów na poziomie domeny trudno to zauważyć.

Feature flags i eksperymenty kontrolowane

W większych systemach opłaca się wprowadzić feature flags do kontrolowania wydania i obciążenia. Azure App Configuration dobrze się do tego nadaje z integracją dla .NET.


builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(configuration["AppConfig:ConnectionString"])
           .UseFeatureFlags();
});

builder.Services.AddAzureAppConfiguration();

Po stronie kodu nowe ścieżki logiki można przełączać warunkowo:


if (await _featureManager.IsEnabledAsync("NewPricingEngine"))
{
    return await _newPricing.CalculateAsync(order, ct);
}

return await _oldPricing.CalculateAsync(order, ct);

W połączeniu z monitoringiem daje to bezpieczną drogę do włączania nowej funkcjonalności dla małego procenta ruchu i szybkiego wycofania w razie problemów.

Najczęściej zadawane pytania (FAQ)

Jak zaprojektować aplikację C# w Azure, żeby była łatwo skalowalna?

Podstawą jest podejście stateless. Instancje aplikacji nie powinny trzymać krytycznego stanu w pamięci ani na lokalnym dysku – sesje, pliki, cache przenosi się do zewnętrznych usług (baza danych, Blob Storage, Redis, kolejki).

Kod dzieli się na wyraźne warstwy: domenową, aplikacyjną, infrastruktury i prezentacji. Dzięki temu logika biznesowa nie zależy od konkretnej usługi Azure i można zmieniać sposób hostowania (App Service, Functions, kontenery) bez przepisywania całości.

Czym różni się podejście do aplikacji C# on-premise i w chmurze Azure?

On-premise zazwyczaj używa jednego lub kilku stabilnych serwerów, więc naturalne jest trzymanie sesji w pamięci, plików na dysku aplikacji i singletonów z cache w RAM. W Azure instancja może zniknąć w każdej chwili, a kolejne żądania użytkownika trafią na różne maszyny.

W chmurze infrastruktura skaluje się automatycznie na podstawie metryk (CPU, czas odpowiedzi, długość kolejek). To wymusza projektowanie pod skalowanie poziome – wiele lekkich instancji bez lokalnego stanu i z trwałymi danymi w usługach zarządzanych (SQL, Storage, Redis).

Jak oddzielić logikę domenową od infrastruktury Azure w aplikacji .NET?

Stosuje się architekturę warstwową lub heksagonalną (ports and adapters). Logika domenowa (reguły biznesowe, encje) nie powinna znać Azure SDK, EF DbContext ani szczegółów baz i kolejek – komunikuje się z nimi tylko przez interfejsy portów.

W praktyce: warstwa aplikacyjna definiuje interfejsy typu IOrderRepository, warstwa infrastruktury implementuje je za pomocą EF Core, Service Bus czy Blob Storage. Warstwa prezentacji (ASP.NET Core, Azure Functions) tylko mapuje HTTP/zdarzenia na wywołania warstwy aplikacyjnej.

Od czego zacząć migrację monolitu C# do Azure?

Na starcie porządkuje się konfigurację (appsettings.json, IConfiguration) i usuwa twarde zależności od dysku lokalnego, wprowadzając abstrakcje jak IFileStorage. Kolejny krok to wydzielenie logiki biznesowej z kontrolerów do osobnych serwisów domenowych/aplikacyjnych.

Następnie przenosi się dane do usług Azure: SQL Server do Azure SQL, pliki do Blob Storage, sesję do rozproszonego cache (Azure Cache for Redis). Taki monolit można bez większych zmian uruchomić w Azure App Service, początkowo skalując pionowo, a potem testując skalowanie poziome.

Co wybrać dla aplikacji C# w Azure: App Service, Functions, Container Apps czy AKS?

Dla klasycznego API lub panelu webowego najprostszy jest Azure App Service – daje zarządzane środowisko dla ASP.NET Core przy niskiej złożoności operacyjnej. Do zadań event-driven, kolejek, cronów zwykle lepsze są Azure Functions.

Azure Container Apps sprawdza się, gdy potrzebne są kontenery (różne języki, niestandardowe zależności systemowe), ale bez pełnego Kubernetes. AKS opłaca się dopiero przy dużych, złożonych platformach, gdzie rzeczywiście wykorzysta się możliwości Kubernetesa.

Jakie usługi danych w Azure najlepiej współpracują z aplikacjami C#?

Typowy zestaw to: Azure SQL Database jako główna baza relacyjna, Azure Cache for Redis jako rozproszony cache, Azure Blob Storage na pliki i raporty. Taki układ pokrywa większość klasycznych systemów biznesowych.

Gdy potrzebna jest bardzo wysoka przepustowość odczytów/zapisów lub globalna dystrybucja, do wybranych modułów można dołożyć Azure Cosmos DB. Dostęp do tych usług dobrze jest ukryć za repozytoriami i serwisami infrastruktury, żeby warstwa domenowa pozostała niezależna.

Jak obsłużyć komunikację asynchroniczną w aplikacji C# na Azure?

Do kolejek pracy i scenariuszy enterprise messaging często stosuje się Azure Service Bus. Dla prostszych zastosowań wystarczy Queue Storage, a do propagowania zdarzeń między usługami i integracjami zewnętrznymi nadaje się Event Grid.

Kod C# (np. worker service, Azure Function, mikroserwis w kontenerze) odczytuje komunikaty z tych usług i wykonuje logikę biznesową w tle. Pozwala to odciążyć API, skrócić czas odpowiedzi użytkownikowi i skalować przetwarzanie niezależnie od frontu.

Opracowano na podstawie

  • Cloud Design Patterns: Cloud Application Architecture Guide. Microsoft (2014) – Wzorce projektowe dla aplikacji chmurowych, stateless, skalowanie poziome
  • Architecting Cloud Native .NET Applications for Azure. Microsoft .NET Architecture Guides (2020) – Architektura aplikacji .NET w Azure, warstwy, separacja domeny i infrastruktury
  • Azure Application Architecture Guide. Microsoft Azure Architecture Center – Zalecenia dot. projektowania aplikacji w Azure, wybór usług, skalowanie