
Tekst: Paweł Gąsienica-Samek
Zadanie było proste: mała usługa zbierająca pomiary z urządzeń, .NET 10, Entity Framework, baza w Azure SQL, hosting na App Service. Kilka endpointów, dwie tabele. Zakładałem godzinę.
Kod napisał się w kwadrans. Cała reszta poszła na wdrożenie i to ono okazało się właściwą lekcją. Wywaliło się cztery razy, za każdym razem z innego powodu i w innej warstwie — od formatu archiwum po architekturę startu aplikacji.
Opisuję po kolei, bo każda z tych rzeczy dopadnie każdego, kto wdraża z Windowsa na Linuksa. Trzy z czterech miały tę samą cechę: komunikat wskazywał zupełnie inne miejsce niż przyczyna.
Punkt wyjścia
Aplikacja jest banalna. Rejestr urządzeń, pomiary, dwa endpointy do zapisu i odczytu. Wszystko w minimalnym API, bez kontrolerów.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(
builder.Configuration.GetConnectionString("Default"),
sql => sql.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null)));
var app = builder.Build();
app.MapGet("/devices", async (AppDbContext db) =>
await db.Devices
.AsNoTracking()
.Select(d => new DeviceDto(d.Serial, d.Customer, d.Location, d.Measurements.Count))
.ToListAsync());
app.Run();Encje zwykłe, dwie klasy. Jedna rzecz warta uwagi dla kogoś, kto wraca do .NET po dłuższej przerwie: właściwości oznaczone jako wymagane. Kompilator wymusza ich ustawienie przy tworzeniu obiektu, więc nie trzeba już obiecywać mu wykrzyknikiem, że ktoś to kiedyś wypełni.
public class Device
{
public int Id { get; set; }
public required string Serial { get; set; }
public required string Customer { get; set; }
public string? Location { get; set; }
public List<Measurement> Measurements { get; set; } = [];
}Pierwsza: ukośniki w archiwum
Zbudowałem paczkę, wysłałem, dostałem błąd wdrożenia bez sensownego komunikatu — samo „Deployment failed" i kod 400.
PS C:\dev\MyDeviceHub> dotnet publish -c Release -o publish
MyDeviceHub net10.0 powodzenie (4,1s) -> publish\
PS C:\dev\MyDeviceHub> Compress-Archive -Path publish\* -DestinationPath publish.zip
PS C:\dev\MyDeviceHub> az webapp deploy --src-path publish.zip --type zip
Initiating deployment
Deploying from local path: publish.zip
Deployment failed.
Error Code : DeploymentFailed
Kudu Status : 400Kod 400 nie mówi nic. Prawdę widać dopiero w dzienniku wdrożenia po stronie serwera — a tam wysypało się kilkadziesiąt linii tego samego wzorca.
$ az webapp log deployment show -n <app> -g <grupa>
rsync: [generator] recv_generator: failed to stat
"/home/site/wwwroot/runtimes\linux-x64\native\libe_sqlite3.so": Invalid argument (22)
rsync: [generator] recv_generator: failed to stat
"/home/site/wwwroot/runtimes\win-x64\native\Microsoft.Data.SqlClient.SNI.dll": Invalid argument (22)
rsync: [generator] recv_generator: failed to stat
"/home/site/wwwroot/pl\Microsoft.Data.SqlClient.resources.dll": Invalid argument (22)
rsync error: some files/attrs were not transferred (code 23) at main.c(1338)
Deployment Failed. deployer = OneDeployPopatrz na ścieżki. Ukośniki odwrotne w środku, tam gdzie powinny być zwykłe. Standard ZIP wymaga ukośników zwykłych w ścieżkach wewnętrznych — Linux widzi więc nie ścieżkę, tylko jedną nazwę pliku zawierającą znaki, których w nazwie być nie może. Stąd „Invalid argument".
Windows PowerShell 5.1 stoi na starym .NET Framework, który tego nie przestrzega i zapisuje separator systemowy. Pliki z korzenia paczki przechodzą, wszystko z podkatalogów pada. A w publikacji .NET podkatalogów jest sporo, bo sterownik SQL Servera ciągnie natywne biblioteki dla wszystkich platform świata.
Zamiana na klasę z przestrzeni System.IO.Compression nic nie dała — ta sama biblioteka pod spodem, ten sam efekt. Działa dopiero jedno z dwóch:
# 1. tar z Windowsa - zapisuje zgodnie ze standardem
tar.exe -a -c -f publish.zip -C publish .
# 2. albo PowerShell 7, ktory stoi na nowoczesnym .NET
winget install Microsoft.PowerShell
pwsh -c 'Compress-Archive -Path publish\* -DestinationPath publish.zip'Druga: limit procesora na darmowym planie
Po poprawieniu paczki wdrożenie zaczęło zwracać 403 ze stroną „Web App - Unavailable". Jedno polecenie wyjaśniło wszystko.
PS C:\dev\MyDeviceHub> az webapp show -n <app> -g <grupa> --query state -o tsv
QuotaExceededPlan F1 daje sześćdziesiąt minut czasu procesora na dobę. Brzmi dużo, dopóki nie zauważysz, że rozpakowanie paczki i start aplikacji też z tego korzystają — a każda nieudana próba zaczyna od zera. Trzy nieudane wdrożenia i limit zniknął. Aplikacja wraca dopiero po północy czasu UTC.
Wniosek nie jest taki, że darmowy plan jest zły. Jest taki, że darmowy plan nie nadaje się do iterowania. Do obejrzenia portalu wystarczy, do pracy nie.
az appservice plan update --name <plan> --resource-group <grupa> --sku B1Trzecia: niekompletny katalog po nieudanym rsyncu
Trzecia próba dała błąd, którego wcześniej nie było. Aplikacja wstawała i natychmiast padała — na bibliotece, którą przecież wysyłałem.
Running the command: dotnet "MyDeviceHub.dll"
Unhandled exception. System.TypeInitializationException:
The type initializer for 'SqlServerTypeMappingSource' threw an exception.
---> System.IO.FileNotFoundException: Could not load file or assembly
'Microsoft.Data.SqlClient, Version=6.0.0.0'. The system cannot find the file.
at MyDeviceHub.AppDbContext.get_Devices() in AppDbContext.cs:line 11
at Program.<Main>$(String[] args) in Program.cs:line 28
/opt/startup/startup.sh: line 20: Aborted (core dumped) dotnet "MyDeviceHub.dll"
Container has finished running with exit code: 134.Przyczyna była pozostałością po pierwszej awarii. Rsync skopiował to, co umiał — czyli pliki z korzenia — i przerwał na podkatalogach, zostawiając katalog docelowy w stanie mieszanym. Kolejne wdrożenia dokładały do bałaganu, bo domyślnie nie czyszczą katalogu, tylko synchronizują różnice.
Lekarstwo to jedna flaga:
az webapp deploy --resource-group <grupa> --name <app> \
--src-path publish.zip --type zip \
--clean true --restart trueWarto o niej wiedzieć zawczasu, bo objaw — brakująca biblioteka — kieruje podejrzenia na pakiety i zależności, czyli w zupełnie złą stronę. Straciłem na to najwięcej czasu z całej czwórki.
Czwarta: baza przy starcie aplikacji
Ostatnia jest najciekawsza, bo to nie usterka narzędzi, tylko mój błąd projektowy.
W kodzie miałem blok zasiewający dane startowe: przy uruchomieniu aplikacja sprawdzała, czy w bazie są urządzenia, i jeśli nie — dodawała dwa przykładowe. Wygodne lokalnie.
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (!await db.Devices.AnyAsync()) // baza niedostepna => wyjatek
{ // => proces sie wywraca
db.Devices.AddRange(/* ... */); // => kontener nigdy nie wstaje
await db.SaveChangesAsync();
}W chmurze oznacza to, że niedostępna baza zamienia się w niedziałającą aplikację. A baza bywa niedostępna zupełnie normalnie: Azure SQL w wariancie serverless usypia po godzinie bezczynności i wybudza się kilkanaście sekund.
PS C:\dev\MyDeviceHub> dotnet ef database update
System.InvalidOperationException: An exception has been raised that is likely
due to a transient failure. Consider enabling transient error resiliency by
adding 'EnableRetryOnFailure' to the 'UseSqlServer' call.
---> Microsoft.Data.SqlClient.SqlException (0x80131904):
Database 'devicehub' is not currently available. Please retry later.
Numer bledu: 40613, stan: 1, klasa: 20Dwie poprawki. Pierwsza to obudowanie zasiewu w obsługę błędu, żeby awaria bazy trafiała do logów zamiast wywracać proces. Druga to ponawianie po stronie dostawcy — mechanizm zna listę błędów uznanych za przejściowe i 40613 jest na niej.
try
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (!await db.Devices.AnyAsync())
{
db.Devices.AddRange(/* ... */);
await db.SaveChangesAsync();
}
}
catch (Exception ex)
{
app.Logger.LogError(ex, "Nie udalo sie zasiac danych startowych");
}Jedna pułapka przy ponawianiu, o którą pytają na rozmowach: przy włączonej strategii nie wolno otwierać transakcji wprost. Skoro operacja może zostać powtórzona, strategia musi wiedzieć, co dokładnie powtórzyć — a transakcja rozpoczęta ręcznie tego nie mówi.
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync();
// ...
await tx.CommitAsync();
});Co z tego zostaje
Trzy z czterech awarii miały wspólną cechę: komunikat wskazywał zupełnie inne miejsce niż przyczyna. Brakująca biblioteka to był nieposprzątany katalog. Kod 403 to był limit procesora. Nieudane rozpakowanie to był separator ścieżki w archiwum zbudowanym na innym systemie.
Żadnej z nich nie dałoby się rozwiązać zgadywaniem i przy żadnej nie pomogłoby powtórzenie tej samej czynności. Za każdym razem trzeba było zejść do logów — do dziennika wdrożenia, do stanu zasobu, do dziennika kontenera — i przeczytać, co system naprawdę mówi.
To zresztą jedyna umiejętność, która przy wdrożeniach naprawdę się liczy. Reszta to znajomość konkretnych flag, a tych i tak nikt nie pamięta.
Aha, i jeszcze jedno. Pierwsza publikacja tego tekstu zwróciła 404, bo ustawiłem datę pięć minut w przyszłość, a silnik nie pokazuje wpisów, których czas jeszcze nie nadszedł. Piąta awaria, w tekście o czterech.