RE:NODE

Приложения11 мин чтения

Параметры dotnet publish: self-contained, single-file и AOT

Что на самом деле производит dotnet publish: framework-dependent и self-contained, идентификаторы runtime, single-file, trimming, ReadyToRun и Native AOT, и что из этого выбрать.

0 прочтений

Для веб-приложения или бота на Linux-сервере правильный dotnet publish почти всегда самый простой: framework-dependent, конфигурация Release, папка вывода, запуск через dotnet App.dll. Это самый маленький вывод, он подхватывает патчи безопасности runtime без пересборки, и у него нет ни одной из ловушек совместимости, свойственных более замысловатым режимам. Self-contained - правильный ответ, когда вы не контролируете runtime на сервере. Single-file, trimming, ReadyToRun и Native AOT решают каждый одну конкретную проблему - время запуска, число файлов, размер - и каждый чего-то стоит, поэтому включайте их, когда у вас есть эта проблема, а не потому, что флаг существует.

В этой статье объясняется, что каждый параметр на самом деле меняет в папке вывода и где каждый из них ломается.

Короткий ответ#

РежимЧто добавить в командуНужен ли runtime на сервереТипичное применение
Framework-dependentничегода, той же мажорной версииБольшинство веб-приложений и ботов
Self-contained-r linux-x64 --self-containedнетRuntime на сервере не тот или неизвестен
Single-file-p:PublishSingleFile=trueзависит от режима вышеОдин файл, который удобно копировать
Trimmed-p:PublishTrimmed=trueнет (только self-contained)Важен размер, код безопасен для trimming
ReadyToRun-p:PublishReadyToRun=trueзависитБолее быстрый холодный старт
Native AOT-p:PublishAot=trueнетБыстрый старт, мало памяти, ограничения на код

Параметры сочетаются. Self-contained, single-file и ReadyToRun сборка для linux-x64 - вполне обычная вещь. Остальная часть статьи о том, что каждый столбец означает на практике.

Что производит dotnet publish#

Начните со значения по умолчанию и посмотрите на папку:

bash
$ dotnet publish src/Api/Api.csproj -c Release -o out$ ls outApi  Api.deps.json  Api.dll  Api.pdb  Api.runtimeconfig.jsonappsettings.json  appsettings.Development.json  web.config  ...

У каждого из этих файлов своя задача:

  • Api.dll - ваш код, скомпилированный в IL, промежуточный язык, который JIT в runtime компилирует в машинный код при первом вызове методов.
  • Api.deps.json перечисляет все зависимости и где их искать. Удалите его, и хост откатится к перебору файлов в папке, что в основном работает - пока не перестанет.
  • Api.runtimeconfig.json говорит, какой общий фреймворк загрузить, например Microsoft.AspNetCore.App 10.0, и хранит настройки runtime вроде режима GC.
  • Api (или Api.exe в Windows) - это apphost, небольшой нативный загрузчик для платформы, на которой вы собирали. ./Api и dotnet Api.dll делают одно и то же. Отключите его через -p:UseAppHost=false, если запускаете приложение только через dotnet.
  • Зависимости из NuGet копируются как отдельные DLL. Сам фреймворк - нет.
  • web.config нужен для IIS и везде больше игнорируется.

Начиная с .NET 8, dotnet publish по умолчанию использует конфигурацию Release для проектов, нацеленных на .NET 8 и новее. Явно писать -c Release всё равно полезная привычка - ради старых проектов и скриптов сборки, которые кто-то потом будет читать.

Framework-dependent и как работает roll-forward#

Вывод по умолчанию рассчитывает на то, что там, где он запускается, установлен общий runtime .NET. Требование к версии записано в runtimeconfig.json:

Api.runtimeconfig.json
{  "runtimeOptions": {    "tfm": "net10.0",    "framework": {      "name": "Microsoft.AspNetCore.App",      "version": "10.0.0"    }  }}

version - это минимум, и runtime применяет политику roll-forward, чтобы найти подходящую версию. Политика по умолчанию, Minor, делает две вещи: всегда выбирает самый новый установленный патч запрошенной версии (так что 10.0.0 запустится на 10.0.7, если она есть), а если никакой 10.0.x нет вообще, принимает более позднюю минорную версию. Мажорную версию она не пересекает: приложение, собранное для .NET 8, не запустится на машине, где есть только .NET 10, и упадёт с «You must install or update .NET to run this application».

Это можно изменить с помощью <RollForward>Major</RollForward> в проекте или переменной окружения DOTNET_ROLL_FORWARD, и тогда приложение для 8 запустится на 10. Обычно это работает, потому что ломающие изменения между мажорными версиями задокументированы и в основном невелики, но тогда вы работаете на runtime, который никогда не тестировали. Честное исправление - сменить целевой фреймворк и пересобрать.

Настоящее преимущество framework-dependent вывода - патчи. Когда хост устанавливает обновление безопасности .NET, ваше приложение подхватывает его при следующем перезапуске без пересборки. Self-contained приложение несёт собственную копию runtime и остаётся на том уровне патчей, с которым было собрано, пока вы не опубликуете его снова.

Self-contained и идентификаторы runtime#

Self-contained сборка копирует runtime в папку вывода, так что на сервере .NET может вообще не быть установлен:

bash
$ dotnet publish -c Release -r linux-x64 --self-contained -o out

Self-contained приложение привязано к одной платформе, которая задаётся идентификатором runtime (RID):

RIDПлатформа
linux-x6464-битный Linux на Intel/AMD с glibc - большинство серверов
linux-arm6464-битный Linux на ARM с glibc
linux-musl-x64Alpine и другие дистрибутивы на musl
win-x6464-битный Windows
osx-arm64macOS на Apple silicon

Начиная с .NET 8, SDK по умолчанию использует такие переносимые RID; специфичные для дистрибутива, например ubuntu.22.04-x64, больше не то, на что стоит нацеливаться. Также начиная с .NET 8, один только -r больше не подразумевает self-contained - вы получите framework-dependent сборку для этой платформы. Пишите --self-contained (или --self-contained false) явно, чтобы никому не приходилось помнить, какой SDK что поменял.

Чего self-contained не включает, так это нативных библиотек операционной системы. Runtime всё равно нужны glibc (или musl для musl-RID), OpenSSL для TLS и ICU для обработки строк с учётом культуры. На минимальном образе Linux без ICU .NET-приложение останавливается при запуске с «Couldn't find a valid ICU package installed on the system». Если вашему приложению действительно не нужно форматирование под конкретную культуру - большинству API, которые говорят на JSON и хранят временные метки в UTC, оно не нужно, - включите инвариантную глобализацию:

xml
<PropertyGroup>  <InvariantGlobalization>true</InvariantGlobalization></PropertyGroup>

Тогда все культуры ведут себя как инвариантная: ToUpper для турецкого текста и форматы дат в de-DE не будут следовать местным правилам. У некоторых драйверов баз данных на этот счёт тоже есть мнение: старые версии Microsoft.Data.SqlClient отказывались открывать соединения в инвариантном режиме, так что проверьте это на своём реальном стеке.

Цена self-contained - размер: runtime и, для веб-приложения, ASP.NET Core добавляют примерно от 70 до 100 МБ в зависимости от версии, против нескольких мегабайт у framework-dependent сборки. На тарифе с 5 ГБ диска это значит меньше, чем кажется, но становится заметным, если вы храните несколько сборок.

Single-file#

PublishSingleFile упаковывает приложение в один исполняемый файл. Ему нужен RID, и он работает как для framework-dependent, так и для self-contained сборок:

bash
$ dotnet publish -c Release -r linux-x64 --self-contained \    -p:PublishSingleFile=true -o out

На трёх деталях люди спотыкаются.

Некоторые файлы всё равно остаются отдельными. Управляемые сборки попадают в бандл. Нативные библиотеки остаются рядом с исполняемым файлом, если не добавить -p:IncludeNativeLibrariesForSelfExtract=true, и тогда они извлекаются во временный каталог при первом запуске (это управляется DOTNET_BUNDLE_EXTRACT_BASE_DIR). Файлы содержимого вроде appsettings.json и wwwroot публикуются рядом с исполняемым файлом, а не внутри него, если не задать IncludeAllContentForSelfExtract, а это редко то, что нужно для конфигурационного файла, который вы собираетесь редактировать.

`Assembly.Location` пуст. Код, который находит свою папку через typeof(Program).Assembly.Location, внутри бандла получает пустую строку. Используйте вместо этого AppContext.BaseDirectory; компилятор предупреждает об этом кодом IL3000, если у вас включены анализаторы.

Он не меньше и не быстрее. Один файл - это те же байты в одном контейнере. Это удобно для утилиты командной строки, которую вы отдаёте другим людям. Для серверного приложения, деплоящегося из Git, папку запустить не сложнее, чем файл, так что single-file решает проблему, которой у вас нет.

Trimming#

Trimming удаляет из фреймворка и ваших зависимостей код, который приложение не использует. Он работает только с self-contained сборками, потому что общий фреймворк нельзя обрезать под одно приложение.

xml
<PropertyGroup>  <PublishTrimmed>true</PublishTrimmed></PropertyGroup>

Trimmer работает через статический анализ: он идёт по вызовам от точки входа и удаляет то, до чего не может дотянуться. Reflection его обходит. Всё, что находит типы во время выполнения, - классическое обнаружение контроллеров MVC, представления Razor, сериализация System.Text.Json без генерации исходного кода, большая часть внедрения зависимостей по соглашению, старые ORM - может лишиться нужного кода, и сбой проявится во время выполнения как MissingMethodException или тип, который тихо десериализуется в пустоту.

SDK сообщает, где риск, через предупреждения trimming (IL2026, IL2070 и родственные). Считайте их ошибками. Если ваша сборка выдаёт их десятками из библиотеки, которую вы не контролируете, trimming не для этого приложения.

На практике: minimal API с JSON через генерацию исходного кода обрезается хорошо. Приложение MVC с представлениями Razor или что угодно, активно использующее EF Core, - плохой кандидат, и собственная документация Microsoft говорит об этом для этих фреймворков. Экономия реальна - часто половина размера self-contained или больше, - но на сервере диск редко бывает тем ограничением, которое имеет значение.

ReadyToRun и многоуровневая компиляция#

Обычный код .NET компилируется в машинный код JIT-компилятором по ходу работы. Поэтому при запуске время уходит на компиляцию, а на медленном CPU это большая доля первых нескольких секунд. ReadyToRun (R2R) компилирует заранее и хранит нативный код рядом с IL:

bash
$ dotnet publish -c Release -r linux-x64 -p:PublishReadyToRun=true -o out

Ему нужен RID, потому что нативный код зависит от платформы. Сборки растут, часто в два-три раза относительно размера IL. JIT никуда не девается: многоуровневая компиляция считает код R2R начальным уровнем и перекомпилирует горячие методы с полной оптимизацией, опираясь на данные динамического профилирования (dynamic PGO, включён по умолчанию с .NET 8), так что в установившемся режиме производительность получается такой же, как без R2R.

R2R даёт холодный старт. На сервере с половиной vCPU, где лимит CPU - жёсткое ограничение, а не пожелание, работа JIT при запуске заметно медленная, и R2R может срезать секунды между «процесс запущен» и «на первый запрос ответили». Если ваше приложение перезапускается при каждом деплое и этот промежуток вам важен, это самый дешёвый выигрыш в списке, и он не стоит ничего в плане совместимости. Если приложение запускается раз в неделю, он не стоит места на диске.

Native AOT#

Native AOT компилирует всё приложение вместе с runtime в один нативный исполняемый файл, вообще без JIT:

xml
<PropertyGroup>  <PublishAot>true</PublishAot></PropertyGroup>

Там, где он применим, результаты впечатляющие: запуск за десятки миллисекунд, бинарник меньше, чем у обрезанной self-contained сборки, и меньше памяти в простое. Ограничения - те же, что у trimming, только доведённые до абсолюта: никакой генерации кода во время выполнения, никакой динамической загрузки сборок, reflection только там, где его видит компилятор. ASP.NET Core поддерживает его для minimal API и gRPC, с WebApplication.CreateSlimBuilder и JSON через генерацию исходного кода; шаблон webapiaot настроен именно на это. MVC, Razor Pages и Blazor Server не поддерживаются. Поддержка AOT в EF Core всё ещё ограничена и в последних версиях помечена как экспериментальная.

Два практических ограничения: Native AOT нужен нативный тулчейн платформы во время сборки (в Linux это clang и заголовки разработки zlib), и он не умеет кросс-компилировать между операционными системами - Linux-бинарники собирайте на Linux. Сборка на собственном маленьком тарифе сервера медленная и прожорливая по памяти; это работа для CI.

Native AOT действительно хорош для небольших узконаправленных сервисов и утилит командной строки. Для типичного бизнес-веб-приложения ограничения стоят больше, чем экономия на времени запуска.

Выбор для небольшого сервера#

Вот как параметры ложатся на реальный случай: API на ASP.NET Core с EF Core, деплой с GitHub на тариф с 1 ГБ и половиной vCPU.

  1. Начните с framework-dependent. dotnet publish -c Release -o out и dotnet out/Api.dll. Самый маленький вывод, патчится вместе с runtime, без сюрпризов.
  2. Если runtime на сервере не той мажорной версии, публикуйте self-contained для linux-x64, а не воюйте с roll-forward. Помните, что патчи runtime теперь на вас: пересобирайте, когда выходит обновление безопасности .NET.
  3. Если перезапуски медленные, первым делом добавьте ReadyToRun. Риска для совместимости у него нет.
  4. Оставьте trimming и AOT выключенными для этого приложения. EF Core и стек, плотно завязанный на reflection, дадут предупреждения, которые нельзя безопасно игнорировать.
  5. Пропустите single-file. Он ничего не добавляет, когда деплой - это Git pull.

Где происходит сборка, важно не меньше, чем как. Сборка на сервере означает, что SDK, MSBuild и компилятор при каждом запуске работают внутри лимита памяти тарифа, а в RE:NODE процесс, достигший лимита памяти, останавливается, и контейнер перезапускается начисто, а не уходит в подкачку, - так что большая сборка на самом маленьком тарифе может упасть на полпути. Сборка в GitHub Actions и деплой готового вывода (из ветки, которую отслеживает сервер) полностью убирают эту работу с сервера. Оба варианта разобраны в статье деплой приложения ASP.NET Core с GitHub, а статья память и сборка мусора в .NET объясняет, сколько будет использовать само приложение, когда оно заработает.

Те же решения применимы и за пределами веба: worker service или бот публикуются точно так же, а модель хостинга для них описана в статье worker services и фоновые задачи в .NET.

FAQ#

Self-contained быстрее, чем framework-dependent?

Нет. Это тот же runtime и тот же JIT; меняется только то, откуда берутся файлы. Скорость запуска дают ReadyToRun или Native AOT, а не упаковка runtime в комплект.

Почему опубликованное приложение просит версию .NET, которая, как я думал, у меня есть?

На сервере установлена другая мажорная версия, чем указана в runtimeconfig.json, а политика roll-forward по умолчанию мажорные версии не пересекает. Смените целевой фреймворк, публикуйте self-contained или осознанно задайте RollForward.

Можно ли публиковать в Windows и запускать в Linux?

Да, для всего, кроме Native AOT. Framework-dependent IL не зависит от платформы, а self-contained сборку для linux-x64 можно сделать в Windows. Отличается только apphost, а запускать можно через dotnet App.dll.

Нужен ли SDK на сервере?

Только если вы там собираете. Для запуска опубликованного приложения нужен только runtime, а для self-contained - вообще ничего. Держать SDK вне пути запуска - одна из причин собирать в CI.

Стоит ли коммитить результат публикации в Git?

Не в основную ветку. Если вы деплоите заранее собранный вывод, держите его в отдельной ветке, которую пишет CI, чтобы история исходников оставалась читаемой. Статья версии .NET и поддержка LTS рассказывает, как держать целевой фреймворк актуальным, а это вторая половина стабильной сборки.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000