.NET 10 porta una novetat que em sembla més important del que pot semblar a primera vista: dotnet run app.cs i dotnet publish app.cs. En resum: pots agafar un sol fitxer C#, sense .csproj, i executar-lo o publicar-lo directament.
Quan ho vaig veure, la lectura fàcil era "ah, finalment .NET té un mode script decent". I sí, una mica va per aquí. Microsoft ho va presentar a l'article Announcing dotnet run app.cs i també hi ha algun resum ràpid com aquest de dev.to.
Però aquest post no va d'explicar la feature en abstracte. Va d'un use-case molt concret que em sembla especialment bo: fer servir un .cs solt com a entrypoint compilat amb Native AOT per a contenidors hardened, distroless, rootless i sense shell. L'exemple real surt de vegops/containers, concretament de la imatge de qBittorrent.
El problema: contenidors sense shell
Quan dius "distroless" aquí no parles només de fer una imatge petita. Parles d'una imatge amb el mínim imprescindible a runtime. En aquest cas concret, l'entorn va sobre Wolfi/Chainguard, sense busybox, sense sh, sense apt, i sense tota la ferralla habitual que acostuma a venir amb una distribució generalista.
A més a més, el contenidor arrenca com a nonroot amb UID 65532. Això és exactament el que vols en una imatge hardened: menys superfície, menys privilegis, menys sorpreses. El problema és que abans d'arrencar el procés principal sovint encara necessites una mica de lògica d'init.
Pot ser copiar una configuració per defecte, o injectar un secret via variable d'entorn, o bé, substituir el procés correctament perquè el teu servei quedi com a PID 1 i rebi senyals directament. Amb shell això és trivial. Sense shell, has de buscar un altre mètode. Les opcions típiques són un binari petit en Go, Rust, tini, o ara també un fitxer C# compilat amb Native AOT.
En un contenidor sense shell, el problema no és "com executo una comanda". El problema real és "on poso la lògica d'entrada sense reintroduir dependències i complexitat".
La solució: un entrypoint.cs amb Native AOT
L'exemple de qBittorrent ho resolc amb un únic fitxer: entrypoint.cs. No hi ha projecte, ni dependències externes, ni tan sols runtime .NET dins la imatge final. Durant el build es compila amb Native AOT i el resultat és un binari autocontingut que acaba a /usr/bin/entrypoint.
La part interessant és que el mateix fitxer porta directives #:property per controlar la compilació. Aquí és on defineixes que vols PublishAot=true, que vols treure símbols, optimitzar per mida, retallar metadades i desactivar coses que no calen com el suport de stack traces.
El programa fa tres coses i prou: garantir que hi ha configuració, rotar la password per defecte si encara hi és, i fer un execv() sobre el binari real de qBittorrent. Res més. Just el tipus d'entrypoint petit, específic i visible que vols en un contenidor distroless.
Aquest és l'entrypoint sencer:
#:property PublishAot=true
#:property InvariantGlobalization=true
#:property StripSymbols=true
#:property OptimizationPreference=Size
#:property IlcOptimizationPreference=Size
#:property StackTraceSupport=false
#:property UseSystemResourceKeys=true
#:property IlcTrimMetadata=true
#:property AssemblyName=entrypoint
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
const string AppBin = "/usr/bin/qbittorrent";
const string ConfigPath = "/qbittorrent/etc/qBittorrent.conf";
const string DefaultConfigPath = "/usr/share/qbittorrent/qBittorrent.conf";
const string DefaultPasswordHash = "@ByteArray(188J/h/wfAYQ9H+mTl/7lA==:j/+e2SwJUi9g+IPiEG2+Pix9W0IOv2c20QjrmBUhr4TBUXO3fcMv6leeU6qK8834xiq8fngh8ShwYDfYO0w6lg==)";
const string Alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
try
{
EnsureConfig();
RotateDefaultPassword();
Exec();
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
Environment.Exit(1);
}
static void EnsureConfig()
{
Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!);
var inlineConfig = Environment.GetEnvironmentVariable("QBITTORRENT_CONFIG");
if (!string.IsNullOrEmpty(inlineConfig))
{
File.WriteAllText(ConfigPath, inlineConfig);
return;
}
if (!File.Exists(ConfigPath))
File.Copy(DefaultConfigPath, ConfigPath);
}
static void RotateDefaultPassword()
{
var content = File.ReadAllText(ConfigPath);
if (!content.Contains(DefaultPasswordHash, StringComparison.Ordinal))
return;
var password = RandomString(12);
var salt = RandomString(16);
var hash = Rfc2898DeriveBytes.Pbkdf2(
password,
Encoding.UTF8.GetBytes(salt),
100000,
HashAlgorithmName.SHA512,
64);
var replacement = $"@ByteArray({Convert.ToBase64String(Encoding.UTF8.GetBytes(salt))}:{Convert.ToBase64String(hash)})";
var updated = content.Replace(DefaultPasswordHash, replacement, StringComparison.Ordinal);
File.WriteAllText(ConfigPath, updated);
Console.WriteLine($"qBittorrent admin password: {password}");
}
static string RandomString(int length)
{
var bytes = RandomNumberGenerator.GetBytes(length);
var chars = new char[length];
for (var i = 0; i < bytes.Length; i++)
chars[i] = Alphabet[bytes[i] % Alphabet.Length];
return new string(chars);
}
static void Exec()
{
var args = Environment.GetCommandLineArgs();
var argv = new string?[args.Length + 1];
argv[0] = "qbittorrent";
Array.Copy(args, 1, argv, 1, args.Length - 1);
argv[^1] = null;
var result = NativeMethods.execv(AppBin, argv);
throw new Exception($"execv({AppBin}) failed: errno {Marshal.GetLastPInvokeError()}, result {result}");
}
static class NativeMethods
{
[DllImport("libc", SetLastError = true)]
internal static extern int execv(string filename, string?[] argv);
}Desglossament del codi
Configuració: EnsureConfig()
La primera responsabilitat és assegurar que qBittorrent té un fitxer de configuració usable. Si existeix la variable d'entorn QBITTORRENT_CONFIG, el contingut s'escriu directament a /qbittorrent/etc/qBittorrent.conf. Si no, i encara no hi ha config, es copia el fitxer per defecte des de /usr/share/qbittorrent/qBittorrent.conf.
És una lògica simple, però és exactament la mena de coses que abans acabaven en un entrypoint.sh. La diferència és que aquí no depens de tenir shell ni eines externes a runtime.
Un bon detall és que la configuració inline via variable d'entorn evita haver de muntar scripts auxiliars només per injectar quatre línies de config.
Rotació de password: RotateDefaultPassword()
La segona responsabilitat és evitar que qBittorrent arrenqui amb el hash per defecte. El codi llegeix el fitxer, busca el DefaultPasswordHash conegut, i si encara hi es genera una password aleatòria i un salt nou.
Després deriva el hash amb Rfc2898DeriveBytes.Pbkdf2, fent servir SHA512, 100000 iteracions i una sortida de 64 bytes. Amb això construeix el format @ByteArray(...) que qBittorrent espera, substitueix el hash per defecte i escriu el fitxer actualitzat.
Finalment imprimeix la nova password a stdout. En un contenidor això és pràctic perquè la pots recuperar directament dels logs de la primera arrencada, sense inventar un mecanisme paral·lel.
Process replacement: Exec()
La tercera peça és la més pròpia de contenidors "de debò": fer un execv() via P/Invoke a libc. Això substitueix el procés actual pel binari real de qBittorrent, en lloc de llançar un subprocess i quedar-te amb un wrapper pel mig.
Aquest detall importa perquè l'entrypoint acaba desapareixent i qBittorrent queda com a PID 1. Això vol dir senyals directes, menys problemes de reaping, i un comportament molt més net quan el runtime del contenidor envia SIGTERM.
Si has fet servir scripts shell com a entrypoint, ja saps el patró: o fas exec, o tard o d'hora acabes amb shutdowns estranys. Aquí es resol igual, però sense shell.
Com es compila i s'integra
Al pipeline de build, el fitxer es compila directament des de melange.yaml:
- name: Build entrypoint (Native AOT)
runs: |
arch="$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/')"
dotnet publish entrypoint.cs -c Release -r "linux-${arch}" -o .
- name: Install artifacts
runs: |
install -Dm755 entrypoint "${{targets.destdir}}/usr/bin/entrypoint"Després, a apko.yaml, la imatge final defineix aquest binari com a entrypoint i arrenca com a usuari nonroot:
accounts:
groups:
- groupname: nonroot
gid: 65532
users:
- username: nonroot
uid: 65532
gid: 65532
run-as: "65532"
entrypoint:
command: /usr/bin/entrypointEl resultat és bastant elegant: el binari viu a /usr/bin/entrypoint, la imatge no necessita ni runtime de .NET ni shell, i tota la lògica d'inicialització queda encapsulada en una peça petita i auditable.
Per què C# i no Go/Rust/shell?
Si ja tens .NET disponible al pipeline de build, afegir un fitxer .cs és gairebé trivial. No has de crear cap projecte, no has de mantenir dependències de tercers, i no has d'obrir un repositori auxiliar només per un entrypoint de poques línies.
Go i Rust continuen sent opcions perfectament bones i vàlides per això. I si tens shell disponible, un script encara pot ser suficient en casos simples. Però en un entorn on ja tens dotnet-10-sdk al build i vols un binari Native AOT petit i autocontingut, C# passa de ser una opció curiosa a ser probablement la més convenient.
Tancament
La novetat de single-file C# a .NET 10 és fàcil de vendre com una millora de DX. Però crec que el més interessant és una altra cosa: obre la porta a un scripting "seriós" amb C#, prou simple per a tasques petites i prou sòlid per acabar a producció.
En aquest cas, no estem parlant d'un hello world. Estem parlant d'un entrypoint Native AOT per a una imatge rootless, distroless i sense shell. I això, sincerament, em sembla un use-case molt millor que qualsevol demo.
Si vols veure l'exemple complet, amb entrypoint.cs, melange.yaml i apko.yaml, aquí el tens: vegops/containers qBittorrent.
No dic que sigui l'única resposta bona. Dic que, si ja vius dins l'ecosistema .NET, ara tens una resposta molt pragmàtica.