Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Change base without breaking the application

By updated

Improve this page

Logo Docker

Changing the FROM line of a Dockerfile replaces the libc of the image, the C library that almost every program calls to read a file, open a connection or reserve memory. Debian and Ubuntu use glibc, Alpine uses musl, and a program compiled for one may no longer start with the other.

This lesson is for people who already know how to choose a base and now want to predict what a change breaks, before publishing. You will learn to read what a binary asks for, to fix a glibc binary that no longer starts on Alpine, to understand why pip does not install the same files from one base to another, and to recognize the error caused by an architecture difference. All measurements come from Docker 29.9.2 on amd64.

Several names in this lesson are French because the commands and outputs are the ones that were run: cumul means "total" and pluies "rainfall measurements" in the C program, and ~/changer-socle is the "change base" work folder. They are identifiers, so they stay as they are.

What you will learn

  • Read the ELF interpreter of a binary and check that it exists in the new base
  • Fix a glibc binary that no longer starts on Alpine, in three measured ways
  • Explain why pip picks a manylinux wheel on Debian and a musllinux wheel on Alpine
  • Spot a Python package with no wheel for the new base, and a silently degraded installation
  • Diagnose an architecture difference, whose error is not always the one you expect
  • Test the application in the new base before publishing

Prerequisites

What does a FROM change do to a compiled binary?

A Linux executable is a file in ELF format. When it is dynamic, it does not contain the libc: its header designates a small program, its interpreter (or loader), which loads the libraries at startup. This file must exist in the image, otherwise the binary does not start.

glibc and musl do not place it at the same location. The kernel then answers no such file or directory on a binary that is nevertheless present: the execve(2) system call returns the ENOENT error also when the interpreter is the missing file, and the message names the requested file, not the interpreter. The lesson Choose and maintain a Docker base image shows this case in detail. Here, you take it up again to learn to predict the failure, then to fix it.

Take the same C program, cumul.c, which adds up four rainfall measurements. Work in a dedicated folder, which the cleanup section removes.

Terminal window
mkdir -p ~/changer-socle/glibc ~/changer-socle/musl ~/changer-socle/static ~/changer-socle/py && cd ~/changer-socle
cumul.c
#include <stdio.h>
int main(void) {
int pluies[] = {4, 9, 3, 17};
int cumul = 0;
for (int i = 0; i < 4; i++) {
cumul += pluies[i];
}
printf("cumul=%d\n", cumul);
return 0;
}

Save this code as cumul.c in ~/changer-socle. You compile it in a glibc image, gcc:14, which weighs about 2.14 GB: it only serves to produce the binary. The file command displays its interpreter, ldd its libraries.

Terminal window
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/src -w /src \
gcc:14@sha256:435c3cbc2ad1c25fbc1684c367808ff0ca2035a1bed56734a40165ab5cd7e3bd \
sh -c 'gcc -O2 -o glibc/cumul cumul.c && file glibc/cumul && ldd glibc/cumul'
glibc/cumul: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped
linux-vdso.so.1 (0x00007f8451359000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8451157000)
/lib64/ld-linux-x86-64.so.2 (0x00007f845135b000)

Look at the interpreter /lib64/ld-linux-x86-64.so.2 line: it is the file that the kernel must find to start this binary. The addresses in parentheses change on every run. Now run this binary in each of the two bases to see the consequence.

Terminal window
docker run --rm -v "$PWD":/src:ro \
debian:13-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 /src/glibc/cumul
docker run --rm -v "$PWD":/src:ro \
alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a /src/glibc/cumul

On Debian, the binary runs and displays cumul=33; on Alpine, it fails with exec /src/glibc/cumul: no such file or directory, with the code 255 returned by docker run (code 127 only appears if a shell launches the program).

How do you know, before changing, whether the binary will run?

You know it by reading the interpreter of the binary in the image that built it, then checking that this path exists in the new base. Neither file nor readelf is installed in alpine or debian:13-slim (sh: file: not found), hence the reading in the build image. The path read above is then searched for in each candidate.

Terminal window
docker run --rm alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a \
sh -c 'ls -l /lib/ld-* /lib64 2>&1'
docker run --rm debian:13-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 \
sh -c 'ls -l /lib64/ld-* /lib/x86_64-linux-gnu/ld-* 2>&1'
ls: /lib64: No such file or directory
-rwxr-xr-x 1 root root 670312 Oct 21 15:50 /lib/ld-musl-x86_64.so.1
-rwxr-xr-x 1 root root 225672 Jun 29 21:37 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
lrwxrwxrwx 1 root root 44 Jun 29 21:37 /lib64/ld-linux-x86-64.so.2 -> ../lib/x86_64-linux-gnu/ld-linux-x86-64.so.2

Alpine has no /lib64 and its loader is called /lib/ld-musl-x86_64.so.1; Debian provides /lib64/ld-linux-x86-64.so.2. The path the binary asks for therefore exists on Debian and not on Alpine: the reading predicted the failure without running anything.

Why not rely on ldd in Alpine?

Because the ldd of Alpine answers without error on a glibc binary that cannot start. It returns code 0 and displays an answer that looks valid.

Terminal window
docker run --rm -v "$PWD":/src:ro \
alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a ldd /src/glibc/cumul
/lib64/ld-linux-x86-64.so.2 (0x7f1d81374000)
libc.so.6 => /lib64/ld-linux-x86-64.so.2 (0x7f1d81374000)

Look at the second line: libc.so.6 is attached to the interpreter itself, which proves nothing. The musl ldd reports no incompatibility, so do not use it as a test; run the binary, or read its interpreter in a glibc image as above.

How do you identify the libc of a base?

A few commands tell which libc a base has. They serve to confirm, not to decide: the referee remains the smoke test, which consists in running the application in the candidate image (it is detailed at the end of this lesson). The /etc/os-release file names the distribution (ID=alpine and VERSION_ID=3.24.3 on Alpine), not the libc. Two commands stand out from the others because their failure is itself information.

Terminal window
docker run --rm alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a getconf GNU_LIBC_VERSION
docker run --rm alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a ldd --version
docker run --rm debian:13-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 getconf GNU_LIBC_VERSION
getconf: GNU_LIBC_VERSION: unknown variable
musl libc (x86_64)
Version 1.2.6
Dynamic Program Loader
Usage: /lib/ld-musl-x86_64.so.1 [options] [--] pathname
glibc 2.41

getconf GNU_LIBC_VERSION answers glibc 2.41 on Debian and unknown variable (code 1) on Alpine: the failure is the signal "this is not glibc". ldd --version also names the libc of Alpine, but returns code 1 because musl treats the option as a usage error and writes the version on the error output. A script that tests the exit code of ldd --version therefore wrongly concludes, on Alpine, that the command failed, whereas musl answers and names its version.

What can you do when the glibc binary does not run on Alpine?

Three fixes exist: rebuild in the new base, link the binary statically, or add a compatibility layer. The first two require the sources of the program, the third is for when you do not have them. They do not cost the same.

Rebuild against musl in Alpine

Compile in an Alpine image with the build-base package. The container runs as root to install the package; the command then gives the file back to your account with chown, evaluated by your shell before launch.

Terminal window
docker run --rm -v "$PWD":/src -w /src \
alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a \
sh -c "apk add --no-cache build-base 2>&1 | tail -2 && gcc -O2 -o musl/cumul cumul.c && ldd musl/cumul && chown $(id -u):$(id -g) musl/cumul"
Executing busybox-1.37.0-r31.trigger
OK: 275.5 MiB in 38 packages
/lib/ld-musl-x86_64.so.1 (0x7fc37aa29000)
libc.musl-x86_64.so.1 => /lib/ld-musl-x86_64.so.1 (0x7fc37aa29000)

The musl binary is 18,384 bytes. To get it, the container installed 38 packages (275.5 MiB) in about 7 s, not counting the image download: a compiler has no place in the final image (see Optimize the size of images, in French, for the multi-stage build). Now run this binary in both bases: you are going to see a symmetry.

Terminal window
docker run --rm -v "$PWD":/src:ro \
alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a /src/musl/cumul
docker run --rm -v "$PWD":/src:ro \
debian:13-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 /src/musl/cumul

Under Alpine, it displays cumul=33. Under debian:13-slim, it fails exactly like the glibc binary under Alpine, with exec /src/musl/cumul: no such file or directory (code 255). Changing base, in either direction, forces a rebuild.

A static link copies the libraries into the binary, which no longer asks for any interpreter. You get it with the -static option of gcc, in the glibc image.

Terminal window
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/src -w /src \
gcc:14@sha256:435c3cbc2ad1c25fbc1684c367808ff0ca2035a1bed56734a40165ab5cd7e3bd \
sh -c 'gcc -O2 -static -o static/cumul cumul.c && file static/cumul && ldd static/cumul'
static/cumul: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, with debug_info, not stripped
not a dynamic executable

ldd answers not a dynamic executable with code 1: here this code is expected. Run the binary in both bases.

Terminal window
docker run --rm -v "$PWD":/src:ro \
alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a /src/static/cumul
docker run --rm -v "$PWD":/src:ro \
debian:13-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 /src/static/cumul

This binary displays cumul=33 under Alpine and under Debian. Its cost is size: 934,520 bytes against 15,688 for the dynamic version, about 60 times more for this tiny program, because it carries the libc with it.

With glibc, the static link has a limit. Functions that resolve names, such as getaddrinfo, still load glibc libraries at run time, and the linker warns that the binary then requires those of the version used at compile time. A program that resolves names or users must therefore be tested on each base, or linked against musl.

Add gcompat when you cannot rebuild

gcompat is an Alpine package that provides a replacement loader bearing the name of the glibc interpreter, plus functions compatible with the glibc interface: the kernel finds the requested file, and the binary runs on musl. It fits when the binary comes from a third party that you do not rebuild.

Terminal window
docker run --rm -v "$PWD":/src:ro \
alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a \
sh -c 'apk add --no-cache gcompat 2>&1 | tail -1; /src/glibc/cumul'
OK: 8336 KiB in 19 packages
cumul=33

The glibc binary runs. The OK: line of apk gives the total state of the container (19 packages, 8336 KiB), not the weight of gcompat alone: the lesson Choose and maintain a Docker base image measures its addition at 225 kB in an image layer.

This measurement, however, covers only a trivial program, which calls almost nothing from the libc: it does not guarantee that a real program runs, and a real program is checked by its smoke test. The table compares the three fixes on this program.

FixRuns under AlpineRuns under DebianBinary sizeWhen to choose it
Rebuild in Alpineyesno18,384 bytescode you control, shipped on Alpine only
Static linkyesyes934,520 bytesone binary for several bases, no name resolution
gcompatyes, trivial caseyes (binary unchanged)15,688 bytes, plus the gcompat packagethird-party binary that cannot be rebuilt

Why does pip not install the same files on both bases?

Because pip, the Python package installer, picks a wheel according to the libc of the image. A wheel is the precompiled archive of a Python package; its name carries a platform tag: manylinux for glibc systems, musllinux for musl systems. The measurements in this section and the following ones use python:3.14-slim (Debian, glibc 2.41) and python:3.14-alpine (musl), with Python 3.14.10 and pip 26.2.2, on amd64.

pip debug --verbose lists the tags that the interpreter accepts. Look at the prefix: it is what decides.

Terminal window
docker run --rm python:3.14-slim@sha256:930e1abba7c4e8f96ed93e8012c35d174d7732752731bbd5e8e757d7ec6f3966 \
sh -c 'pip debug --verbose 2>/dev/null | grep -E "cp314-cp314-(many|musl)" | head -4'
docker run --rm python:3.14-alpine@sha256:eb2c62658b8d51bff0acc408504b76521d02030de56a8a3c1e830083e89b89d2 \
sh -c 'pip debug --verbose 2>/dev/null | grep -E "cp314-cp314-(many|musl)" | head -4'
cp314-cp314-manylinux_2_41_x86_64
cp314-cp314-manylinux_2_40_x86_64
cp314-cp314-manylinux_2_39_x86_64
cp314-cp314-manylinux_2_38_x86_64
cp314-cp314-musllinux_1_2_x86_64
cp314-cp314-musllinux_1_1_x86_64
cp314-cp314-musllinux_1_0_x86_64

The first four lines come from Debian, the last three from Alpine. The Debian base accepts manylinux wheels, and the Alpine base accepts no manylinux wheel, only musllinux. For current tags, the number that follows the prefix is the minimum libc version required (the old names manylinux2010 and manylinux2014 are an exception, see the table below): a manylinux_2_28 wheel suits glibc 2.28 and later, a musllinux_1_2 wheel musl 1.2 and later. The same pip install markupsafe therefore downloads two different files, which the loaded file confirms.

Terminal window
docker run --rm python:3.14-slim@sha256:930e1abba7c4e8f96ed93e8012c35d174d7732752731bbd5e8e757d7ec6f3966 \
sh -c 'pip install --no-cache-dir --root-user-action=ignore markupsafe 2>&1 | grep -v notice; python -c "import markupsafe._speedups as s;print(s.__file__)"'
docker run --rm python:3.14-alpine@sha256:eb2c62658b8d51bff0acc408504b76521d02030de56a8a3c1e830083e89b89d2 \
sh -c 'pip install --no-cache-dir --root-user-action=ignore markupsafe 2>&1 | grep -v notice; python -c "import markupsafe._speedups as s;print(s.__file__)"'
Downloading markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (23 kB)
/usr/local/lib/python3.14/site-packages/markupsafe/_speedups.cpython-314-x86_64-linux-gnu.so
Downloading markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl (23 kB)
/usr/local/lib/python3.14/site-packages/markupsafe/_speedups.cpython-314-x86_64-linux-musl.so

The outputs are shortened to the Downloading lines and the last line. The native extension (a module written in C, which Python loads like a library), here _speedups, carries the linux-gnu suffix on Debian and linux-musl on Alpine. On this example, the installation takes 1.8 s on Debian and 2.2 s on Alpine, with images already pulled: the idea that Alpine forces you to compile is outdated for this package.

Which common packages have a musllinux wheel?

The eight packages tested all have one, as of November 3, 2026. The following command downloads the wheel of each without installing it; --only-binary :all: forbids compilation, so a package with no wheel would fail.

Terminal window
docker run --rm python:3.14-alpine@sha256:eb2c62658b8d51bff0acc408504b76521d02030de56a8a3c1e830083e89b89d2 \
sh -c 'cd /tmp; for p in markupsafe psutil orjson pyyaml cryptography pillow grpcio numpy; do printf "%s: " $p; pip download --no-deps --only-binary :all: -d /tmp/d_$p -q $p 2>&1 | tail -1; ls /tmp/d_$p 2>/dev/null; echo; done'

Under each package name, the command displays the downloaded wheel file, preceded by a [notice] line from pip to ignore. The table gives the result, with the first platform tag of the file name on Debian (the same command on python:3.14-slim) and on Alpine.

The names manylinux2010 and manylinux2014 are old tag names, kept for wheels already published: manylinux2010 is equivalent to manylinux_2_12 and manylinux2014 to manylinux_2_17, hence glibc 2.12 and 2.17 at minimum (PEP 571, PEP 599, and PEP 600 for the manylinux_x_y form).

PackageWheel on DebianWheel on Alpine
markupsafemanylinux2014_x86_64musllinux_1_2_x86_64
psutilmanylinux2010_x86_64musllinux_1_2_x86_64
orjsonmanylinux_2_17_x86_64musllinux_1_2_x86_64
pyyamlmanylinux2014_x86_64musllinux_1_2_x86_64
cryptographymanylinux_2_34_x86_64musllinux_1_2_x86_64
pillowmanylinux_2_27_x86_64musllinux_1_2_x86_64
grpciomanylinux2014_x86_64musllinux_1_2_x86_64
numpymanylinux_2_27_x86_64musllinux_1_2_x86_64

These eight packages cost no compilation on Alpine. The risk is elsewhere, with a package whose publisher does not publish a musllinux wheel.

What happens when a Python package has no wheel for the new base?

The installation fails with a message that does not mention the libc, or worse, it succeeds while delivering a truncated package. onnxruntime illustrates the first case: as of November 3, 2026, its wheel for amd64 and Python 3.14 is published as manylinux_2_28 only.

Terminal window
docker run --rm python:3.14-slim@sha256:930e1abba7c4e8f96ed93e8012c35d174d7732752731bbd5e8e757d7ec6f3966 \
sh -c 'pip install --dry-run --no-deps --only-binary :all: --root-user-action=ignore onnxruntime 2>&1 | grep -v notice | tail -8'
docker run --rm python:3.14-alpine@sha256:eb2c62658b8d51bff0acc408504b76521d02030de56a8a3c1e830083e89b89d2 \
sh -c 'pip install --dry-run --no-deps --only-binary :all: --root-user-action=ignore onnxruntime 2>&1 | grep -v notice | tail -8'
Collecting onnxruntime
Downloading onnxruntime-1.31.1-cp314-cp314-manylinux_2_28_x86_64.whl.metadata (5.8 kB)
Would install onnxruntime-1.31.1
ERROR: Could not find a version that satisfies the requirement onnxruntime (from versions: none)
ERROR: No matching distribution found for onnxruntime

Debian finds the wheel; Alpine answers from versions: none, as if the package did not exist. The message mentions neither musl nor a wheel: without knowing the mechanism, you would look for a typo or a network problem. After a FROM change, a No matching distribution found on a package that used to install should therefore make you think of a missing wheel for this libc. A Python version or an architecture with no wheel gives the same message.

What happens if pip compiles instead?

With no wheel, pip can compile the package from its sources, which requires a C compiler. Neither of the two Python images has one (which gcc cc returns code 1 on both). Forcing the compilation with --no-binary shows it: psutil fails on both bases.

Terminal window
docker run --rm python:3.14-alpine@sha256:eb2c62658b8d51bff0acc408504b76521d02030de56a8a3c1e830083e89b89d2 \
sh -c 'pip install --no-cache-dir --root-user-action=ignore --no-binary psutil psutil 2>&1 | grep -v notice | tail -12'
sudo apk add gcc python3-dev musl-dev linux-headers
error: [Errno 2] No such file or directory: 'gcc'
[end of output]
note: This error originates from a subprocess, and is likely not a problem with pip.
ERROR: Failed building wheel for psutil
Failed to build psutil
error: failed-wheel-build-for-install
× Failed to build installable wheels for some pyproject.toml based projects
╰─> psutil

The output suggests installing gcc and the development headers (the apk command above; on Debian, sudo apt-get install gcc python3-dev), then gives the error No such file or directory: 'gcc'. That error is a clear one. In a container, sudo does not exist, and on the official python: images measured here, installing the compiler (build-base on Alpine, build-essential on Debian) was enough to compile.

The trap is a package that knows how to do without its C extension: with --no-binary, markupsafe installs without gcc, because the installation falls back to a py3-none-any wheel, the pure Python version.

Terminal window
docker run --rm python:3.14-alpine@sha256:eb2c62658b8d51bff0acc408504b76521d02030de56a8a3c1e830083e89b89d2 \
sh -c 'pip install -q --no-cache-dir --root-user-action=ignore --no-binary markupsafe markupsafe 2>&1 | grep -v notice; python -c "import markupsafe._speedups" 2>&1 | tail -1'
ModuleNotFoundError: No module named 'markupsafe._speedups'

The installation is green, the creation of the markupsafe-3.0.4-py3-none-any.whl wheel too, and the _speedups extension is absent; the result is identical on Debian. Without the -q option, pip displays the line Created wheel for markupsafe: filename=markupsafe-3.0.4-py3-none-any.whl. The application runs without its C extension, with nothing to signal it: you have to import the extension to notice.

With a compiler, the compilation does produce the native extension, but it costs time. The two commands install the compiler in the container then compile markupsafe; the last line displayed is the path of the extension, this time present.

Terminal window
docker run --rm python:3.14-alpine@sha256:eb2c62658b8d51bff0acc408504b76521d02030de56a8a3c1e830083e89b89d2 \
sh -c 'apk add --no-cache build-base >/dev/null 2>&1; pip install -q --no-cache-dir --root-user-action=ignore --no-binary markupsafe markupsafe 2>&1 | grep -v notice; python -c "import markupsafe._speedups as s;print(s.__file__)"'
docker run --rm python:3.14-slim@sha256:930e1abba7c4e8f96ed93e8012c35d174d7732752731bbd5e8e757d7ec6f3966 \
sh -c 'apt-get update -qq >/dev/null 2>&1; apt-get install -y -qq build-essential >/dev/null 2>&1; pip install -q --no-cache-dir --root-user-action=ignore --no-binary markupsafe markupsafe 2>&1 | grep -v notice; python -c "import markupsafe._speedups as s;print(s.__file__)"'
/usr/local/lib/python3.14/site-packages/markupsafe/_speedups.cpython-314-x86_64-linux-musl.so
/usr/local/lib/python3.14/site-packages/markupsafe/_speedups.cpython-314-x86_64-linux-gnu.so

Each command takes about 11 s on Alpine and 24 s on Debian, against 2 s for the wheel. These durations depend on the network, only the order of magnitude matters. Two safeguards avoid the silence: refuse implicit compilation with --only-binary :all:, which turns the silence into an error, and import, in the Dockerfile or the smoke test, the native extension the application depends on.

When an essential package truly has no musllinux wheel, two options remain: install a compiler and the headers in the build stage of the image, or keep a glibc base such as python:3.14-slim. The first costs build time, the second a bigger image.

What if the architecture changes with the base?

When the platform changes too (for example from amd64, 64-bit x86 processors, to arm64, ARM processors), the error is not necessarily exec format error. The lesson Choose and maintain a Docker base image shows that exec format error appears when the program itself is of another architecture. The case here is different: an amd64 glibc binary run in an arm64 image, on an amd64 host with no emulation (no tool translates the instructions from one architecture to the other).

Terminal window
docker run --rm --platform linux/arm64 -v "$PWD":/src:ro \
alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a /src/glibc/cumul
docker run --rm --platform linux/arm64 -v "$PWD":/src:ro \
debian:13-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 /src/glibc/cumul
exec /src/glibc/cumul: no such file or directory
exec /src/glibc/cumul: no such file or directory

Both images answer no such file or directory with code 255, not exec format error. The explanation: the kernel, which is that of the amd64 host, runs the x86 binary without looking at the image label; what is missing is its loader /lib64/ld-linux-x86-64.so.2, absent from the arm64 image. The message is therefore the same as for a libc change, and it is diagnosed the same way: read the interpreter, check that it exists.

On an arm64 host, the same amd64 binary fails differently: the kernel cannot run x86 instructions, and the answer is exec format error, the mechanism described in Choose and maintain a Docker base image. A first run with --platform also pulls the arm64 content of the image, which adds up on disk (docker image ls goes from 13 MB to 26.6 MB for Alpine).

To check that an arm64 base does contain the expected loader, docker image inspect --format '{{.Architecture}}' is useless: it returns amd64 even after a docker run --platform linux/arm64, and only docker image inspect --platform linux/arm64 returns arm64, with another image identifier. An ls in the arm64 image is also impossible without emulation (docker run --platform linux/arm64 … ls returns exec /bin/ls: exec format error, code 255).

The method therefore consists in reading the interpreter with file in the build image, then listing the content of the platform's image without running it: docker create prepares a container that does not start, and docker export writes its file system as an archive that tar -t enumerates.

Terminal window
c=$(docker create --platform linux/arm64 alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a /bin/true)
docker export "$c" | tar -t | grep ld-
docker rm "$c"
lib/ld-musl-aarch64.so.1

The docker rm command prints the identifier of the removed container again, which the output above omits. Alpine arm64 provides lib/ld-musl-aarch64.so.1 and no ld-linux-x86-64.so.2, so the amd64 glibc binary does not find its loader there. With Debian, the same command on debian:13-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 lists usr/lib/ld-linux-aarch64.so.1 and usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1, and there either, no ld-linux-x86-64.so.2.

To know all the platforms that an image publishes, query its manifest.

Terminal window
docker buildx imagetools inspect alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a

For Alpine, the list contains amd64, arm/v6, arm/v7, arm64/v8, 386, ppc64le, riscv64 and s390x. To change architecture, rebuild for the target platform; a binary that is already built cannot be converted.

On an amd64 host with no emulation, an arm64 image does not run (see Choose and maintain a Docker base image). To test it anyway, three ways exist: QEMU emulation, which binfmt_misc registers with the kernel, cross-compilation (compiling on the host for the target architecture), or a machine of the target architecture. The measurement machine has no emulation: /proc/sys/fs/binfmt_misc/ only contains python3.13, register and status there, with no entry for arm64.

In which order should you check before changing the FROM?

In the order of the diagram below: the binary, then the Python packages, then the platform, and finally a smoke test in the new base. Each question on the left matches one of the three previous sections, and each "no" or "yes" that comes out on the right, in red, is a fix to make before publishing.

Decision tree before a FROM change, in three successive questions: does the interpreter of a dynamic binary exist in the new base (otherwise rebuild, link statically or gcompat), does a wheel exist for the libc of the new base (otherwise install a compiler or keep a glibc base), does the platform change (then rebuild); all paths end with a smoke test of the application in the new base.You want to changethe FROM lineDynamic binary:interpreter present?Python packages:wheel for this libc?yes or n/aRebuild in the base,link statically or gcompatnoPlatform changedtoo?yes or n/aInstall a compileror keep a glibc basenoRebuild forthe new platformyesSmoke test: run the applicationin the new baseno
The fixes in red are made before publishing; the smoke test ends all paths. Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The smoke test is the referee. None of the previous readings (interpreter, getconf, wheel tags) is a proof: they predict. The test runs the real application, or its binary, in the candidate image, with its real dependencies, and checks the exit code and the expected output.

Terminal window
docker run --rm -v "$PWD":/src:ro \
alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a /src/musl/cumul
echo "code=$?"
cumul=33
code=0

For a Python application, add to this test the import of the native extensions it depends on, as in the section on Python packages. For an application that listens on a port, the test is a request on that port. A FROM change is only validated by this launch.

How do you play the smoke test in the build?

The smoke test goes in the Dockerfile itself: a RUN step that runs the binary or imports the extension makes the build fail before any publication. It is the last box of the diagram, played at build time. First case, a multi-stage build: the first stage (gcc:14) compiles cumul, the second copies the binary into the candidate base, passed as the BASE argument, and runs it. The default value of ARG BASE avoids the InvalidDefaultArgInFrom warning, which BuildKit emits when the argument of a FROM line has no value.

Save this file as Dockerfile, in ~/changer-socle next to cumul.c.

Dockerfile
ARG BASE=debian:13-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4
FROM gcc:14@sha256:435c3cbc2ad1c25fbc1684c367808ff0ca2035a1bed56734a40165ab5cd7e3bd AS build
COPY cumul.c .
RUN gcc -O2 -o /cumul cumul.c
FROM ${BASE}
COPY --from=build /cumul /usr/local/bin/cumul
RUN /usr/local/bin/cumul

Build the image twice, with Debian then with Alpine as the candidate base. On Debian, the build ends without error and the RUN step displays cumul=33. On Alpine, it stops at that step.

Terminal window
docker build --progress=plain --build-arg BASE=debian:13-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 -t cumul-test:debian .
docker build --progress=plain --build-arg BASE=alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a -t cumul-test:alpine .
> [stage-1 3/3] RUN /usr/local/bin/cumul:
0.179 /bin/sh: /usr/local/bin/cumul: not found
Dockerfile:8
8 | >>> RUN /usr/local/bin/cumul
ERROR: failed to build: failed to solve: process "/bin/sh -c /usr/local/bin/cumul" did not complete successfully: exit code: 127

Look at the exit code: 127 is that of the shell that executes the RUN (/bin/sh -c), which answers not found because the interpreter of the binary is missing. The same binary run directly by docker run returns 255, as seen above: the code depends on who launches the program. The image is not produced, so nothing unusable goes to production.

Second case, a Python application: the --only-binary :all: option forbids compilation, and the next step imports the native extension. Save the following two files in ~/changer-socle/py.

py/ok.Dockerfile
FROM python:3.14-alpine@sha256:eb2c62658b8d51bff0acc408504b76521d02030de56a8a3c1e830083e89b89d2
RUN pip install --no-cache-dir --only-binary :all: markupsafe
RUN python -c "import markupsafe._speedups"

This Dockerfile builds without error, because markupsafe has a musllinux wheel. The next one replaces the package with onnxruntime, which has none.

py/ko.Dockerfile
FROM python:3.14-alpine@sha256:eb2c62658b8d51bff0acc408504b76521d02030de56a8a3c1e830083e89b89d2
RUN pip install --no-cache-dir --only-binary :all: onnxruntime
RUN python -c "import onnxruntime"
Terminal window
docker build --progress=plain -f py/ok.Dockerfile -t cumul-test:python-ok py
docker build --progress=plain -f py/ko.Dockerfile -t cumul-test:python-ko py
1.717 ERROR: Could not find a version that satisfies the requirement onnxruntime (from versions: none)
1.719 ERROR: No matching distribution found for onnxruntime
ERROR: failed to build: failed to solve: process "/bin/sh -c pip install --no-cache-dir --only-binary :all: onnxruntime" did not complete successfully: exit code: 1

The build stops at the pip install step with code 1 (the output is shortened: the [notice] lines from pip and the reminder of the faulty Dockerfile are omitted). The message does not mention the libc, but since it occurs at build time, you see it before publishing and not at deployment.

Troubleshooting

In most of these symptoms, the message accuses a file or a package that does exist: the cause is elsewhere. The table links each symptom to its cause and to the check that confirms it.

SymptomLikely causeCheck
exec /path: no such file or directory, code 255, file presentELF interpreter missing from the new basefile in the build image, then ls of the path read in the new base
ldd of Alpine answers without error on a glibc binarythe musl ldd does not report the incompatibilityrun the binary, do not rely on ldd
No matching distribution found, from versions: noneno musllinux wheel for this packagepip download --no-deps --only-binary :all: <package> in the target image
Failed to build ..., No such file or directory: 'gcc'pip compiles, the image has no compilerinstall gcc and its headers, or choose a package with a wheel
ModuleNotFoundError: No module named '..._speedups'py3-none-any wheel installed by the compilation without gccimport the native extension in the smoke test, use --only-binary :all:
amd64 binary in arm64 image: no such file or directoryamd64 loader missing from the arm64 imagefile in the build image, then docker create --platform linux/arm64 … and docker export … | tar -t | grep ld- (an ls in the image is impossible without emulation)
docker build stops on a RUN step with not found, code 127interpreter of the binary missing from the candidate base (the RUN shell returns 127, docker run returns 255)file in the build image, then ls of the path read in the candidate base
The binary rebuilt for Alpine fails under Debiansymmetry: the musl interpreter does not exist on Debianls /lib/ld-musl-* in the image

How do you clean up after the measurements?

The measurements leave heavy images, a build cache and a working folder. The gcc:14 image weighs 2.14 GB and the two Python images 178 MB and 73 MB (sizes from docker image inspect --format '{{.Size}}'): remove them by their reference, after the cumul-test images built in the previous section. The alpine and debian:13-slim images serve other lessons and stay.

Terminal window
docker rmi cumul-test:debian cumul-test:python-ok
docker rmi gcc:14@sha256:435c3cbc2ad1c25fbc1684c367808ff0ca2035a1bed56734a40165ab5cd7e3bd
docker rmi python:3.14-slim@sha256:930e1abba7c4e8f96ed93e8012c35d174d7732752731bbd5e8e757d7ec6f3966
docker rmi python:3.14-alpine@sha256:eb2c62658b8d51bff0acc408504b76521d02030de56a8a3c1e830083e89b89d2
cd ~/changer-socle && rm glibc/cumul musl/cumul static/cumul py/ok.Dockerfile py/ko.Dockerfile Dockerfile cumul.c
rmdir glibc musl static py && cd ~ && rmdir ~/changer-socle

To check, docker images must no longer list gcc, python or cumul-test, and ls -d ~/changer-socle must answer No such file or directory. One trap remains: after builds, a docker rmi of an image followed by a new docker pull does not bring it back to its original size, because the build cache retains its content (Alpine stays at 26.6 MB instead of 13 MB). You must first empty this cache. This step erases the whole build cache of the machine, not only that of this lesson: avoid it on a shared machine. The cache reached 2.6 GB after the measurements.

The following sequence empties the cache, removes Alpine, pulls it again by its digest (the A variable keeps it) and gives it back the name alpine:latest, useful only if another lesson uses it.

Terminal window
docker builder prune -af
A=alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
docker rmi alpine:latest "$A"
docker pull -q "$A" && docker tag "$A" alpine:latest
docker image ls
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
alpine:latest be2656061b53 13MB 3.94MB
alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a be2656061b53 13MB 3.94MB

Alpine goes back down to 13 MB (measured on November 3, 2026 with Docker 29.9.2). The output is shortened to the Alpine lines: docker image ls also lists the other images you keep, such as the Debian one. The docker rmi answers No such image for the second reference, which the removal of the first has already taken away, with no consequence for what follows.

Key points

  1. Changing the FROM changes the libc, hence the interpreter that dynamic binaries ask for: glibc and musl do not place it at the same location.

  2. The compatibility of a binary is predicted by reading: file in the build image gives the ELF interpreter, ls in the new base says whether it exists.

  3. Do not rely on ldd in Alpine: it answers with code 0 on a glibc binary that cannot start. A failing getconf GNU_LIBC_VERSION signals a libc that is not glibc.

  4. Three fixes for a glibc binary on Alpine: rebuild in Alpine (the musl binary then fails under Debian), link statically (about 60 times bigger on this program, it runs on both bases tested, with a reservation for name resolution), gcompat (third-party binary, trivial case measured).

  5. The libc of the image chooses the Python wheel: pip debug --verbose shows manylinux on Debian and musllinux on Alpine, and the eight packages tested have a musllinux wheel.

  6. The risk is a package with no musllinux wheel: No matching distribution found does not mention the libc. An installation by compilation without gcc can also succeed while delivering a wheel without the C extension.

  7. An architecture difference does not always give exec format error: on an amd64 host with no emulation, an amd64 binary in an arm64 image answers no such file or directory; on an arm64 host, this same binary answers exec format error.

  8. The smoke test decides: run the application in the new base, native extensions included, before publishing. Placed in a RUN step of the Dockerfile, it makes the build fail: code 127 for a binary with no interpreter, and code 1 from the installation for a package with no wheel, even before the import.

Knowledge check

Check that the essentials of this lesson have stuck. The questions cover only what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

8 questions
8 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn