Для веб-приложения или бота на 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#
Начните со значения по умолчанию и посмотрите на папку:
$ 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.App10.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:
{ "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 может вообще не быть установлен:
$ dotnet publish -c Release -r linux-x64 --self-contained -o outSelf-contained приложение привязано к одной платформе, которая задаётся идентификатором runtime (RID):
| RID | Платформа |
|---|---|
linux-x64 | 64-битный Linux на Intel/AMD с glibc - большинство серверов |
linux-arm64 | 64-битный Linux на ARM с glibc |
linux-musl-x64 | Alpine и другие дистрибутивы на musl |
win-x64 | 64-битный Windows |
osx-arm64 | macOS на 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, оно не нужно, - включите инвариантную глобализацию:
<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 сборок:
$ 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 сборками, потому что общий фреймворк нельзя обрезать под одно приложение.
<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:
$ 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:
<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.
- Начните с framework-dependent.
dotnet publish -c Release -o outиdotnet out/Api.dll. Самый маленький вывод, патчится вместе с runtime, без сюрпризов. - Если runtime на сервере не той мажорной версии, публикуйте self-contained для
linux-x64, а не воюйте с roll-forward. Помните, что патчи runtime теперь на вас: пересобирайте, когда выходит обновление безопасности .NET. - Если перезапуски медленные, первым делом добавьте ReadyToRun. Риска для совместимости у него нет.
- Оставьте trimming и AOT выключенными для этого приложения. EF Core и стек, плотно завязанный на reflection, дадут предупреждения, которые нельзя безопасно игнорировать.
- Пропустите 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. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.