Деплой и наблюдаемость .NET
Код, прошедший тесты, ещё не приносит пользы — его нужно собрать в артефакт, доставить и наблюдать в проде. Эта статья про то, как превратить приложение в бинарник или контейнер и как понять, что происходит внутри работающего сервиса.
Публикация: варианты сборки артефакта
dotnet publish создаёт готовый к запуску набор файлов. Есть три основных режима.
1. Framework-dependent (FDD) — самый лёгкий артефакт; требует установленного .NET runtime на целевой машине.
dotnet publish -c Release -o out
# out/ содержит .dll и зависимости; запуск: dotnet MyApp.dll
2. Self-contained (SCD) — тащит runtime внутри; работает на машине без .NET, но артефакт большой (десятки МБ). Указываете RID (runtime identifier) целевой платформы.
dotnet publish -c Release -r linux-x64 --self-contained true -o out
# out/ содержит нативный исполняемый файл + весь runtime
3. Single-file / trimmed — упаковать в один файл и обрезать неиспользуемый код:
dotnet publish -c Release -r linux-x64 --self-contained true \
-p:PublishSingleFile=true -p:PublishTrimmed=true
Trimming уменьшает размер, но опасен для кода, активно использующего рефлексию — тестируйте результат. Как выбрать: для контейнеров обычно FDD поверх официального runtime-образа (меньше слой, общий базовый образ), для standalone-утилит и десктопа — self-contained.
Native AOT (кратко)
Native AOT компилирует приложение в нативный бинарник заранее, без JIT. Результат: мгновенный старт (важно для serverless и CLI), меньше памяти, нет зависимости от runtime. Цена: ограничения на рефлексию и динамическую генерацию кода, дольше сборка.
dotnet publish -c Release -r linux-x64 -p:PublishAot=true
С .NET 8+ Minimal API и многие библиотеки AOT-совместимы. Отлично подходит для маленьких высоконагруженных сервисов и функций, где важен холодный старт. Не всё совместимо — проверяйте предупреждения AOT-анализатора. Подробно: learn.microsoft.com/dotnet/core/deploying/native-aot.
Контейнеризация для ASP.NET Core
Стандарт доставки — Docker. Каноничный подход — multi-stage build: собираем в образе с SDK, а запускаем в лёгком образе с одним runtime.
# --- Этап сборки ---
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Сначала копируем только csproj и восстанавливаем — кэш слоёв Docker
COPY ["src/MyApp.Api/MyApp.Api.csproj", "src/MyApp.Api/"]
RUN dotnet restore "src/MyApp.Api/MyApp.Api.csproj"
# Теперь весь код и публикация
COPY . .
RUN dotnet publish "src/MyApp.Api/MyApp.Api.csproj" -c Release -o /app/publish \
--no-restore /p:UseAppHost=false
# --- Этап рантайма (лёгкий образ, только runtime) ---
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
# Не root — базовое требование безопасности
USER $APP_UID
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "MyApp.Api.dll"]
Важные детали:
- Порядок слоёв. Копирование
.csprojиrestoreдо копирования всего кода позволяет Docker кэшировать восстановление пакетов — сборка при изменении только кода становится мгновенной. chiseled-образы (mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled) — минимальные, без шелла и пакетного менеджера, меньше поверхность атаки.- Не root. Официальные образы .NET 8+ определяют
$APP_UIDдля непривилегированного пользователя. - Альтернатива Dockerfile —
dotnet publish -t:PublishContainer(встроенная сборка образов SDK, без Dockerfile вовсе) или Buildpacks.
Health checks
Оркестратор (Kubernetes) должен знать, жив ли контейнер и готов ли принимать трафик. Для этого — health checks.
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString, name: "db") // проверка БД
.AddCheck("self", () => HealthCheckResult.Healthy());
var app = builder.Build();
// liveness — процесс жив (не проверяет зависимости)
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false
});
// readiness — готов принимать трафик (проверяет БД и т.п.)
app.MapHealthChecks("/health/ready");
Различайте liveness (перезапустить, если завис) и readiness (не слать трафик, пока зависимости не готовы). Путать их опасно: если readiness падает при недоступности БД и вы повесили на него liveness — Kubernetes начнёт бесконечно перезапускать здоровый под, ухудшая ситуацию.
Наблюдаемость: три столпа
Наблюдаемость (observability) отвечает на вопрос «что происходит внутри системы, судя по её выходным сигналам». Три столпа:
что случилось] App -->|метрики| M[Метрики
сколько/как быстро] App -->|трейсы| T[Трейсы
путь запроса через сервисы] L & M & T --> OTLP[OpenTelemetry Collector] --> Backend[Grafana / Jaeger / Prometheus / ...]
Современный .NET — OpenTelemetry-native: логи, метрики и трейсинг строятся на
встроенных API (ILogger, System.Diagnostics.Metrics, System.Diagnostics.Activity),
а OpenTelemetry экспортирует их куда угодно по стандарту OTLP.
Логи: ILogger и структурное логирование
ILogger<T> — встроенная абстракция. Ключевой принцип — структурное логирование:
не склеивайте значения в строку, передавайте их как именованные поля. Тогда лог можно
фильтровать и агрегировать по полям.
public class OrderService(ILogger<OrderService> logger)
{
public async Task PlaceAsync(Order order, CancellationToken ct)
{
// ✅ структурно: OrderId и Total станут отдельными полями в JSON-логе
logger.LogInformation("Placing order {OrderId} for {Total}", order.Id, order.Total);
// ❌ так теряется структура:
// logger.LogInformation($"Placing order {order.Id} for {order.Total}");
}
}
Обратите внимание: {OrderId} — не интерполяция, а плейсхолдер шаблона; значения
идут аргументами и сохраняются как поля. Для продакшена популярен Serilog — гибкие
sink-и (файл, Seq, Elasticsearch), обогащение контекстом:
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter()));
В контейнерах логируйте в stdout в JSON — оркестратор и агрегатор соберут сами.
Не пишите в файлы внутри контейнера. Правильные уровни: Information для бизнес-событий,
Warning для аномалий, Error для сбоев с исключением; не логируйте секреты и PII.
Метрики
Метрики — числовые агрегаты (счётчики, гистограммы, gauge). Встроенный API —
System.Diagnostics.Metrics.Meter:
public sealed class OrderMetrics
{
private readonly Counter<long> _placed;
private readonly Histogram<double> _duration;
public OrderMetrics(IMeterFactory factory)
{
var meter = factory.Create("MyApp.Orders");
_placed = meter.CreateCounter<long>("orders.placed");
_duration = meter.CreateHistogram<double>("orders.duration", unit: "ms");
}
public void OrderPlaced(double ms)
{
_placed.Add(1);
_duration.Record(ms);
}
}
ASP.NET Core, HttpClient, EF Core уже эмитят богатые встроенные метрики (латентность запросов, коды ответов). Экспортируйте их в Prometheus/OTLP и стройте дашборды и алерты по «золотым сигналам»: latency, traffic, errors, saturation.
Распределённый трейсинг
Трейс показывает путь одного запроса через все сервисы — где потрачено время, где
упало. В .NET трейсинг строится на Activity/ActivitySource; контекст (trace id)
автоматически прокидывается через HttpClient и заголовки W3C Trace Context.
private static readonly ActivitySource Source = new("MyApp.Orders");
public async Task PlaceAsync(Order order, CancellationToken ct)
{
using var activity = Source.StartActivity("PlaceOrder");
activity?.SetTag("order.id", order.Id);
// ... вложенные вызовы (БД, HTTP) автоматически станут дочерними span-ами
}
Собираем всё через OpenTelemetry
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("MyApp.Api"))
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddOtlpExporter()) // отправка в коллектор/бэкенд
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation() // GC, потоки, память
.AddMeter("MyApp.Orders")
.AddOtlpExporter());
Один стандарт (OTLP), любой бэкенд (Jaeger, Tempo, Prometheus, Grafana, Honeycomb, Datadog). Не привязывайтесь к вендору — эмитьте по OpenTelemetry. Для локальной разработки удобен .NET Aspire dashboard — из коробки показывает логи, метрики и трейсы. Подробно: opentelemetry.io/docs/languages/net.
Производительность и профилирование
Наблюдаемость сказала «медленно» — дальше нужны инструменты для «почему». В .NET они кросс-платформенные и ставятся как global tools:
dotnet tool install -g dotnet-counters
dotnet tool install -g dotnet-trace
dotnet tool install -g dotnet-dump
dotnet tool install -g dotnet-gcdump
# Живые метрики процесса: CPU, память, GC, очередь пула потоков
dotnet-counters monitor -p <PID> --counters System.Runtime,Microsoft.AspNetCore.Hosting
# Собрать трассу для анализа в PerfView/Speedscope
dotnet-trace collect -p <PID> --duration 00:00:30
# Дамп памяти и анализ утечек
dotnet-gcdump collect -p <PID>
Что смотреть в первую очередь:
- ThreadPool Queue Length растёт — где-то блокируете потоки (sync-over-async из статьи про конкурентность), пул голодает.
- Gen 2 / LOH коллекции часты — много долгоживущих или крупных аллокаций; оптимизируйте аллокации, пулинг.
- Высокий CPU в GC — давление на кучу; ищите горячие аллокации.
Для бенчмаркинга кода — BenchmarkDotNet, золотой стандарт микробенчмарков в .NET
(корректно прогревает, статистически обрабатывает, показывает аллокации). Не измеряйте
производительность Stopwatch-ом в цикле — получите шум. Глубоко про перф и внутренности
пишет Stephen Toub в ежегодных «Performance Improvements in .NET»
(devblogs.microsoft.com/dotnet).
Что дальше
SDLC и лучшие ресурсы — как всё это складывается в жизненный цикл разработки: CI/CD, релизы, безопасность зависимостей и курированный список лучших источников по .NET.