Homelab · Episódio 3
Sou de CSharp e Escrevo Meus Projetos em Rust
C# ou Rust no Kubernetes? Medi imagem e memória de ASP.NET, Native AOT e Rust: o AOT empata no tamanho, mas o Rust ocupa seis vezes menos RAM.

Neste artigo · 8 seções
Tenho muito mais estrada em C# do que em Rust. Mesmo assim, o backend do aplicativo que eu estou construindo em casa é Rust. Quando alguém pergunta por quê, a resposta decepciona: não foi por desempenho nem por segurança de memória. Foi pelo tamanho da imagem Docker.
No primeiro episódio desta série eu contei isso de passagem e deixei a tribo do .NET convidada a discordar. Antes de alguém discordar, resolvi medir direito. E a medição discordou de mim também, pelo menos em parte.
o motivo besta
As imagens dos meus apps ficam no registry de containers do GitHub, o GHCR. Quando o app nasceu, em setembro, minha conta estava no plano gratuito, e a tabela de cotas do GitHub diz que o plano Free tem 500 MB para pacotes privados, divididos ainda com os artefatos do Actions. O documento de plataforma do homelab, escrito no primeiro dia, resumiu a preocupação numa frase: "duas imagens .NET esgotam".
A frase é exagerada, e já chego nisso. Mas a ideia por trás dela era simples: a imagem base do ASP.NET, a aspnet:10.0 da Microsoft, pesa quase 96 MB comprimida. Num espaço de 500 MB, isso é muita coisa para um app que, no fim, só responde JSON.
E tem um detalhe que deixa a história mais engraçada. A documentação do GitHub diz hoje que "o armazenamento e a banda das imagens no registry de containers são, por enquanto, gratuitos" (no original, "Container image storage and bandwidth for the Container registry is currently free"), com a promessa de avisar com pelo menos um mês de antecedência antes de mudar. Ou seja, a cota que me assustou talvez nem esteja sendo cobrada. Eu preferi não construir em cima de um "por enquanto", mas reconheço que o motivo começou mais besta do que eu admitia.
medindo direito
Para este artigo eu fiz o que devia ter feito antes de escolher a linguagem: medi. Primeiro as imagens base, lendo direto no registry o manifesto de cada uma, o arquivo que lista as camadas da imagem e o tamanho de cada camada, sem baixar nada. Depois três versões do mesmo programa, um "hello world" HTTP que responde "ok" na raiz: uma em ASP.NET do jeito tradicional, com JIT, em que o código é compilado enquanto o app roda; outra em ASP.NET com Native AOT, que compila tudo para código de máquina já no build; e uma em Rust, com o framework web axum.
Cada uma foi construída na imagem base que faz sentido para ela, publicada num registry local e medida do mesmo jeito. A memória medida é a residente do processo, o RSS da saída abaixo, ou seja, quanta RAM ele ocupa de fato, lida dois segundos depois da primeira requisição, com o container limitado a 3 CPUs, que é o tamanho de uma VM do meu cluster. Três execuções para cada uma.
$ ./medir.sh
bases, comprimidas
mcr.microsoft.com/dotnet/aspnet:10.0 → 95.9 MB
mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled → 55.1 MB
mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled → 5.8 MB
gcr.io/distroless/cc-debian12 → 9.2 MB
gcr.io/distroless/static-debian12 → 0.7 MB
hello world HTTP, 3 CPUs: imagem final e memória (3 execuções)
hello-dotnet-jit → imagem 55.2 MB · RSS 63.9 / 64.1 / 63.7 MiB
hello-dotnet-aot → imagem 10.3 MB · RSS 20.7 / 20.7 / 20.8 MiB
hello-rust → imagem 9.6 MB · RSS 3.2 / 3.2 / 3.2 MiB
"Comprimida" é o tamanho que o registry guarda e que o nó do Kubernetes baixa quando vai subir o pod. A aspnet:10.0 é a imagem completa, em cima de um Debian, com shell, gerenciador de pacotes e tudo mais. A versão noble-chiseled é a mesma coisa em cima de um Ubuntu "esculpido", do qual a Canonical tirou tudo o que um app não usa: sem shell, sem gerenciador de pacotes, só os arquivos de que o runtime precisa. Só isso já tira 40 MB.
A runtime-deps chiseled é outra conversa. Ela não tem o runtime do .NET, só as bibliotecas do sistema de que um programa nativo precisa, porque é feita para receber um binário Native AOT, que já leva o runtime compilado dentro dele. A distroless/cc, do projeto distroless do Google, faz o mesmo papel para o Rust: traz a glibc (a biblioteca C da maioria das distribuições Linux), a libgcc e os certificados para falar HTTPS, e mais nada. A distroless/static é ainda menor porque serve só para binários totalmente estáticos, que não dependem nem da glibc.
Agora, os três programas. O ASP.NET tradicional dá 55,2 MB, praticamente a base inteira, já que o app em si quase não pesa. O Rust dá 9,6 MB. E o Native AOT dá 10,3 MB.
Setecentos kilobytes de diferença.
o motivo besta perdeu
Se o argumento fosse só o tamanho da imagem, eu teria que voltar para o C#. O .NET de hoje, compilado com Native AOT em cima da base chiseled, chega a menos de um mega do Rust. A base dele é até menor que a do Rust (5,8 MB contra 9,2 MB); o binário é que é maior, porque carrega junto o runtime e o coletor de lixo, enquanto o binário do Rust, nesse exemplo, comprime para menos de meio mega.
Só que o AOT tem preço. Ele abre mão do JIT, o compilador que roda junto com a aplicação e otimiza o código enquanto ela trabalha, sobre o qual eu já escrevi em Melhorias do JIT no .NET 10. E ele não convive bem com reflection: o compilador precisa saber, na hora de compilar, todo código que vai existir, e corta o que não aparece sendo usado. É o tal do trimming. A serialização de JSON passa a usar source generator, e nem toda biblioteca do ecossistema está pronta para isso. Você descobre quais não estão pelos avisos de trimming no build.
A imagem chiseled ainda não traz as bibliotecas de globalização, então o app roda em modo invariante: formatação de data e de número e comparação de texto deixam de seguir as regras de cada idioma, como as do pt-BR, a não ser que você use a variante extra.
Mesmo com esse preço, para um time .NET, AOT com chiseled vem antes de qualquer conversa sobre trocar de linguagem por causa de imagem. Eu não experimentei antes de escolher. Se tivesse experimentado, o tamanho da imagem não teria decidido nada.
onde o Rust ainda ganha
O tamanho empatou. A memória, não.
Olha o fim de cada linha da saída. O hello world em ASP.NET tradicional ocupa uns 64 MiB de memória residente parado. O AOT cai para uns 21 MiB, porque não carrega o JIT nem o código que ele gera. O Rust ocupa 3,2 MiB. São 20 vezes menos que o ASP.NET tradicional e mais de seis vezes menos que o AOT. E isso com o container limitado a 3 CPUs; sem o limite, numa máquina de 24 threads, os números do .NET quase não mudaram.
A diferença tem explicação. O .NET, mesmo compilado para nativo, continua tendo um runtime e um coletor de lixo, que reserva memória para trabalhar e mantém estruturas próprias. Já falei do ciclo de vida desse coletor em Garbage Collection em .NET Core. O Rust não tem coletor: a memória é liberada no ponto exato em que o dono dela sai de escopo, e o compilador garante que isso esteja certo antes de deixar o programa existir.
Num servidor com memória sobrando, isso não importa. No meu, importa. O cluster são três VMs de 6, 5 e 5 GB dividindo uma máquina de 32 GB com outras coisas, e memória é o recurso mais disputado. Por isso o app em Rust pede ao Kubernetes só 32 MiB de memória reservada, contra 96 MiB do app de demonstração em Python e 128 MiB do padrão do meu chart. Um cuidado com esses números: são o que cada app declara que precisa, não o que eu medi em produção. O hello world é o que eu medi, e ele aponta na mesma direção.
Teve uma surpresa nessa parte. A decisão de arquitetura do app diz que o backend é um "binário estático". Não é. Ele é compilado contra a glibc, e por isso a base dele é a distroless/cc, de 9,2 MB, e não a distroless/static, de 0,7 MB. Compilar contra a musl, uma biblioteca C alternativa pensada para binário estático, daria um binário estático de verdade e economizaria mais uns 8 MB, mas a troca tem custos que eu ainda não medi, e não vou fazê-la no chute.
a parte que não era o motivo
A memória me deixou no Rust. O que me fez gostar dele foi outra coisa, que não estava em nenhuma lista quando eu escolhi: o sqlx.
O sqlx é a biblioteca de acesso a banco do app, e ele faz uma coisa que o .NET não faz de fábrica: confere o SQL em tempo de compilação. As consultas do código de produção são escritas com macros como query!, e o compilador verifica cada uma contra o esquema real do banco, com tipos de coluna, nulos e tudo. Errou o nome de uma coluna? O build quebra. Mudou o tipo de um campo numa migration e esqueceu de ajustar a consulta? O build quebra. No .NET, o caminho comum é pegar esse tipo de erro num teste de integração, se alguém escreveu o teste.
Para o build não precisar de um banco ligado, o sqlx guarda os metadados de cada consulta numa pasta versionada no repositório, com 37 arquivos hoje. E o CI tem um job só para garantir que essa pasta não está mentindo: ele sobe um Postgres vazio, aplica as migrations e confere se os metadados batem com o código. Depois, de propósito, altera uma consulta e exige que a conferência reprove. Se ela passar com a consulta adulterada, o job falha. É um teste que só vale alguma coisa porque tem que falhar.
As migrations também vão para dentro do binário. A macro sqlx::migrate! lê os arquivos de SQL na hora de compilar e embute cada um no executável, então a imagem de produção não carrega pasta de SQL nenhuma. O comentário no Dockerfile resume bem a consequência: "um pod nunca aplica um esquema diferente daquele com que foi compilado".
Tem uma pegadinha, e ela vale para qualquer linguagem. Durante um deploy canary, o pod novo sobe, aplica a migration e passa a receber parte do tráfego, enquanto o pod antigo continua rodando contra o mesmo banco, agora com o esquema novo. As duas versões convivem por alguns minutos acreditando em esquemas diferentes. O binário garante que cada pod conhece o esquema com que foi compilado, mas não garante que o banco ainda seja aquele. Migration que quebra a versão anterior, como renomear ou apagar uma coluna que o código antigo ainda lê, continua proibida, com ou sem Rust.
o que o Rust me cobra
Não é de graça, e o .NET ganha em várias frentes. O compilador do Rust é lento, e o cache das dependências no Docker virou engenharia à parte: o Docker só reaproveita uma camada se nada antes dela mudou, então o Dockerfile compila primeiro um main vazio só com as dependências; assim, mexer no código não recompila o mundo. A curva de aprendizado é real, e o compilador recusa programa que um dev de C# escreveria sem pensar. E, para um time, contratar gente que domine Rust é bem mais difícil do que achar quem domine C#.
O Python do app de demonstração ensina outro custo, que não aparece em fatura nenhuma. A imagem dele mede 72,7 MB, em cima de uma base python:3.13-slim de 43,1 MB. No primeiro build, o portão do Trivy, o scanner de vulnerabilidades da esteira, barrou a publicação com três falhas críticas, todas em pacotes do sistema operacional da imagem base. O Dockerfile ganhou um apt-get upgrade só por causa disso. Imagem maior tem mais pacote, e mais pacote é mais coisa para o scanner reclamar e mais tempo de pull num nó que acabou de subir. Também é mais espaço ocupado no registry, onde nada é apagado: cada push publica uma imagem nova, e a decisão escrita é medir o crescimento antes de automatizar a limpeza. Ainda não medi.
E é por isso que a frase "duas imagens .NET esgotam" estava errada. Duas imagens de 96 MB não chegam perto de 500 MB. O que chega é o acúmulo de versões que ninguém limpa, e isso acontece com imagem de qualquer tamanho, só que mais rápido com as grandes.
o que fica
Eu escolhi Rust pelo tamanho da imagem, e a medição tirou esse argumento de mim: o Native AOT do .NET chega a 700 KB do Rust em tamanho de imagem. O que me segura no Rust é outra coisa. É a memória, mais de seis vezes menor que a do AOT num hello world, num cluster em que memória é o recurso mais disputado. E é o sqlx, que transforma erro de SQL em erro de compilação.
Se você trabalha num time .NET e está olhando imagens de 100 MB com inveja de quem usa Go ou Rust, o recado é mais simples do que trocar de linguagem: experimente AOT com chiseled, meça e só depois decida. E, se for medir, meça a memória também.
Insights & Takeaways
- A base do ASP.NET tem 95,9 MB comprimida. A chiseled corta para 55,1 MB, e o Native AOT sobre
runtime-depschiseled fecha a imagem em 10,3 MB, a 0,7 MB de um binário Rust sobre distroless. - A diferença grande está na memória: 64 MiB no ASP.NET tradicional, 21 MiB no AOT e 3,2 MiB no Rust, num hello world limitado a 3 CPUs.
- Native AOT cobra em reflection e trimming. Para um time .NET, é o primeiro experimento antes de qualquer troca de linguagem.
- O sqlx confere o SQL contra o esquema em tempo de compilação, e o CI prova que essa conferência funciona exigindo que ela reprove uma consulta adulterada.
- Migration embutida no binário garante o esquema de cada pod, não o do banco. Num canary, as duas versões convivem com o esquema novo, então migration que quebra a versão anterior continua proibida.
Pergunta para a tribo do .NET: quem aqui já colocou Native AOT em produção, e qual foi a primeira biblioteca que travou no trimming?


