The ripgrep musl crash: why your .NET container on Alpine can betray you

A segfault in ripgrep on musl exposes a problem that also hits your .NET container on Alpine. On allocators, libc differences, and which base image you should actually pick.

Jean-Pierre Broeders

Freelance .NET Developer

August 3, 20269 min. read
The ripgrep musl crash: why your .NET container on Alpine can betray you

There is an open bug report on ripgrep right now that got me thinking. Not because I write ripgrep source code, but because the cause sits exactly one layer below where most .NET teams ever look: the libc inside their container.

The short version. Someone ran the static musl build of ripgrep across a huge tree, roughly 20 GB spread over 1.8 million files, on a 24-core machine. After about a minute: SIGSEGV. The backtrace points at musl's allocator (mallocng), on a calloc() called from opendir(). An integrity check on the heap metadata fails. On glibc this does not happen. Same code, different libc, different outcome.

That is the kind of bug you never see in development and see exactly once in production, at the worst possible moment.

Why this is a .NET story

.NET has run on musl for years. The runtime has a dedicated RID for it: linux-musl-x64. And Microsoft ships ready-made Alpine images, like mcr.microsoft.com/dotnet/aspnet:9.0-alpine. Alpine is popular because the images are small. A few dozen megabytes instead of a few hundred. Smaller image means faster pulls, less attack surface, quicker cold starts. All real benefits.

The price is just not printed on the label. Alpine uses musl instead of glibc, and those two are not interchangeable. Both implement the C standard library, but the choices underneath differ. Thread stacks, DNS resolution, locale handling, and therefore the allocator too. The ripgrep bug is an allocator story, and that reaches you the moment your container comes under pressure.

One detail matters here: the crash is inside musl's own allocator, called by libc (opendir), not in ripgrep's Rust code. Even if ripgrep brought its own allocator for the Rust side, the calls libc makes internally still go through musl. Translate that to .NET: your managed code runs through the GC, but every time you P/Invoke, load a native library, or hit a syscall through libc, you are on the exact path that failed here.

What musl does differently from glibc

This is not about bashing musl. musl is small, clean, and correctly written. But there are three differences that keep showing up in .NET production.

First, the allocator. With mallocng, musl chose a design that prioritizes low fragmentation and predictability. Under heavy concurrent allocation that approach is slower than glibc and slower than alternatives like jemalloc or mimalloc. The ripgrep thread shows that at extreme concurrency it is not only about speed, but that a crash can surface too. At the time of writing the discussion has not landed on a definitive cause or fix. It is open, and that is precisely why I am writing about it.

Second, the thread stack. The default per-thread stack size on musl is far lower than on glibc. Historically around 128 KB versus several megabytes. .NET usually sets an explicit stack size for its own managed threads, so you do not hit it directly there. But native libraries that count on deep recursion or large stack buffers can topple on musl where they had just enough room on glibc.

Third, globalization. Alpine traditionally does not ship a full ICU. In the past you had to set DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true or install icu-libs separately, or your app would fall over on the first CultureInfo. Newer Alpine images from Microsoft handle this more gracefully, but the moment you assemble your own base image you own this again.

The core: you test on glibc and you run on musl

This is where it goes wrong in practice. You develop on your Mac or on Windows with WSL, where you most likely have glibc under the hood. Your CI runs on ubuntu-latest, also glibc. All your tests are green. Then you build a production image on -alpine, because small is nice, and you ship musl code that has never been tested under musl.

At low load nothing shows. The ripgrep bug needed 24 cores and a million files to crash within a minute. Your version does not have to be a file search. It could be a spike in concurrent requests, a batch job doing thousands of tiny allocations, or a native imaging library that handles the heap a little differently under pressure. The pattern is the same: the difference lives in the layer you do not test.

What I do in practice

I am not against Alpine. I am against Alpine-by-accident. Here is how I make the call.

First, know which RID and which libc you use. You can log that from the app itself at startup, so it lives in your logs and not in your head.

using System.Runtime.InteropServices;

var rid = RuntimeInformation.RuntimeIdentifier;
var desc = RuntimeInformation.OSDescription;
Console.WriteLine($"RID: {rid}");
Console.WriteLine($"OS: {desc}");
// On an Alpine image you will see linux-musl-x64 appear here.
// On Debian or Ubuntu it says linux-x64.

That is one line in your startup log that saves you an afternoon of debugging months later.

Chiseled instead of Alpine

If you chose Alpine purely for the size, there has been a better option since .NET 8: chiseled images. These are stripped-down Ubuntu images, so glibc, without a shell and without a package manager. They are almost as small as Alpine and you keep the libc behavior you were already testing in CI.

# Build on the full 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 on a chiseled image: small and 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"]

This runtime layer is a fraction of a classic Debian image and runs as non-root thanks to $APP_UID. You get the compact image you wanted, without switching libc away from your test environment.

If you do want Alpine, then test on Alpine

Sometimes you genuinely want musl. An extremely small image, a specific base your organization mandates, fine. Then make sure your CI pipeline also runs on musl, not only on glibc. That means executing your test suite inside an Alpine container, under load.

FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine
WORKDIR /src
COPY . .
# Run your tests on exactly the libc you will produce on
RUN dotnet test -c Release --logger "console;verbosity=normal"

And do not stop at unit tests. Those rarely do enough parallel allocation to surface this kind of behavior. Put a load or soak test next to them that keeps your service under concurrent pressure for a while. The ripgrep crash only came after a minute of hammering. A five-second test would have missed it.

Consider a different allocator for native work

For pure .NET workloads the GC manages the managed heap and this matters less. But the moment you lean hard on native interop or ship a native tool inside your image, you can replace the libc allocator. On glibc you can preload jemalloc or mimalloc through LD_PRELOAD, for example.

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"]

Measure this before you turn it on. A different allocator is not free money, it is a trade-off between throughput, memory usage, and fragmentation that plays out differently per workload. On musl it is harder, because the libc-internal calls still go through musl no matter what, exactly as in ripgrep.

What the ripgrep thread actually teaches us

The bug itself will get fixed, in musl or in ripgrep. That is not the point. The point is that a widely used, carefully written piece of software ran fine for a year and then fell over under a specific combination of cores, files, and load, for a reason that sat one layer below the application code.

Your .NET app is not immune because it is managed code. The runtime, the GC, and every native dependency sit on top of that same libc. If you change that libc from glibc to musl without testing it, you change behavior you cannot see until it breaks.

So pick your base image deliberately. Know which libc is in it. Test on what you run. And if all you wanted was a smaller image, take chiseled and keep glibc.

Frequently asked questions about .NET on Alpine and musl

What is the difference between linux-x64 and linux-musl-x64 in .NET? They are two separate Runtime Identifiers for Linux. linux-x64 assumes glibc, the C library on Debian, Ubuntu, and most distributions. linux-musl-x64 assumes musl, the lighter libc that Alpine uses. The runtime links against different symbols and behaves differently, so you must publish for the RID that matches your base image.

Is Alpine a bad choice for .NET containers? No, Alpine is fine if you pick musl deliberately and test on musl. It becomes risky when you grab Alpine purely for the small image and run all your tests on glibc. Then you ship untested behavior to production. If all you want is a smaller image, chiseled images give you comparable size with glibc behavior.

Why does a musl allocator bug also affect my managed code? Because managed code is not separate from libc. Every P/Invoke, every native library, and many syscalls run through libc functions that allocate memory internally with musl's allocator. The GC only manages the managed heap. The moment you step onto the native path, you are on the same route that failed in the ripgrep crash.

How do I test whether my app is stable under musl? Run your test suite inside an Alpine container using the sdk:alpine image, not only on your glibc CI. Add a load or soak test that keeps the service under concurrent pressure for a longer period. Short unit tests rarely do enough parallel allocation to catch this kind of problem.

Do I still need globalization configuration on Alpine? Microsoft's recent Alpine images handle ICU better than before, so it often works out of the box. If you build your own base image, you can still hit missing ICU and need to install icu-libs or deliberately enable invariant mode. Test culture-dependent code explicitly on your final image.

Further reading


Conclusion: Treat your base image as a technical decision, not a detail. Know which libc is in it, test on what you run, and take chiseled if all you wanted was a smaller image.

Sources: ripgrep issue #3494 · Hacker News discussion.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch