Dodajesz drugą instancję aplikacji ASP.NET Core. Kod się nie zmienia, load balancer rozkłada ruch, wszystko wygląda dobrze. A następnego dnia klienci piszą, że znika im koszyk, a sklep co chwilę ich wylogowuje.
To jeden z najbardziej zaskakujących momentów w życiu aplikacji webowej. Na jednym serwerze wszystko działało latami, a na 2 serwerach nagle pojawiają się błędy, których nie da się odtworzyć lokalnie. Powód jest prosty: aplikacja po cichu trzymała stan w pamięci albo na dysku jednej maszyny - i dopóki maszyna była jedna, nikt tego nie zauważał.
W tym artykule przejdziemy przez 5 miejsc, które psują się po dodaniu drugiego serwera: koszyk w IMemoryCache, sesję na AddDistributedMemoryCache, klucze Data Protection, pliki zapisywane na dysk i zadania w tle (BackgroundService). Przy każdym pokażę, jak je naprawić, z kodem z działającego sklepu Dioda - repozytorium mojego kursu (firma i dane są fikcyjne, kod działa). Na koniec dostaniesz ściągę, którą warto mieć pod ręką, zanim dołożysz drugą instancję.
Wersje .NET: przykłady pochodzą ze sklepu w .NET 10, ale opisane mechanizmy (IMemoryCache, sesja, Data Protection, BackgroundService) działają tak samo we wcześniejszych wersjach, np. w .NET 8 i 9.
Znikający koszyk krok po kroku
Zacznijmy od tego, co widzi klient. Sklep Dioda, tuż po uruchomieniu drugiej instancji:
1. Klient dodaje słuchawki do koszyka.
2. Load balancer kieruje żądanie (POST /koszyk) do instancji A.
3. A zapisuje koszyk w swojej pamięci.
4. Sklep przekierowuje klienta na stronę koszyka.
5. Tym razem load balancer wybiera instancję B (GET /koszyk).
6. B nie ma tego koszyka, więc klient widzi pusty koszyk.
U mnie, na 2 instancjach, koszyk miał w kolejnych żądaniach na zmianę 0 i 1 sztukę. Odświeżasz stronę - jest. Odświeżasz jeszcze raz - nie ma. Dla klienta wygląda to jak losowa awaria, a dla programisty jak błąd, którego "u mnie nie ma", bo lokalnie zawsze działa jedna instancja.
Zasada: każde żądanie może trafić do dowolnej instancji
Zapamiętaj tę zasadę, bo z niej wynika cała reszta artykułu:
Każde żądanie może trafić do dowolnej instancji.
Wszystko, co aplikacja trzyma w pamięci jednej instancji (albo na jej dysku), dla pozostałych nie istnieje. Dlatego przed dodaniem drugiego serwera trzeba znaleźć każde takie miejsce i przenieść ten stan tam, gdzie widzą go wszystkie instancje.
Można by próbować obejść problem tzw. sticky sessions, czyli ustawieniem load balancera tak, żeby klient zawsze trafiał do tej samej instancji. Ale to tylko zamiatanie pod dywan: gdy instancja padnie albo zostanie zrestartowana przy wdrożeniu, jej klienci i tak stracą koszyki i logowanie. Lepiej naprawić przyczynę.
Miejsce 1: koszyk w IMemoryCache
Tak wyglądał koszyk w kodzie sklepu przed naprawą (CartService.cs):
return cache.GetOrCreate($"koszyk:{cartId}", entry =>
{
entry.SlidingExpiration = CartLifetime;
return new ShoppingCart();
})!;Koszyk trafia do IMemoryCache, czyli do pamięci procesu, który obsłużył żądanie. Na jednej instancji to działa świetnie: szybko, prosto, bez bazy. Na 2 instancjach koszyk istnieje tylko tam, gdzie klient akurat go utworzył.
A tak wygląda po naprawie:
return await db.CartItems
.AsNoTracking()
.Where(i => i.CartId == cartId)
.OrderBy(i => i.UpdatedAt)
.ToListAsync(cancellationToken);Pozycje koszyka leżą w tabeli w bazie, z kluczem z identyfikatora koszyka i numeru produktu. Każda instancja czyta ten sam koszyk.
Przy większym ruchu koszyk może trafić do Redisa. Ale uwaga: wtedy Redis jest dla koszyka magazynem danych, a nie cache. To ważne rozróżnienie. Cache to kopia danych, którą można w każdej chwili wyrzucić i odtworzyć ze źródła. Koszyka nie da się odtworzyć - jeśli zniknie, klient traci to, co wybrał. Dlatego Redis używany jako magazyn koszyków musi mieć zapewnioną trwałość i nie może po cichu usuwać kluczy, gdy zabraknie mu pamięci.
Miejsce 2: AddDistributedMemoryCache to nie jest wspólny cache
Druga pułapka to jedna nazwa. AddDistributedMemoryCache() brzmi jak cache rozproszony, ale trzyma dane w pamięci jednej instancji. To implementacja interfejsu IDistributedCache przeznaczona do testów i do aplikacji na jednym serwerze.
Problem robi się poważny, gdy stoi na niej sesja. Sesja w ASP.NET Core zapisuje dane w IDistributedCache, więc jeśli tym cache jest AddDistributedMemoryCache, to na 2 instancjach sesja też znika. Kod wygląda niewinnie, bo w Program.cs widzisz słowo "Distributed" i masz wrażenie, że sprawa jest załatwiona.
Naprawa ma 2 warianty:
• Przenieś dane do ciasteczka, jeśli to drobne informacje, które mogą podróżować z klientem. W sklepie Dioda tak właśnie zrobiliśmy: zamiast sesji jest ciasteczko.
• Podłącz prawdziwy wspólny cache, np. Redisa, jako implementację IDistributedCache. Wtedy sesja żyje poza instancjami:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});
builder.Services.AddSession(); /* sesja w Redisie, wspólna dla instancji */Przejrzyj swój Program.cs pod kątem AddDistributedMemoryCache. Jeśli go tam znajdziesz, a aplikacja ma działać na więcej niż jednym serwerze, to masz właśnie drugie miejsce do poprawienia.
Miejsce 3: klucze Data Protection i "sklep mnie wylogowuje"
Trzecie miejsce jest trudniejsze do zauważenia, bo nie ma go nigdzie w Twoim kodzie. Zobacz, co się dzieje:
1. Klient loguje się na instancji A.
2. A szyfruje ciasteczko logowania swoim kluczem Data Protection.
3. I odsyła je klientowi.
4. Następne żądanie (GET /konto) trafia do instancji B, razem z ciasteczkiem.
5. B nie ma klucza A, więc ciasteczka nie odczyta.
6. Dla B klient jest niezalogowany i dostaje przekierowanie do logowania.
Efekt: klient pisze, że sklep co chwilę go wylogowuje.
Domyślnie każdy serwer trzyma klucze Data Protection na swoim dysku. Na jednej maszynie nie ma z tym problemu. Na 2 maszynach każda ma własne, inne klucze - a te same klucze chronią nie tylko ciasteczka logowania, ale też np. tokeny antyforgery w formularzach.
Naprawa to jedno wywołanie w pliku Program.cs:
/* Klucze szyfrujące ciasteczka logowania w bazie:
każda instancja sklepu odczyta ciasteczko z innej */
builder.Services.AddDataProtection()
.PersistKeysToDbContext<ShopDbContext>()
.SetApplicationName("dioda"); • PersistKeysToDbContext zapisuje klucze w tabeli w bazie, więc każda instancja czyta te same. Do tego potrzebny jest pakiet Microsoft.AspNetCore.DataProtection.EntityFrameworkCore, a Twój DbContext musi implementować interfejs IDataProtectionKeyContext (czyli mieć DbSet na klucze).
• SetApplicationName ustawia wspólną nazwę aplikacji. Bez niej ASP.NET Core oddziela aplikacje po ścieżce na dysku - a ta na różnych serwerach może być różna, więc instancje nadal nie odczytałyby swoich ciasteczek.
Zamiast bazy klucze można trzymać także w Redisie albo we wspólnym magazynie plików. Ważne, żeby wszystkie instancje sięgały do tego samego miejsca.
I jeszcze jedno: klucze to sekret. Kto je ma, podrobi ciasteczko logowania. Dlatego na produkcji szyfrujesz je dodatkowo certyfikatem albo trzymasz w Key Vault.
Miejsce 4: zdjęcia zapisywane na dysk serwera
Czwarte miejsce to pliki. Panel administracyjny Diody zapisuje nowe zdjęcie produktu w folderze wwwroot/uploads serwera, który obsłużył żądanie. Czyli na dysku instancji A.
Co drugie żądanie o to zdjęcie trafia do B, a B go nie ma. Klient widzi produkt bez zdjęcia albo z ikoną zepsutego obrazka - i to tylko czasami, co znowu utrudnia diagnozę.
Po naprawie zdjęcia leżą we wspólnym magazynie plików, więc każda instancja widzi każde zdjęcie, a klientom podaje je CDN. W sklepie Dioda ścieżka do zdjęć jest ustawieniem konfiguracji (Photos:Path) i wskazuje wspólny folder. W chmurze najczęściej będzie to magazyn obiektów (np. Azure Blob Storage), a przed nim CDN, który dodatkowo zdejmuje z aplikacji ruch na pliki statyczne.
Ta sama zasada dotyczy wszystkiego, co aplikacja zapisuje na dysk: wygenerowanych faktur PDF, eksportów, plików tymczasowych, które ma potem pobrać użytkownik. Jeśli plik ma przeżyć jedno żądanie, nie może leżeć na dysku jednej instancji.
Miejsce 5: BackgroundService na każdej instancji
Piąte miejsce to zadanie w tle. W sklepie co 5 minut działa BackgroundService, który anuluje zamówienia nieopłacone od 3 dni. Na jednym serwerze - bez zarzutu. Ale BackgroundService uruchamia się w każdej instancji aplikacji. Na 2 serwerach dzieje się tak:
1. A pyta bazę o nieopłacone zamówienia.
2. Chwilę później to samo robi B.
3. A dostaje zamówienie 1041.
4. B też, bo A jeszcze nie skończyła.
5. A anuluje zamówienie i wysyła maila.
6. B robi to samo drugi raz.
Klient dostaje 2 maile, a towar wraca na stan 2 razy. I to nie za każdym razem, tylko wtedy, gdy przebiegi obu instancji akurat na siebie trafią - więc trudno to złapać.
Naprawa: blokada przed przebiegiem. Zanim zadanie zacznie pracę, próbuje wziąć blokadę w bazie o stałej nazwie (UnpaidOrdersCanceller.cs):
await using var dbLock = await DatabaseLock.TryAcquireAsync(
db, LockName, cancellationToken);
if (dbLock is null)
{
logger.LogInformation(…);
return;
}Jeśli blokadę ma już inna instancja, ta zapisuje to w logu i kończy przebieg. Pod spodem klasa DatabaseLock woła procedurę sp_getapplock z SQL Server, z parametrem @LockTimeout = 0, czyli bez czekania. Przebieg robi tylko ta instancja, która dostała blokadę. Dzięki await using blokada zwalnia się sama po zakończeniu przebiegu.
Fajne w tym rozwiązaniu jest to, że nie potrzebujesz żadnej nowej infrastruktury - wystarczy baza, którą już masz. Alternatywą jest osobny worker w jednej kopii: zadania w tle wynosisz z aplikacji webowej do osobnego procesu, który zawsze działa w jednym egzemplarzu, niezależnie od tego, ile instancji ma aplikacja.
Ściąga: stan w pamięci - gdzie go przenieść
Na koniec ściąga, którą warto mieć przed dodaniem drugiego serwera:
| Stan | Gdzie przenieść | W sklepie Dioda |
| Koszyk | baza albo Redis jako magazyn danych | tabela CartItems |
| Sesja | ciasteczko albo wspólny cache | ciasteczko dioda-ostatnio |
| Klucze Data Protection | baza, Redis albo magazyn plików | tabela w bazie |
| Pliki | magazyn plików i CDN | wspólny folder Photos:Path |
| Zadania w tle | blokada w bazie albo worker w jednej kopii | sp_getapplock |
Przejdź tę tabelę wiersz po wierszu dla swojej aplikacji. Każdy wiersz, przy którym odpowiesz "u nas to siedzi w pamięci albo na dysku serwera", to potencjalne zgłoszenie od klienta dzień po dodaniu drugiej instancji.
Jak znaleźć te miejsca we własnej aplikacji
Zanim dołożysz drugą instancję, zrób szybki przegląd kodu. Szukaj po kolei:
• IMemoryCache i MemoryCache. Sprawdź, co tam trzymasz. Kopie danych z bazy (np. lista kategorii) mogą zostać - każda instancja ma wtedy własną kopię i co najwyżej przez chwilę widzi trochę starsze dane. Ale dane, których nie da się odtworzyć ze źródła (koszyk, stan procesu zakupowego), muszą trafić do bazy albo do wspólnego magazynu.
• AddDistributedMemoryCache i AddSession w Program.cs. Jeśli są razem, sesja żyje w pamięci jednej instancji.
• AddDataProtection. Jeśli w ogóle go nie ma albo nie ma przy nim PersistKeysTo... i SetApplicationName, klucze lądują osobno na dysku każdej instancji.
• Zapisy na dysk: wwwroot, File.Write..., ścieżki budowane od katalogu aplikacji. Każdy plik, który ma przeżyć żądanie, musi trafić do wspólnego magazynu.
• Klasy dziedziczące po BackgroundService albo implementujące IHostedService. Przy każdej zadaj pytanie: co się stanie, gdy ta praca wykona się 2 razy naraz?
• Statyczne pola i singletony ze stanem, np. słowniki w pamięci. To ten sam problem w innym przebraniu: każda instancja ma własną kopię.
Potem sprawdź to w praktyce: uruchom lokalnie 2 instancje aplikacji za prostym load balancerem i przeklikaj najważniejsze ścieżki - koszyk, logowanie, dodanie zdjęcia w panelu. Błędy, których na jednej instancji nie da się odtworzyć, na 2 instancjach pojawią się od razu.
Podsumowanie
Zasada jest jedna: każde żądanie może trafić do dowolnej instancji. Wszystko, co aplikacja trzyma w pamięci albo na dysku jednego serwera, trzeba przenieść tam, gdzie widzą to wszystkie instancje:
• koszyk z IMemoryCache do bazy (albo do Redisa jako magazynu danych),
• sesja z AddDistributedMemoryCache do ciasteczka albo do prawdziwego wspólnego cache,
• klucze Data Protection do bazy, Redisa albo magazynu plików, ze wspólną nazwą aplikacji i zaszyfrowane na produkcji,
• pliki z wwwroot do wspólnego magazynu z CDN,
• zadania w tle za blokadą w bazie albo do osobnego workera.
Przejrzyj swoją aplikację, zanim dołożysz drugi serwer, a nie dzień po.
To tylko jeden z tematów, które pojawiają się, gdy aplikacja rośnie. Jeśli chcesz przejść całą tę drogę - od szacowania ruchu, przez skalowanie, bazę danych, cache, kolejki i odporność na awarie, aż po architekturę i rozmowę system design na seniora - zapraszam Cię do mojego kursu Szkoła System Design. To ponad 20 godzin nauki: 12 modułów, 7 laboratoriów na działającym sklepie Dioda w .NET 10 (poprawki z tego artykułu są tam omówione na tym samym kodzie) i 11 zadań projektowych z omówieniami.
Szczegóły znajdziesz tutaj: modestprogrammer.pl/szkola-system-design. Zapisy otwieram tylko na tydzień - jeśli akurat są zamknięte, zostaw tam e-mail na liście oczekujących, a dam Ci znać o następnej edycji. Jeśli trwają, zapiszesz się od razu.
A co psuło się u Ciebie po dodaniu drugiego serwera? :)