.NET 8 auf .NET 10 migrieren: Breaking Changes, EF Core und Docker
Von net8.0 auf net10.0: TFM, EF-Core-Migrationen, das neue Ubuntu-Basis-Image und Breaking Changes in ASP.NET Core 10 — ein durchgehendes Beispiel.
Dieser Artikel setzt voraus, dass Sie die Entscheidung getroffen haben: .NET 10 LTS. Wenn Sie noch im „Warum jetzt"-Stadium sind, lesen Sie zuerst den Überblick .NET 8 und .NET 9 laufen im November 2026 aus. Hier geht es um das Wie: net8.0 → net10.0, an einer konkreten Solution — Minimal API mit EF Core 10 und SQL Server, ein BackgroundService, Multi-Stage-Dockerfile, Traefik als Reverse Proxy, GitHub Actions als CI. Die Schrittfolge richtet sich nach der Reihenfolge, in der Fehler auftreten.
Das Beispiel: eine Solution, zwei Projekte
Die Solution enthält zwei Projekte, die denselben Upgrade-Pfad durchlaufen:
- Api — Minimal API mit EF Core 10, SQL Server als Datenbankbackend, hinter Traefik
- Worker —
BackgroundServicemitPeriodicTimer, liest dieselbe Datenbank
+-------------------+ +--------------------+
| Browser / Client | | Worker-Container |
+--------+----------+ +--------+-----------+
| |
+----+------+ +-----+------+
| Traefik | | SQL Server|
| :443 | | :1433 |
+----+------+ +-----+------+
| ^
+------+---------+ |
| Api-Container +---EF Core ---+
| :8080 intern |
+----------------+
TFM und SDK: der Wechsel, der alles in Bewegung setzt
Die einzig zwingende Änderung ist der Tausch des Target Framework Monikers. Bei einer Multi-Projekt-Solution empfiehlt sich die zentrale Steuerung über Directory.Build.props im Solution-Root:
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Mit dem TFM-Wechsel springt die Sprachversion automatisch von C# 12 auf C# 14. Das ist in den meisten Codebasen unsichtbar — außer wenn ein Property-Accessor einen Bezeichner namens field enthält. field ist in C# 14 das Schlüsselwort für das synthetisierte Backing Field; eine lokale Variable dieses Namens erzeugt jetzt:
error CS9272: 'field' is a keyword within a property accessor.
Rename the variable or use the identifier '@field' instead.
Umbenennen oder @field löst das Problem in einer Zeile.
NETSDK1045 — wenn das SDK nicht mitzieht
Wer den TFM-Wechsel vornimmt, ohne das SDK zu aktualisieren, sieht unmittelbar:
NETSDK1045: The current .NET SDK does not support 'net10.0' as a target.
Either target 'net8.0' or lower, or use a version of the .NET SDK that supports 'net10.0'.
Die häufigste Ursache: global.json pinnt noch auf eine 8.x-Version. dotnet --list-sdks zeigt, was installiert ist. Nach der SDK-Installation:
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
rollForward: latestFeature erlaubt neuere Feature-Bänder (10.0.200 usw.) ohne die Versionspinnung aufzugeben.
Direkt 8 → 10, nicht über 9 — und warum nicht auf .NET 11 warten
.NET 8 und .NET 9 laufen am 10. November 2026 am selben Tag aus. Ein Zwischenstop bei .NET 9 bedeutet eine zweite Migration innerhalb von Wochen, ohne jeglichen zusätzlichen Support-Zeitraum. .NET 11 abzuwarten ist keine stabilere Wahl: Es ist als STS nur zwei Jahre unterstützt und erscheint erst am 10.11.2026 — also genau am EOL-Tag von .NET 8, und mit kürzerer Laufzeit als .NET 10 LTS (bis November 2028). Der dritte Grund ist EF Core: „EF10 will not run on earlier .NET versions" — EF Core 10 setzt zwingend .NET 10 voraus. Wer EF Core 9 auf net8.0 nutzt, macht das Upgrade vollständig jetzt, mit .NET 10.
Pakete, NuGet-Audit und eine neue stille Fehlerquelle
dotnet list package --outdated
Alle Microsoft.AspNetCore.*, Microsoft.EntityFrameworkCore.*, Microsoft.Extensions.* und System.Net.Http.Json auf 10.0.x heben. Die EF-Core-Tools müssen denselben Major-Version-Stand haben:
dotnet tool update --global dotnet-ef
Ab net10.0 als TFM schaltet NuGet den Audit-Modus auf all um — transitive Pakete werden auf Schwachstellen geprüft. Bei TreatWarningsAsErrors=true bricht das sofort den Build. Die sauberere Lösung gegenüber einem Downgrade auf NuGetAuditMode=direct ist die gezielte Ausnahme, kombiniert mit dotnet nuget why <Paketname> zur Analyse der Abhängigkeitskette:
<PropertyGroup>
<WarningsNotAsErrors>
NU1901;NU1902;NU1903;NU1904;$(WarningsNotAsErrors)
</WarningsNotAsErrors>
</PropertyGroup>
System.Linq.Async herausnehmen
System.Linq.AsyncEnumerable ist ab .NET 10 Teil der Standardbibliothek. Wer das Community-Paket System.Linq.Async bisher direkt referenziert hat, bekommt beim Kompilieren Mehrdeutigkeitsfehler auf LINQ-Methoden für IAsyncEnumerable<T>. Die Paket-Referenz aus der csproj entfernen; bei transitiver Nutzung durch ein anderes Paket <ExcludeAssets>compile</ExcludeAssets> setzen.
EF Core 10: Migrationen sauber halten
Was PendingModelChangesWarning beim Upgrade bedeutet
Das Paket-Upgrade ändert den internen Model-Snapshot von EF Core. Ab EF Core 9 wirft MigrateAsync() eine Exception, sobald das Laufzeitmodell von der letzten gespeicherten Migration abweicht:
The model for context 'AppDbContext' has pending changes. Add a new migration
before updating the database. This exception can be suppressed or logged by
passing event ID 'RelationalEventId.PendingModelChangesWarning' to the
'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.
Fix: nach dem Paket-Upgrade eine neue (ggf. inhaltlich leere) Migration erzeugen, bevor Sie deployen. Als CI-Gate eignet sich dotnet ef migrations has-pending-model-changes — gibt er mit Exit-Code 1 zurück, fehlt eine Migration und der Build bricht korrekt ab.
Migrations-Bundle statt Migrate() beim Container-Start
Migrate() beim App-Start ist möglich, hat aber laut EF-Core-Dokumentation dokumentierte Vorbehalte: DDL-Rechte für die Anwendung, kein SQL-Review, kein Rollback. Das Migrations-Bundle als einmaliger Compose-Job ist die sauberere Wahl:
dotnet ef migrations bundle --self-contained -r linux-x64 \
--project src/Api --startup-project src/Api \
--output ./docker/efbundle
Im Compose-Stack läuft das Bundle als kurzlebiger Service; die API-Container warten via condition: service_completed_successfully:
services:
db-migrate:
image: my-app:${VERSION}
entrypoint: /app/efbundle
environment:
ASPNETCORE_ENVIRONMENT: Production
ConnectionStrings__Default: "${DB_CONNECTION}"
depends_on:
db:
condition: service_healthy
restart: "no"
api:
image: my-app:${VERSION}
depends_on:
db-migrate:
condition: service_completed_successfully
stop_grace_period: 35s
Der seit EF Core 9 eingebaute Migrations-Lock verhindert parallele Ausführung durch mehrere Replicas. Wie depends_on-Bedingungen und Healthchecks im Stack korrekt verdrahtet werden, zeigt Docker HEALTHCHECK und restart_policy.
Das Dockerfile nach dem Basiswechsel
Die Änderung am Dockerfile besteht aus zwei FROM-Zeilen. Was sich dahinter ändert, ist die Basis-Distribution — und das hat messbare Konsequenzen.
Zwei Nächte Docker: Debian raus, Ubuntu rein — Zahlen vom VPS
.NET 8 nutzte Debian 12 als Standard-Basis. .NET 10 nutzt Ubuntu 24.04 (Noble Numbat) — Debian-Images werden für .NET 10 nicht mehr ausgeliefert. Gemessen am 24.09.2026 auf einem arm64-VPS (Docker Engine 29.8.0, kein lokaler Cache) per docker images nach docker pull:
| Image | Basis-OS | Größe entpackt | Pull-Dauer | Runtime |
|---|---|---|---|---|
aspnet:8.0 |
Debian 12 | 249 MB | 7,4 s | 8.0.31 |
aspnet:10.0 |
Ubuntu 24.04 | 262 MB | 7,7 s | 10.0.12 |
aspnet:10.0-noble-chiseled |
Ubuntu 24.04 (kein Shell) | 131 MB | 3,6 s | 10.0.12 |
Der direkte Wechsel von aspnet:8.0 auf aspnet:10.0 macht das Runtime-Image 5 % größer (249 → 262 MB) — der Distro-Wechsel von Debian nach Ubuntu erklärt das. Erst aspnet:10.0-noble-chiseled halbiert die Größe auf 131 MB bei identischer Runtime-Version (10.0.12 auf beiden). Wie das Multi-Stage-Dockerfile aufgebaut ist, beschreibt Multi-Stage-Dockerfile für .NET und Node.js.
aspnet:10.0-noble-chiseled: halb so groß, aber ohne Shell
Chiseled-Images laufen per Default als Non-Root-User (UID 1654), enthalten keinen Paketmanager und keine Shell. Die konkreten Einschränkungen:
HEALTHCHECK CMD curl ...undCMD sh -c ...funktionieren nicht; die Prüfung muss über den Reverse Proxy (Traefik-Healthcheck) oder einen im Image vorhandenen Prüfer erfolgen.- Standardmäßig keine ICU-Bibliotheken: kultursensitive String-Operationen schlagen fehl. Abhilfe:
aspnet:10.0-noble-chiseled-extra(enthält ICU und tzdata) oderDOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1.
Für Apps hinter Traefik ohne Shell-basierte Runtime-Checks ist chiseled die empfehlenswerte Wahl. Unabhängig vom Image-Typ gilt: Der Generic Host empfängt SIGTERM korrekt (HostOptions.ShutdownTimeout Default: 30 Sekunden). Da Docker laut Docker-Dokumentation nach docker stop per Default nach 10 Sekunden per SIGKILL beendet, gehört stop_grace_period: 35s in die Compose-Konfiguration.
ASP.NET Core 10: drei Breaking Changes aus dem laufenden Betrieb
Forwarded Headers und der Log-Eintrag „Unknown proxy"
Seit ASP.NET Core 8.0.17 verwirft die Forwarded-Headers-Middleware X-Forwarded-*-Header von nicht konfigurierten Proxys. Ohne eingetragene Proxy-IP findet sich im Log Unknown proxy: 10.0.0.100:54321; HTTPS-Redirects und Auth-Prüfungen laufen dann ins Leere. In .NET 10 sind IPNetwork und KnownNetworks als ASPDEPR005 obsolet; die neuen Typen sind System.Net.IPNetwork und KnownIPNetworks:
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownProxies.Add(IPAddress.Parse("10.0.0.100"));
// Traefik-Docker-Subnetz als IPNetwork:
options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse("172.16.0.0"), 12));
});
app.UseForwardedHeaders(); // vor UseHsts()
Cookie-Auth liefert jetzt 401 statt 302
[ApiController]-Endpunkte und Minimal APIs mit JSON-Response liefern bei fehlender Authentifizierung ab .NET 10 401/403 statt Redirect auf die Login-URL. Für eine reine Web-API ist das das korrekte Verhalten; SPAs bemerken die Änderung in der Regel nicht. Wer für einzelne Endpunkte das Redirect-Verhalten behalten möchte:
app.MapGet("/dashboard", () => Results.Ok())
.RequireAuthorization()
.AllowCookieRedirect();
WithOpenApi ist deprecated
Das neue Web-API-Template nutzt AddOpenApi()/MapOpenApi() und liefert das Dokument unter /openapi/v1.json (ab .NET 10 im Format OpenAPI 3.1). WithOpenApi() ist als ASPDEPR002 deprecated. Wer Operationen anpassen möchte:
app.MapGet("/products", GetProducts)
.AddOpenApiOperationTransformer((op, ctx, ct) => {
op.Summary = "Alle Produkte";
return Task.CompletedTask;
});
Swagger UI funktioniert weiterhin über Swashbuckle.AspNetCore.SwaggerUi mit Endpunkt /openapi/v1.json.
CI mit GitHub Actions
actions/setup-dotnet@v6 unterstützt dotnet-version: '10.0.x' und global-json-file. Für die Übergangsphase bietet sich eine kurzzeitige Matrix an:
strategy:
matrix:
dotnet: ['8.0.x', '10.0.x']
steps:
- uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ matrix.dotnet }}
- run: dotnet restore
- run: dotnet build --configuration Release --no-restore
- run: dotnet test --no-restore
MTP beim Upgrade nicht aktivieren
.NET 10 unterstützt neben VSTest auch Microsoft Testing Platform (MTP), aktivierbar über global.json. Die Umstellung gilt repoweit — kein einziges Testprojekt darf danach noch VSTest nutzen. Während die 8/10-Übergangsmatrix noch läuft, ist MTP nicht zu aktivieren: Testprojekte, die noch auf net8.0 zeigen, würden fehlschlagen. MTP ist eine separate Migration nach dem abgeschlossenen TFM-Wechsel. Wie GitHub Actions, GHCR-Push und der Build-Workflow strukturiert sind, beschreibt GitHub Actions CI für .NET und GHCR.
Fragen aus dem Migrations-Alltag
Muss ich gleichzeitig auf EF Core 10 wechseln?
Nein. EF Core 9 läuft auf net10.0. EF Core 10 läuft jedoch nicht auf früheren .NET-Versionen. Die Empfehlung ist trotzdem, beides zusammen zu machen: EF Core 9 hat denselben EOL-Termin (10.11.2026), und EF Core 10 bringt benannte Query-Filter, vereinfachten ExecuteUpdateAsync-Syntax und LeftJoin/RightJoin als echte LINQ-Operatoren.
Was passiert, wenn ASPNETCORE_ENVIRONMENT vor dem Bundle-Build fehlt?
Das Bundle wird mit Development-Defaults gebaut — falscher Connection String, falsche Feature-Flags. Die Umgebungsvariable muss sowohl beim dotnet ef migrations bundle-Befehl als auch beim späteren Ausführen des Bundles explizit gesetzt sein.
Das chiseled-Image wirft beim Start eine Globalisierungsausnahme.
Chiseled-Images enthalten keine ICU-Bibliotheken. Kultursensitive Zeichenkettenvergleiche und bestimmte Kalender-Operationen schlagen fehl. Abhilfe: aspnet:10.0-noble-chiseled-extra verwenden (enthält ICU und tzdata) oder DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 setzen, sofern die App keine kulturabhängigen Vergleiche benötigt.
EF-Core-Tools bei Multi-Targeting: warum erscheint ein Fehler?
Ab EF Core 10 verlangen die Tools bei <TargetFrameworks> (Plural, Multi-Targeting) die explizite Angabe des Ziel-Frameworks: dotnet ef migrations add ... --framework net10.0. Ohne den Flag erscheint: The project targets multiple frameworks. Use the --framework option to specify which target framework to use.
Native AOT — lohnt sich das im Rahmen dieses Upgrades?
Nicht für eine EF-Core-lastige Web-API. EF Core stuft seine AOT-Unterstützung selbst als „highly experimental" ein; klassisches MVC wird von AOT nicht unterstützt. Native AOT ist ein separates Vorhaben nach dem abgeschlossenen TFM-Wechsel, das ein eigenes Template (dotnet new webapiaot) und einen AOT-kompatiblen Datenzugriffsweg erfordert.
Offizielle Quellen und nächste Schritte
Direkte Einstiegspunkte bei Microsoft: Upgrade auf eine neue .NET-Version · ASP.NET Core 9→10 Migration Guide · EF Core 10 Breaking Changes · Migrations-Bundles · Container-Images: Ubuntu als neue Basis.
Wenn Sie Unterstützung beim Upgrade, bei der Überprüfung von Breaking Changes oder bei der Anpassung Ihrer CI/CD-Pipeline benötigen, finden Sie weitere Informationen unter .NET-Entwicklung.
Hinweis: Die Beiträge dieses Blogs werden unter Einsatz von KI erstellt und vor der Veröffentlichung redaktionell geprüft. Die redaktionelle Verantwortung trägt Emre Yurtbay (siehe Impressum).