musl vs glibc: why an Alpine binary will not run on Ubuntu
$ ./app
bash: ./app: No such file or directory
$ ls -l ./app
-rwxr-xr-x 1 root root 1843200 Oct 25 12:04 ./app
The file is there. The message is about something else entirely: the interpreter recorded in the
binary’s PT_INTERP segment does not exist on this host. The kernel reports ENOENT for the
interpreter but names the binary in the message, which is why this single error has wasted more
engineer-hours than any other in the container era.
The interpreter is the C library’s dynamic loader, and its path is baked in at link time:
$ readelf -l ./app | grep interpreter
[Requesting program interpreter: /lib/ld-musl-x86_64.so.1]
ld-musl-* means musl — the library Alpine uses. Ubuntu has no such file, because it ships
/lib64/ld-linux-x86-64.so.2 (glibc).
The two families
| glibc | musl | |
|---|---|---|
| Loader path | /lib64/ld-linux-x86-64.so.2 | /lib/ld-musl-x86_64.so.1 |
| Default on | Debian, Ubuntu, RHEL, Fedora, SUSE | Alpine, postmarketOS, many minimal images |
| Size | large, feature-complete | small, strictly standards-focused |
| DNS | full resolv.conf + NSS | parser for common resolv.conf forms |
iconv | built in | external (libiconv package) |
malloc | tuned for allocation-heavy work | simpler, can be slower under heavy malloc churn |
| Symbol versioning | yes (GLIBC_2.34 etc.) | no |
Each family has its own idea of what an ABI is. A binary linked against one cannot use the other’s libraries, even for the same architecture and the same word size. This is not a bug or a packaging oversight — they are different implementations that happen to define the same function names.
Why the container world made this common
Multi-stage Docker builds encourage this mistake. You compile in a fat image and copy the result into a small one:
FROM golang:1.24 AS build
RUN CGO_ENABLED=1 go build -o /app ./cmd/app
FROM alpine:3.20
COPY --from=build /app /app
CMD ["/app"]
CGO_ENABLED=1 on a Debian-based builder produces a glibc-linked binary. Copying it into Alpine
gives you No such file or directory at start. The build succeeds, the image builds, the container
exits immediately, and the log contains the least helpful message in the Linux toolbox.
The mirror image happens too: an Alpine-built binary into an Ubuntu “distroless-ish” image.
Telling them apart
$ file ./app
./app: ELF 64-bit LSB pie executable, x86-64, ..., dynamically linked,
interpreter /lib/ld-musl-x86_64.so.1, stripped
file prints the interpreter, so it answers the question directly. If it is truncated or unavailable:
$ readelf -l ./app | grep -i interp
[Requesting program interpreter: /lib/ld-musl-x86_64.so.1]
$ strings ./app | grep -E 'musl|GLIBC_2' | head -3
musl libc (x86_64)
For a shared object there is no interpreter, so the only signal is the dependency list:
$ readelf -d ./libfoo.so | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libc.musl-x86_64.so.1]
libc.musl-* means musl. libc.so.6 means glibc. The
Dependency Doctor shows both and labels the family for you.
The fixes, best first
1. Build against the libc you ship. The durable answer. Use golang:alpine for an Alpine target,
or copy the binary and its loader from a matching base.
2. Link statically with musl. CGO_ENABLED=0 go build produces a static binary with no libc
dependency at all, which sidesteps the question. For C/C++, musl-gcc -static.
3. Do not try to install glibc on Alpine. gcompat and libc6-compat exist and work for some
binaries, but you are now running a hybrid system whose behaviour depends on which code path touched
which library. Fine as a stopgap, poor as a design.
4. Never copy a loader between distributions. cp /lib64/ld-linux-x86-64.so.2 into Alpine
produces a binary that starts and then resolves glibc symbols against musl’s libc, failing somewhere
unrelated. This is the container equivalent of the libc-copy mistake described in
GLIBC version not found.
Which should you choose
musl is the better default for container images: smaller attack surface, faster start, no NSS
configuration to get wrong. glibc is the better default for anything that needs iconv, complex
resolver behaviour, nsswitch plugins (LDAP, SSSD), or glibc-specific APIs — and for distributing
binaries to arbitrary Linux hosts, where glibc compatibility is the path of least surprise among
distribution packages.
Related: static vs dynamic linking explains why the static case is not always the escape hatch it appears to be.
Every command in this guide was run against the distribution named in the output. If your output differs, the mechanism section explains why — and the ELF Inspector shows the same fields without needing a shell.