De ripgrep-crash op musl: waarom je .NET-container op Alpine je kan verraden
Een segfault in ripgrep op musl legt een probleem bloot dat ook jouw .NET-container op Alpine raakt. Over allocators, libc-verschillen en welke base image je echt moet kiezen.
Jean-Pierre Broeders
Freelance .NET Developer
Er staat op dit moment een bugreport open bij ripgrep die me aan het denken zette. Niet omdat ik ripgrep-broncode schrijf, maar omdat de oorzaak precies onder de laag zit waar de meeste .NET-teams nooit naar kijken: de libc in hun container.
De korte versie. Iemand draaide de statische musl-build van ripgrep over een enorme boom, ongeveer 20 GB verdeeld over 1,8 miljoen bestanden, op een machine met 24 cores. Na ongeveer een minuut: SIGSEGV. De backtrace wijst naar de allocator van musl (mallocng), bij een calloc() die vanuit opendir() wordt aangeroepen. Er faalt een integriteitscheck op de heap-metadata. Op glibc gebeurt dit niet. Zelfde code, ander libc, andere afloop.
Dat is het soort bug dat je in ontwikkeling nooit ziet en in productie precies één keer, op het slechtste moment.
Waarom dit een .NET-verhaal is
.NET draait al jaren op musl. De runtime heeft een eigen RID daarvoor: linux-musl-x64. En Microsoft publiceert kant-en-klare Alpine-images, zoals mcr.microsoft.com/dotnet/aspnet:9.0-alpine. Alpine is populair omdat de images klein zijn. Een paar tientallen megabytes in plaats van een paar honderd. Kleiner image betekent sneller pullen, minder aanvalsoppervlak, snellere cold starts. Allemaal echte voordelen.
De prijs staat er alleen niet bij. Alpine gebruikt musl in plaats van glibc, en die twee zijn niet inwisselbaar. Ze implementeren allebei de C-standaardbibliotheek, maar de keuzes eronder verschillen. Threadstacks, DNS-resolutie, locale-afhandeling, en dus ook de allocator. De ripgrep-bug is een allocator-verhaal, en dat raakt jou zodra je container onder druk komt te staan.
Belangrijk detail: de crash zit in de allocator van musl zelf, aangeroepen door libc (opendir), niet in de Rust-code van ripgrep. Ook al zou ripgrep zijn eigen allocator meenemen voor de Rust-kant, de aanroepen die libc intern doet blijven via musl lopen. Vertaal dat naar .NET: jouw managed code gaat door de GC, maar elke keer dat je P/Invoke doet, een native library laadt, of een syscall via libc gaat, zit je op het pad dat hier faalde.
Wat musl anders doet dan glibc
Het gaat mij niet om musl bashen. musl is klein, netjes en correct geschreven. Maar er zijn drie verschillen die in .NET-productie telkens terugkomen.
Ten eerste de allocator. musl koos met mallocng voor een ontwerp dat lage fragmentatie en voorspelbaarheid vooropstelt. Onder zware, gelijktijdige allocatie is die aanpak trager dan glibc en dan alternatieven als jemalloc of mimalloc. De ripgrep-thread laat zien dat het onder extreme concurrency niet alleen om snelheid gaat, maar dat er ook een crash kan optreden. Op het moment van schrijven is de discussie nog niet uitgemond in een definitieve oorzaak of fix. Het staat open, en dat is precies waarom ik er hier over schrijf.
Ten tweede de threadstack. De standaard stackgrootte per thread ligt bij musl veel lager dan bij glibc. Historisch rond de 128 KB tegenover megabytes. .NET zet voor zijn eigen managed threads meestal een expliciete stackgrootte, dus daar merk je het niet direct. Maar native libraries die op een diepe recursie of grote stackbuffers rekenen, kunnen op musl omvallen waar ze op glibc net genoeg lucht hadden.
Ten derde globalization. Alpine heeft van oudsher geen volledige ICU aan boord. Vroeger moest je daarom DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true zetten of icu-libs los installeren, anders klapte je app bij de eerste CultureInfo. Nieuwere Alpine-images van Microsoft regelen dit netter, maar zodra je zelf een base image samenstelt, ben je hier weer verantwoordelijk voor.
De kern: je test op glibc en je draait op musl
Dit is waar het misgaat in de praktijk. Je ontwikkelt op je Mac of op Windows met WSL, waar je waarschijnlijk glibc onder de motorkap hebt. Je CI draait op ubuntu-latest, ook glibc. Al je tests zijn groen. En dan bouw je een productie-image op -alpine, want klein is mooi, en je stuurt musl-code de wereld in die nooit onder musl is getest.
Bij lage load valt dat niet op. De ripgrep-bug had 24 cores en een miljoen bestanden nodig om binnen een minuut te crashen. Jouw variant hoeft geen file-search te zijn. Het kan een piek in gelijktijdige requests zijn, een batch-job die duizenden kleine allocaties doet, of een native imaging-library die onder druk net anders met de heap omgaat. Het patroon is hetzelfde: het verschil zit in de laag die je niet test.
Wat ik in de praktijk doe
Ik ben niet tegen Alpine. Ik ben tegen Alpine-per-ongeluk. Hier is hoe ik de keuze maak.
Weet eerst welke RID en welke libc je gebruikt. Je kunt dat vanuit de app zelf loggen bij startup, zodat het in je logs staat en niet in je hoofd.
using System.Runtime.InteropServices;
var rid = RuntimeInformation.RuntimeIdentifier;
var desc = RuntimeInformation.OSDescription;
Console.WriteLine($"RID: {rid}");
Console.WriteLine($"OS: {desc}");
// Op een Alpine-image zie je hier linux-musl-x64 verschijnen.
// Op Debian of Ubuntu staat er linux-x64.
Dat is één regel in je startuplog die je maanden later een middag debuggen bespaart.
Chiseled in plaats van Alpine
Als je Alpine koos puur voor de grootte, dan is er sinds .NET 8 een beter alternatief: chiseled images. Dat zijn uitgeklede Ubuntu-images, dus glibc, zonder shell en zonder package manager. Ze zijn bijna net zo klein als Alpine en je houdt het libc-gedrag dat je in CI al testte.
# Build op de volledige SDK
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app
# Runtime op een chiseled image: klein en glibc
FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled AS runtime
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENTRYPOINT ["dotnet", "MyApi.dll"]
Deze runtime-laag is een fractie van een klassieke Debian-image en draait als non-root dankzij $APP_UID. Je krijgt de compacte image die je wilde, zonder van libc te wisselen tegenover je testomgeving.
Wil je toch Alpine, test dan op Alpine
Soms wil je echt musl. Een extreem klein image, een specifieke basis die je organisatie voorschrijft, prima. Zorg dan dat je CI-pipeline ook op musl draait, niet alleen op glibc. Dat betekent je testsuite in een Alpine-container uitvoeren, onder belasting.
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine
WORKDIR /src
COPY . .
# Draai je tests op precies de libc waarop je gaat produceren
RUN dotnet test -c Release --logger "console;verbosity=normal"
En laat het niet bij unit tests. Die doen zelden genoeg parallelle allocaties om dit soort gedrag naar boven te halen. Zet er een load- of soaktest naast die je service een tijd onder gelijktijdige druk zet. De ripgrep-crash kwam pas na een minuut hameren. Een test van vijf seconden had hem gemist.
Overweeg een andere allocator voor native werk
Voor pure .NET-workloads regelt de GC de managed heap en heeft dit minder impact. Maar zodra je zwaar leunt op native interop of een native tool meelevert in je image, kun je de libc-allocator vervangen. Op glibc kun je bijvoorbeeld jemalloc of mimalloc voorladen via LD_PRELOAD.
FROM mcr.microsoft.com/dotnet/aspnet:9.0
RUN apt-get update && apt-get install -y libjemalloc2 && rm -rf /var/lib/apt/lists/*
ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
ENTRYPOINT ["dotnet", "MyApi.dll"]
Meet dit wel voordat je het aanzet. Een andere allocator is geen gratis winst, het is een afweging tussen doorvoer, geheugengebruik en fragmentatie die per workload anders uitpakt. Op musl is dit lastiger, want de libc-interne aanroepen blijven hoe dan ook via musl gaan, precies zoals bij ripgrep.
Wat de ripgrep-thread ons echt leert
De bug zelf wordt vast opgelost, in musl of in ripgrep. Dat is niet het punt. Het punt is dat een breed gebruikt, zorgvuldig geschreven stuk software een jaar lang prima kon draaien en dan onder een specifieke combinatie van cores, bestanden en load onderuitging, om een reden die één laag onder de applicatiecode zat.
Jouw .NET-app is niet immuun omdat het managed code is. De runtime, de GC en elke native afhankelijkheid staan bovenop dezelfde libc. Als je die libc verandert van glibc naar musl zonder het te testen, dan verander je gedrag dat je niet ziet tot het misgaat.
Kies je base image dus bewust. Weet welke libc erin zit. Test op wat je draait. En als je alleen een kleiner image wilde, pak dan chiseled en houd glibc.
Veelgestelde vragen over .NET op Alpine en musl
Wat is het verschil tussen linux-x64 en linux-musl-x64 in .NET?
Het zijn twee aparte Runtime Identifiers voor Linux. linux-x64 gaat uit van glibc, de C-bibliotheek op Debian, Ubuntu en de meeste distributies. linux-musl-x64 gaat uit van musl, de lichtere libc die Alpine gebruikt. De runtime linkt tegen andere symbolen en gedraagt zich anders, dus je moet publiceren voor de RID die overeenkomt met je base image.
Is Alpine een slechte keuze voor .NET-containers? Nee, Alpine is prima als je bewust voor musl kiest en je op musl test. Het wordt riskant wanneer je Alpine pakt puur voor de kleine image en al je tests op glibc draaien. Dan stuur je ongetest gedrag naar productie. Wil je enkel een kleiner image, dan geven chiseled images je vergelijkbare grootte met glibc-gedrag.
Waarom raakt een allocator-bug in musl ook mijn managed code? Omdat managed code niet losstaat van libc. Elke P/Invoke, elke native library en veel syscalls lopen via libc-functies die intern geheugen alloceren met de allocator van musl. De GC beheert alleen de managed heap. Zodra je het native pad op gaat, zit je op dezelfde route die in de ripgrep-crash faalde.
Hoe test ik of mijn app onder musl stabiel is?
Draai je testsuite in een Alpine-container met de sdk:alpine-image, niet alleen op je glibc-CI. Voeg daar een load- of soaktest aan toe die de service een langere periode onder gelijktijdige druk zet. Korte unit tests doen zelden genoeg parallelle allocaties om dit soort problemen op te sporen.
Heb ik op Alpine nog steeds globalization-configuratie nodig?
De recente Alpine-images van Microsoft regelen ICU beter dan vroeger, dus vaak werkt het out of the box. Bouw je zelf een base image, dan kun je alsnog tegen ontbrekende ICU aanlopen en moet je icu-libs installeren of de invariant-modus bewust aanzetten. Test culture-afhankelijke code expliciet op je uiteindelijke image.
Verder lezen
- Health Checks en Zero-Downtime Deployments met Docker Compose
- Alles in een binary: wat de overstap van Claude Code naar Bun-in-Rust betekent voor jouw CLI
- Bun is in zes dagen door AI herschreven in Rust. De les gaat niet over Rust.
Conclusie: Behandel je base image als een technische beslissing, niet als een detail. Weet welke libc erin zit, test op wat je draait, en pak chiseled als je alleen een kleiner image wilde.
Bronnen: ripgrep issue #3494 · Hacker News-discussie.
