Publicação Self-Contained, Single-File e com Trimming
Objective
dotnet publish não é uma operação só, e sim um conjunto de chaves
independentes: quem fornece o runtime (a máquina ou a sua pasta), quantos
arquivos você entrega (uma pasta ou um executável), quanto do framework fica
(tudo ou só o que a análise estática diz que você usa) e se o código vai
pré-compilado (ReadyToRun). Cada chave mexe em tamanho, em startup e no que pode
quebrar. Este conceito publica os mesmos apps em todas as combinações e mostra
tamanhos, tempos e avisos de trim reais, para a escolha sair de dado, não de
hábito.
Ambiente de todos os números: SDK 10.0.100, runtime 10.0.0, Release,
osx-arm64, Apple M4 Max, macOS 27. A máquina estava dividida com outros
builds, então os tempos são medianas de execuções repetidas e só entram
diferenças que se mantiveram entre as rodadas.
Use Cases
- Entregar uma ferramenta de linha de comando para máquinas onde não dá para instalar o .NET.
- Escolher o modo de publicação de uma imagem de container ou de uma função serverless, em que tamanho de imagem e cold start viram custo.
- Distribuir um executável em vez de uma pasta com 190 arquivos.
- Entender por que um build com trimming passa nos testes em Debug e falha em
produção com um
nullvindo de reflection.
Deep Dive
Framework-dependent ou self-contained
Uma publicação framework-dependent leva os seus assemblies e espera um
runtime compartilhado compatível no destino; é o padrão. Por padrão ela também
gera um apphost (um lançador nativo pequeno com o nome do app) ao lado da
DLL portável, então ./Hello e dotnet Hello.dll funcionam; -p:UseAppHost=false
remove o apphost. Uma publicação self-contained copia o runtime e o
framework inteiro para a pasta e precisa de um runtime identifier (RID)
como linux-x64, win-arm64 ou osx-arm64: o runtime é código nativo, então
cada publicação roda em um SO e uma arquitetura.
plaintextdotnet publish -c Release # framework-dependent dotnet publish -c Release -r osx-arm64 --self-contained # self-contained
A troca é de manutenção. Um app framework-dependent faz roll forward para o
patch mais novo do runtime da mesma major instalado na máquina, então um
apt upgrade corrige uma CVE em todos os apps de uma vez. Um app
self-contained fica com o runtime da publicação até você publicar de novo.
Um app de console que imprime o runtime e o RID, publicado dos dois jeitos:
| Modo | Arquivos | Tamanho |
|---|---|---|
| framework-dependent | 5 | 0,14 MB (apphost 124 KB, Hello.dll 5,5 KB) |
| self-contained | 191 | 82,8 MB |
Single file: um executável, com exceções
-p:PublishSingleFile=true anexa os assemblies gerenciados, o deps.json e o
runtimeconfig.json ao apphost. Funciona nos dois modos:
| Modo | Arquivos | Tamanho |
|---|---|---|
| framework-dependent, single file | 2 (Hello, Hello.pdb) |
0,13 MB |
| self-contained, single file | 2 | 76,4 MB |
Os assemblies gerenciados são carregados de dentro do bundle, sem extração. Isso quebra código que supõe assembly em disco, e o analisador avisa já no build:
textProgram.cs(6,33): warning IL3000: 'System.Reflection.Assembly.Location.get' always returns an empty string for assemblies embedded in a single-file app. If the path to the app directory is needed, consider calling 'System.AppContext.BaseDirectory'.
A execução confirmou: Location: '', enquanto AppContext.BaseDirectory
imprimiu a pasta de publicação.
Bibliotecas nativas são a exceção. Um app com Microsoft.Data.Sqlite publicado
como single file ainda gerou três arquivos: Pkg, Pkg.pdb e
libe_sqlite3.dylib (1,5 MB) ao lado. Com
-p:IncludeNativeLibrariesForSelfExtract=true a biblioteca entra no bundle,
mas o host precisa extraí-la para o disco antes de carregar. Rodando com
DOTNET_BUNDLE_EXTRACT_BASE_DIR apontando para uma pasta de rascunho,
apareceu extract/Pkg/0UcDQXFeJ4aL/libe_sqlite3.dylib. Sem essa variável, a
documentação diz que os arquivos vão para $HOME/.net no Linux e no macOS e
para %TEMP%\.net no Windows, o que falha em units do systemd sem $HOME.
(O código nativo do próprio runtime já estava dentro do bundle no macOS; a
documentação descreve arquivos de runtime separados no Windows.)
-p:EnableCompressionInSingleFile=true comprime os assemblies embutidos: o
Hello self-contained caiu de 76,4 MB para 34,8 MB, e o startup subiu de cerca
de 40 ms para cerca de 75 ms, porque tudo é descomprimido em memória no início
(pico de RSS de 37 MB para 50 MB).
Trimming: a análise estática apaga o framework não usado
-p:PublishTrimmed=true roda o ILLink sobre a saída self-contained. Ele
parte do Main, segue toda referência estática e remove assemblies, tipos e
membros que nunca são alcançados. Trimming só existe para apps self-contained
(o framework compartilhado de um app framework-dependent não pode ser cortado
por app).
| Modo | Arquivos | Tamanho |
|---|---|---|
| self-contained, trimmed | 29 | 20,2 MB |
| self-contained, single file, trimmed | 2 | 13,0 MB |
A análise não enxerga reflection guiada por dado. Este programa tem os três padrões clássicos:
csharpusing System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
// 1. A type chosen at run time from a string
var name = args.Length > 0 ? args[0] : "Plugin";
Console.WriteLine($"GetType(\"{name}\"): {Type.GetType(name)?.Name ?? "null"}");
// 2. Reflecting over a Type parameter, without and with annotation
Console.WriteLine($"Plain: {string.Join(", ", Methods(typeof(InvoiceA)))}");
Console.WriteLine($"Annotated: {string.Join(", ", AnnotatedMethods(typeof(InvoiceB)))}");
// 3. Reflection-based JSON
try { Console.WriteLine(JsonSerializer.Serialize(new InvoiceA())); }
catch (Exception ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}"); }
static IEnumerable<string> Methods(Type t) =>
t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).Select(m => m.Name);
static IEnumerable<string> AnnotatedMethods(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type t) =>
t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).Select(m => m.Name);
class Plugin { }
class Exporter { }
class InvoiceA { public decimal Total { get; set; } = 10m; public void Print() { } }
class InvoiceB { public decimal Total { get; set; } = 10m; public void Print() { } }A publicação com trimming imprimiu cada aviso duas vezes: uma pelo analisador
Roslyn (warning IL2057) e outra pelo ILLink (Trim analysis warning IL2057):
textProgram.cs(7,43): warning IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String)'. It's not possible to guarantee the availability of the target type. Program.cs(18,5): warning IL2070: 'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicMethods' in call to 'System.Type.GetMethods(BindingFlags)'. Program.cs(14,25): warning IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(...)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.
Sem e com trimming, passando Exporter como argumento:
text// dotnet run -c Release -- Exporter GetType("Exporter"): Exporter Plain: get_Total, set_Total, Print Annotated: get_Total, set_Total, Print {"Total":10} // ./out/trim/Trim Exporter GetType("Exporter"): null Plain: Annotated: get_Total, set_Total, Print InvalidOperationException: Reflection-based serialization has been disabled for this application. ...
Cada aviso virou falha de verdade. Exporter nunca foi referenciado, então foi
apagado (o Plugin padrão sobreviveu, aparentemente porque o ILLink conseguiu
ver aquele literal). InvoiceA perdeu todos os métodos. O parâmetro anotado é
a correção: [DynamicallyAccessedMembers] diz ao ILLink que qualquer Type que
chegue ali precisa manter os métodos públicos, e o analisador passa a conferir
cada chamador. [RequiresUnreferencedCode] é a outra ferramenta: marca uma API
como incompatível, e o aviso sobe para quem a chama, como faz o
JsonSerializer. O JSON não foi apagado; foi desligado por um feature
switch.
Feature switches: trimming muda comportamento, não só tamanho
PublishTrimmed grava switches no runtimeconfig.json. Alguns, do app acima:
plaintext"System.Net.SocketsHttpHandler.Http3Support": false, "System.StartupHookProvider.IsSupported": false, "System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": false, "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
Http3Support é novidade do .NET 10: com PublishTrimmed ou PublishAot, o
HttpClient deixa de carregar HTTP/3, a menos que você defina
<Http3Support>true</Http3Support>. StartupHookProvider desligado significa
que DOTNET_STARTUP_HOOKS é ignorado, e é assim que alguns agentes de APM se
acoplam. Cada switch também tem uma propriedade MSBuild (EventSourceSupport,
InvariantGlobalization, UseSystemResourceKeys) para cortar mais.
Pacotes recebem outro tratamento. Com Newtonsoft.Json 13.0.4, as chamadas no
seu código aparecem uma a uma, mas os avisos de dentro do pacote viram um só:
textNewtonsoft.Json.dll : warning IL2104: Assembly 'Newtonsoft.Json' produced trim warnings.
<TrimmerSingleWarn>false</TrimmerSingleWarn> abre a lista. Esse app ainda
funcionou com trimming, por sorte: o construtor e os getters do record eram
referenciados pelo próprio código.
Trimming e ReadyToRun: a surpresa no startup
ReadyToRun (R2R) guarda código nativo ao lado do IL para o JIT trabalhar menos
no startup (veja o conceito sobre o JIT). O framework já vem em R2R. O mesmo app
com SQLite e Newtonsoft, com um Stopwatch em volta do Main (mediana de 11
execuções) e a contagem de métodos compilados pelo JIT
(DOTNET_JitStdOutFile=/dev/stdout DOTNET_JitDisasmSummary=1):
| Publicação | Tamanho | Métodos no JIT | Tempo no Main |
Pico de RSS |
|---|---|---|---|---|
| framework-dependent | 0,96 MB de DLLs | 693 | 46 ms | 72,5 MB |
| framework-dependent, R2R | 2,7 MB de DLLs | 56 | 27 ms | 74,8 MB |
| self-contained single file | 77,3 MB | 693 | 46 ms | 68,7 MB |
| single file, trimmed | 19,3 MB | 3.021 | 101 ms | 29,1 MB |
| single file, trimmed, R2R | 44,0 MB | 153 | 19 ms | 53,0 MB |
Newtonsoft.Json.dll cresceu de 723 KB para 2,0 MB com R2R (2,9 vezes). A
linha com trimming é a armadilha: o JIT compilou 3.021 métodos em vez de 693,
sinal de que as cópias aparadas dos assemblies do framework perderam o código
pré-compilado, e o app passou o dobro do tempo no Main. Somar
PublishReadyToRun recompila o conjunto aparado e ganha em tudo, menos em
tamanho.
Trade-offs
- Framework-dependent é pequeno e recebe patch pelo SO; self-contained fica
congelado. Um app self-contained carrega as CVEs do seu runtime até ser
republicado.plaintext
dotnet publish -r linux-x64 --self-contained # republicar a cada patch do runtime - Single file simplifica a cópia, não a lógica de caminhos.
Assembly.Locationvem vazio, e bibliotecas nativas de pacotes ficam ao lado do arquivo ou são extraídas para uma pasta que precisa ser gravável e confiável. - Compressão corta o arquivo pela metade e dobra o startup. Meça antes de ligar; serve para ferramenta rodada de vez em quando, não para CLI chamada em loop.
- Trimming levou 82,8 MB para 20,2 MB, mas transforma bug de reflection em
bug de produção. Trate
IL2xxxcomo erro e teste o binário publicado, não odotnet run. Com a linha abaixo, a publicação do programa acima parou comerror IL2026,error IL2057eerror IL2070.plaintext<PublishTrimmed>true</PublishTrimmed> <WarningsAsErrors>$(WarningsAsErrors);IL2026;IL2057;IL2070;IL2104</WarningsAsErrors> - Trimming sem R2R pode iniciar mais devagar que sem trimming. Aqui, 101 ms
contra 46 ms no
Main; trimming com R2R foi o mais rápido, com 19 ms. - R2R troca disco por startup. Assemblies 2 a 3 vezes maiores; pouco ganho quando o código do próprio app é pequeno, já que o framework já é R2R.
- Feature switches mudam o comportamento em runtime. HTTP/3, startup hooks e JSON por reflection ficam desligados em apps .NET 10 com trimming até você religar.
Documentation Links
- .NET application publishing overview, Microsoft Learn
- Create a single file for application deployment, Microsoft Learn
- Trimming options, Microsoft Learn
- Fixing trim warnings, Microsoft Learn
- ReadyToRun deployment overview, Microsoft Learn
- HTTP/3 disabled by default with PublishTrimmed (.NET 10 breaking change), Microsoft Learn