$RodHat_
MOTD

Your Alpine container runs musl, not glibc. Most of you have no idea what that means.

Published by

Your Alpine container runs musl, not glibc. Most of you have no idea what that means.
Photo: AI-generated — no human photographer / RodHat AI Cover

Alpine Linux is the most-pulled Docker base image by volume. Not Ubuntu, not Debian. Alpine. A 7 MB image that boots faster than some programs take to link. The appeal is obvious: it’s small, it’s fast, it has a package manager, the containers are tiny. I have nothing against Alpine. I use Alpine.

What I have something against is the number of engineers who pull FROM alpine:3.21, copy a pre-built binary into it, wonder why it crashes at startup, and file a ticket that gets resolved three hours later when someone who has been around long enough points at the ldd output and says “that’s because Alpine runs musl, not glibc.”

This happens every week. Let me explain why.

The dynamic linker is not a commodity

When a Linux executable starts, before your main() runs — before main() even exists as a concept in the running process — the kernel hands control to the dynamic linker. The linker’s job is to find every shared library the binary declared as a dependency, map them into the process’s address space, resolve symbol references, and then finally call the C runtime initialization code that sets up enough infrastructure for main() to run.

The dynamic linker is not part of the kernel. It’s part of libc. On a glibc system, the interpreter listed in your ELF binary is /lib64/ld-linux-x86-64.so.2. On a musl system, it’s /lib/ld-musl-x86-64.so.1. These are different programs. They are not compatible. A binary that hardcodes /lib64/ld-linux-x86-64.so.2 as its interpreter will not run on musl because that path doesn’t exist.

$ readelf -l /usr/bin/curl | grep interpreter
      [Requesting program interpreter: /lib/ld-musl-x86-64.so.1]

That line is burned into the ELF at link time. It is not a runtime configuration. When you copy a curl binary built on Ubuntu into your Alpine container, the kernel reads /lib64/ld-linux-x86-64.so.2, finds nothing, and returns ENOENT. The error message you get — if you get any — is “No such file or directory,” which is true and completely useless as a diagnostic if you don’t already know what you’re looking for.

musl and glibc are not two implementations of the same spec

The correct mental model is: musl and glibc are both C standard libraries. They both implement POSIX. They diverge on everything else, and “everything else” turns out to be a lot of what production software actually uses.

glibc extensions that musl doesn’t implement:

printf("%m") is a glibc-specific format specifier that prints strerror(errno). It’s documented nowhere in the C standard. It works on every Linux system most developers have ever touched, because they’ve always been touching glibc systems. On musl, %m prints literally %m. Your error messages silently stop including error descriptions.

error() and error_at_line() — GNU libc extensions used heavily in GNU utilities for structured error output. Not in musl. If you’re building something from source on Alpine that uses these, you get a link error. If you’re copying a prebuilt binary, you get a missing symbol at load time.

getline() and getdelim() — actually POSIX now (since 2008), so musl has them. But for years these were glibc extensions, and a lot of older codebases still mark them as feature-test-macro-gated. POSIX ports of those macros expose them fine; musl’s behavior here is actually correct.

GNU IFUNC — the silent performance regression:

This is the one that actually hurt me. GNU Indirect Functions (__attribute__((ifunc(...)))) are a glibc/binutils mechanism that lets a function resolve to different implementations at load time based on CPU feature detection. glibc uses this heavily for memcpy, memset, strlen, and the cryptographic routines in libgcrypt, OpenSSL’s libcrypto, and similar. On a glibc system, if your CPU supports AVX-512, the linker silently resolves memcpy to the AVX-512-optimized version. You never see it; you just get the fast one.

musl doesn’t support GNU IFUNC. At all. If you link OpenSSL against musl and OpenSSL uses IFUNC for its AES-NI or SHA-NI implementations — and it does — musl either refuses to load the library or falls back to a generic C implementation that runs three to five times slower. Your containerized TLS service benchmarks great in dev (where someone ran a glibc-based image) and mysteriously underperforms in production (Alpine). No error messages. No warnings. Just slower, quieter, invisibly slower.

Thread-local storage — variant 1 vs. variant 2:

glibc on x86-64 uses the TLS variant-2 model, where the thread pointer (%fs) points to a negative offset from the TCB and TLS variables are at negative offsets. musl uses variant 1 for most architectures (positive offsets, different layout). For x86-64 specifically, musl also supports variant 2 for compatibility, but there are edge cases — particularly with dlopen() loading a shared object that uses TLS — where musl’s stricter conformance breaks code that was relying on glibc’s more lenient behavior in ordering initialization.

The practical symptom: dlopen() returns NULL, dlerror() says something about TLS, and whoever shipped the closed-source library you were trying to load doesn’t support Alpine and won’t be helping you.

How to check before it bites you

# On a running container: what libc is this system using?
$ ldd $(which sh)
        /lib/ld-musl-x86-64.so.1 (0x7f8b6a000000)

# On a binary you're about to copy in: what linker does it want?
$ readelf -l ./my-binary | grep interpreter
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

If the binary wants /lib64/ld-linux-x86-64.so.2 and you’re on Alpine, it won’t run. Full stop. Your options are: build from source on Alpine using musl (preferred), use gcompat — a glibc compatibility shim that Alpine ships — (usually works, never perfect), or use a different base image.

The Alpine community’s answer to “I need a small image but my binary requires glibc” is usually distroless/base or Wolfi OS, which use glibc but strip everything else. That’s a real option. Pick it consciously rather than discovering it post-incident.

The busybox thing, briefly

musl is the headline incompatibility, but Alpine’s default userspace is also BusyBox — a single binary that impersonates ls, cat, grep, awk, sed, tar, wget, and a hundred others. BusyBox’s implementations are not GNU. The flags don’t always match. sed -i behaves differently in edge cases. grep doesn’t have all the GNU extensions. If your entrypoint script was written and tested against bash and GNU coreutils on Ubuntu, it will probably work on Alpine BusyBox, and it will fail in a subtle way on the one input you didn’t test with.

ash is not bash. Writing #!/bin/sh and testing on Alpine will catch this; writing #!/bin/bash and running on Alpine will fail at the shebang. Both are correctible; neither is a reason to avoid Alpine; both are reasons to understand what you’re running before you ship it.

The larger point

Alpine is a good base image. musl is a good C library — cleaner code than glibc, better security defaults (no FORTIFY_SOURCE theater, actual bounds-checking behavior), small enough to read. The combination has real engineering merit.

The problem isn’t Alpine. The problem is the container ecosystem’s culture of “grab the smallest image, copy in the binary, ship it” without any particular interest in what that binary was built against. The same pattern shows up in package registry typosquatting — the trust model assumes familiarity that doesn’t exist.

ldd, readelf -d, readelf -l. Three commands. They tell you everything you need to know about what a binary expects from the world before you hand it one. Running them takes thirty seconds. Not running them costs you a three-hour incident debrief.


If you want to understand what the ELF format actually encodes, what the program interpreter entry means, how the dynamic linker resolves symbols at load time, and why TLS layout is an ABI decision rather than a runtime one — The Linux Programming Interface covers the ELF format and dynamic linking in Chapter 41, and the process of loading an executable from execve(2) onward in Chapter 6. For the broader question of what POSIX actually specifies and where glibc and musl diverge in their interpretations, Advanced Programming in the UNIX Environment is where you go to read the spec as written versus how vendors actually shipped it. If you want to avoid the whole problem by running containers that have actual process isolation instead of a shared kernel namespace, the jails post is still the most honest comparison of what “container isolation” actually costs you.