diff --git a/src/dotnet/skills/dotnet-deploy-optimize/SKILL.md b/src/dotnet/skills/dotnet-deploy-optimize/SKILL.md new file mode 100644 index 0000000000..1064a3d527 --- /dev/null +++ b/src/dotnet/skills/dotnet-deploy-optimize/SKILL.md @@ -0,0 +1,268 @@ +--- +name: dotnet-deploy-optimize +description: Optimizes .NET 8+ application deployments by analyzing publish configuration, trimming, Native AOT, Docker images, CI/CD pipelines, environment configuration, and health checks. Use when preparing a .NET app for production deployment or improving an existing deployment pipeline. +--- + +# .NET Deployment Optimization + +Analyze and optimize a .NET 8+ application's deployment pipeline to produce smaller, faster, and more reliable production artifacts. The skill walks through publish modes, trimming, Native AOT, container optimization, CI/CD tuning, configuration management, and health-check readiness. + +## When to Use + +- Preparing a .NET 8+ application for its first production deployment +- Reducing published application size or cold-start latency +- Optimizing Docker images for a .NET service +- Improving CI/CD build times for .NET projects +- Adding health checks or readiness probes to a .NET service +- Reviewing an existing deployment pipeline for inefficiencies + +## When Not to Use + +- The project targets .NET Framework (not .NET Core/.NET 8+) +- The goal is application-level performance profiling (CPU, memory, hot-path optimization) +- The project is a library or NuGet package (no deployment artifact) + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Project or solution path | Yes | Path to the `.csproj`, `.fsproj`, or `.sln` file | +| Target environment | No | Description of where the app will run (e.g., Linux container, Windows IIS, cloud PaaS) | +| Current Dockerfile | No | Existing Dockerfile, if one exists | +| CI/CD pipeline config | No | Existing pipeline file (e.g., GitHub Actions YAML, Azure Pipelines YAML) | + +## Workflow + +### Step 1: Assess the current project configuration + +1. Read the project file(s) and identify the target framework, output type, and any existing publish settings. +2. Check for a `Properties/launchSettings.json`, `appsettings.json`, and `appsettings.*.json` files. +3. Note any existing `PublishTrimmed`, `PublishAot`, `PublishSingleFile`, `ReadyToRun`, or `SelfContained` properties. +4. Identify the application type using these signals: + - **Web API / MVC / Razor Pages**: `` with no Blazor packages + - **Blazor**: `Sdk="Microsoft.NET.Sdk.Web"` plus `Microsoft.AspNetCore.Components` packages + - **gRPC**: `Sdk="Microsoft.NET.Sdk.Web"` plus `Grpc.AspNetCore` package reference + - **Worker Service**: `Sdk="Microsoft.NET.Sdk.Worker"`, or `Sdk="Microsoft.NET.Sdk"` with a `BackgroundService` or `IHostedService` implementation and a reference to `Microsoft.Extensions.Hosting` + - **Console**: `Sdk="Microsoft.NET.Sdk"` with `Exe` + - **WinForms / WPF**: `true` or `true` + +### Step 2: Recommend a publish mode + +Evaluate and recommend the most appropriate publish mode based on the application type: + +| Mode | When to use | +|------|-------------| +| Framework-dependent | Target already has the .NET runtime installed; smallest artifact | +| Self-contained | Target may not have the runtime; trade size for portability | +| Single-file | Self-contained apps that benefit from a single executable | +| ReadyToRun (R2R) | Self-contained apps where faster startup is needed but full AOT is not feasible; pre-compiles IL to native code while keeping JIT as a fallback | +| Native AOT | Console or API apps needing minimal startup time and memory; no reflection-heavy libraries | + +Provide the recommended `dotnet publish` command with appropriate flags: + +```bash +# Example: self-contained single-file publish for Linux +dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true +``` + +Or equivalently, set the properties in the project file: + +```xml + + + true + true + linux-x64 + +``` + +### Step 3: Apply trimming and tree-shaking + +> **Skip this step** if Native AOT was chosen in Step 2 — AOT applies trimming automatically. + +1. Check if the application is trim-compatible by scanning for known trim-incompatible patterns: + - Heavy use of `System.Reflection` and using string representations of types, strongly typed reflection is not problematic + - Dynamic assembly loading + - `System.Text.Json` source generators not configured + - COM interop + - Check trim-compatibility of dependencies, including from nuget +2. If compatible, offer to assist to enable trimming and fix errors: + +```xml + + true + +``` + +3. Suggest adding `[DynamicallyAccessedMembers]` or `[RequiresUnreferencedCode]` annotations where needed. +4. Recommend running `dotnet publish` with trimming and reviewing warnings. + +### Step 4: Evaluate Native AOT + +Native AOT, ReadyToRun (R2R), and JIT with tiered compilation each offer different trade-offs. **Always present these options with their trade-offs** so the user can make an informed choice based on what they value most: + +| Strategy | Startup | Binary size | Runtime dependencies | Steady-state throughput | +|----------|---------|-------------|---------------------|------------------------| +| **Native AOT** | Fastest (no JIT needed) | Smallest (single native binary) | Fewest (no .NET runtime required) | Static — what you compile is what you get | +| **ReadyToRun (R2R)** | Fast (pre-compiled, JIT only for cold paths) | Larger (IL + native code) | Requires .NET runtime | Improves over time via tiered compilation and dynamic PGO | +| **JIT only (default)** | Slowest (all code JIT-compiled at startup) | Smallest IL output | Requires .NET runtime | Best long-term — tiered compilation and dynamic PGO optimize hot paths progressively | + +1. Check if the application is AOT-compatible: + - No `dynamic` keyword usage + - No unbounded reflection + - All serialization uses source generators + - No runtime code generation (e.g., `System.Reflection.Emit`) + - Check AOT-compatibility of dependencies, including from nuget +2. If compatible, recommend enabling AOT and offer to fix any errors: + +```xml + + true + +``` + +3. **Explicitly discuss the trade-offs** for each strategy. AOT eliminates JIT tiered compilation and dynamic PGO — which progressively optimize hot code paths at runtime. R2R preserves tiered compilation while still improving startup. These are orthogonal to whether the service is long-running or short-lived; present the trade-offs and let the user decide. +4. If not compatible, document the blockers and suggest alternatives (trimming, ReadyToRun). + +### Step 5: Optimize Docker images + +> **Skip this step** if the application is not deployed as a container and container deployment is not planned. + +If a Dockerfile exists or container deployment is planned: + +1. Recommend a multi-stage build pattern: + +```dockerfile +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY *.csproj ./ +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", ".dll"] # Replace with the actual project assembly name +``` + +2. Recommend using `aspnet` (not `sdk`) as the runtime base image. +3. Suggest using Alpine-based images (`8.0-alpine`) when glibc dependencies allow, for smaller images. +4. Recommend a `.dockerignore` file that excludes `bin/`, `obj/`, `.git/`, and other non-essential files. +5. Suggest layer ordering: restore before copy to maximize cache hits. +6. For AOT apps, use the `runtime-deps` base image instead. + +### Step 6: Optimize CI/CD pipeline + +> **Skip this step** if no CI/CD pipeline configuration file exists in the repository. + +Review the pipeline configuration and recommend: + +1. **Dependency caching**: Cache NuGet packages across builds. + +```yaml +# GitHub Actions example +- uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ hashFiles('**/*.csproj') }} +``` + +2. **Parallelism**: Run tests and builds in parallel jobs when possible. +3. **Incremental builds**: Avoid unnecessary full restores; use `--no-restore` after a cached restore step. +4. **Artifact size**: Publish only the deployment artifact, not the full build output. +5. **Build configuration**: Ensure `Release` configuration is used for deployed artifacts. + +### Step 7: Review configuration and environment management + +> **Skip this step** if the application has no configuration files (`appsettings.json`, environment variables, or similar). + +1. Verify that no `appsettings*.json` file contains secrets (check the base `appsettings.json`, `appsettings.Production.json`, and any other environment-specific files). +2. Recommend environment variables or a secret manager for sensitive values. +3. Confirm that configuration binding uses the Options pattern (`IOptions`). +4. Suggest using `DOTNET_ENVIRONMENT` or `ASPNETCORE_ENVIRONMENT` to control configuration layering. +5. Check that connection strings and API keys are not hardcoded. + +### Step 8: Add health checks and probes + +> **Skip this step** for console applications and CLI tools that do not run as long-lived services. + +For web applications and worker services: + +**For web applications** (Web API, MVC, Blazor, gRPC): + +1. Add the health checks middleware: + +```csharp +builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy()); + +app.MapHealthChecks("/healthz"); +``` + +2. Recommend separate endpoints for liveness (`/healthz`) and readiness (`/ready`). +3. Add dependency health checks for databases, caches, and external services: + +```csharp +builder.Services.AddHealthChecks() + .AddNpgSql(connectionString) // PostgreSQL + .AddRedis(redisConnectionString); // Redis +``` + +4. Document how orchestrators (Kubernetes, Docker Compose) should configure probes pointing at these endpoints. + +**For Worker Services** (no HTTP endpoints by default): + +> **Important:** Adding a web server (Kestrel) to a worker service is an architectural change that adds dependencies, memory overhead, and attack surface. Always discuss the trade-offs of each option and prefer the lightest-weight approach that meets the orchestrator's requirements. Present all three options to the user. + +1. Option A — **File-based health check** (lightest weight, recommended for simple liveness): Write a timestamp to a file periodically; configure K8s `exec` liveness probe to check file freshness. No additional dependencies or ports needed. + +2. Option B — **TCP health check listener**: Open a TCP socket on a port; K8s uses `tcpSocket` probe. Lighter than HTTP but requires a port. + +3. Option C — **Minimal Kestrel HTTP endpoint** (heaviest, but most flexible): Add a minimal web host for `/healthz`. This requires changing the SDK to `Microsoft.NET.Sdk.Web` and adds Kestrel overhead. Only recommend this when the orchestrator requires HTTP probes or when dependency health checks (database, cache) are needed: + +```csharp +builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy()); + +builder.WebHost.UseKestrel(o => o.ListenAnyIP(8080)); +var app = builder.Build(); +app.MapHealthChecks("/healthz"); +``` + +### Step 9: Summarize recommendations + +Produce a summary table of all recommendations: + +| Area | Current state | Recommendation | Impact | +|------|--------------|----------------|--------| +| Publish mode | (detected) | (recommended) | (size/startup) | +| Trimming | (enabled/disabled) | (recommendation) | (size reduction) | +| AOT | (enabled/disabled) | (recommendation) | (startup/size) | +| Docker | (exists/missing) | (recommendation) | (image size) | +| CI/CD | (detected) | (recommendation) | (build time) | +| Config | (reviewed) | (recommendation) | (security) | +| Health checks | (present/missing) | (recommendation) | (reliability) | + +## Validation + +- [ ] `dotnet publish -c Release` completes without errors after changes +- [ ] Published output size is smaller than or equal to the baseline (if trimming or AOT was applied) +- [ ] Docker image builds successfully (if Dockerfile was modified) +- [ ] Health check endpoints return HTTP 200 (if health checks were added) +- [ ] No secrets are present in configuration files committed to source control +- [ ] CI/CD pipeline runs successfully with caching enabled +- [ ] Application starts and responds to requests in the target environment + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| Enabling trimming without testing | Run the full test suite after trimming; fix trim warnings before deploying | +| AOT with reflection-heavy libraries | Use source generators for serialization; avoid libraries that rely on unbounded reflection | +| Using `sdk` as Docker runtime image | Switch to `aspnet` or `runtime-deps` to reduce image size by hundreds of MB | +| Caching the wrong NuGet path | Verify the NuGet cache path for your CI runner OS (`~/.nuget/packages` on Linux, `%USERPROFILE%\.nuget\packages` on Windows) | +| Hardcoded secrets in appsettings | Move secrets to environment variables, user secrets (dev), or a vault (production) | +| Missing `.dockerignore` | Add one to prevent `bin/`, `obj/`, and `.git/` from bloating the Docker build context | +| Health checks without dependency checks | Add checks for databases, caches, and external APIs to get meaningful readiness signals | +| Single-file publish without `IncludeNativeLibrariesForSelfExtract` | Native libraries may not bundle correctly; add the property if needed | diff --git a/src/dotnet/tests/dotnet-deploy-optimize/eval.yaml b/src/dotnet/tests/dotnet-deploy-optimize/eval.yaml new file mode 100644 index 0000000000..03137cb00c --- /dev/null +++ b/src/dotnet/tests/dotnet-deploy-optimize/eval.yaml @@ -0,0 +1,132 @@ +scenarios: + - name: "Basic Web API publish optimization" + prompt: "I'm getting ready to deploy this .NET 8 Web API to production. How can I make the published output as small as possible and get the fastest startup time?" + setup: + files: + - path: "MyApi/MyApi.csproj" + content: | + + + net8.0 + enable + enable + + + - path: "MyApi/Program.cs" + content: | + var builder = WebApplication.CreateBuilder(args); + builder.Services.AddControllers(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + var app = builder.Build(); + app.UseSwagger(); + app.MapControllers(); + app.Run(); + - path: "MyApi/appsettings.json" + content: | + { + "Logging": { + "LogLevel": { + "Default": "Information" + } + }, + "AllowedHosts": "*" + } + assertions: + - type: "output_contains" + value: "publish" + - type: "output_contains" + value: "trimm" + - type: "exit_success" + rubric: + - "Correctly identifies the project as a Web API application" + - "Recommends an appropriate publish mode with a dotnet publish command or project file properties" + - "Evaluates trimming compatibility and provides a recommendation" + - "Discusses Native AOT feasibility for this project type" + - "Provides a clear summary table of recommendations" + timeout: 180 + + - name: "Detect conflicting publish settings" + prompt: "I'm trying to publish this .NET 8 app but the build output seems wrong and I'm not sure my project settings make sense together. Can you check what's going on?" + setup: + files: + - path: "ConflictApp/ConflictApp.csproj" + content: | + + + net8.0 + enable + true + true + true + false + + + - path: "ConflictApp/Program.cs" + content: | + var builder = WebApplication.CreateBuilder(args); + var app = builder.Build(); + app.MapGet("/", () => "Hello"); + app.Run(); + assertions: + - type: "output_contains" + value: "conflict" + - type: "exit_success" + rubric: + - "Identifies the conflict between PublishAot and PublishSingleFile" + - "Identifies the conflict between PublishAot and ReadyToRun" + - "Identifies the conflict between PublishAot and SelfContained=false" + - "Provides specific fix recommendations for each conflict" + timeout: 180 + + - name: "Worker service deployment readiness" + prompt: "I need to deploy this .NET 8 background worker to Kubernetes. I want it to start up fast, use as little memory as possible, and I need a way for K8s to know if it's healthy. What should I do?" + setup: + files: + - path: "MyWorker/MyWorker.csproj" + content: | + + + net8.0 + enable + enable + + + + + + - path: "MyWorker/Program.cs" + content: | + using MyWorker; + var builder = Host.CreateDefaultBuilder(args); + builder.ConfigureServices(services => + { + services.AddHostedService(); + }); + var host = builder.Build(); + host.Run(); + - path: "MyWorker/Worker.cs" + content: | + namespace MyWorker; + public class Worker : BackgroundService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(1000, stoppingToken); + } + } + } + assertions: + - type: "output_contains" + value: "health" + - type: "output_contains" + value: "worker" + - type: "exit_success" + rubric: + - "Correctly identifies the project as a Worker Service" + - "Recommends health check options appropriate for a Worker Service (file-based, TCP, or minimal Kestrel)" + - "Recommends an appropriate publish mode considering long-running workload characteristics" + - "Discusses AOT trade-offs for long-running services (loss of JIT tiered compilation)" + timeout: 180