One of the promises of Docker is reproducibility: you can build an image on a different machine, and assuming you’ve done the appropriate setup, get the same result. So it can be a little confusing when you try to build your Python-based
Dockerfile on a new Mac, and everything starts failing. What used to work before—on an older Mac, or on a Linux machine—fails in completely unexpected ways.
The problem is that the promise of reproducibility relies on certain invariants that don’t apply on newer Macs. The symptoms can be non-obvious, though, so in this article we’ll cover:
- Common symptoms of the problem when using Python.
- The cause of the problem: a different CPU instruction set.
- Solving the problem by ensuring the code is installable or compilable.
- Solving problem with CPU emulation, some of the downsides of this solution, and future improvements to look forward to.
- A takeaway for maintainers of open source Python packages.
Identifying the problem
Symptom #1: You need a compiler
Consider the following
FROM python:3.9-slim RUN pip install murmurhash==1.0.6
If we build it on a Linux desktop, all is well:
linux:$ docker build . ... Successfully built bda44f5fa6cc
On a Mac Mini M1, however, things go wrong:
macos:% docker build . ... > [2/2] RUN pip install murmurhash==1.0.6: Collecting murmurhash==1.0.6 Downloading murmurhash-1.0.6.tar.gz (12 kB) ... creating build/temp.linux-aarch64-cpython-39/murmurhash gcc -pthread -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -fPIC -I/usr/local/include/python3.9 -I/tmp/pip-install-qitioo67/murmurhash_05dd4f37b6414216a03a1a2d0e285374/murmurhash/include -I/usr/local/include/python3.9 -c murmurhash/MurmurHash2.cpp -o build/temp.linux-aarch64-cpython-39/murmurhash/MurmurHash2.o -O3 -Wno-strict-prototypes -Wno-unused-function error: command 'gcc' failed: No such file or directory ...
Why do we need a compiler on the Mac, but not on Linux?
Symptom #2: Missing packages
Let’s try a different
FROM python:3.9-slim RUN pip install filprofiler==2022.05.0
Again, everything works on fine on my Linux machine:
linux:$ docker build . ... Successfully built 1d8aa3de92bb
But on the Mac, it fails with a new error:
% docker build . ... ERROR: Could not find a version that satisfies the requirement filprofiler==2022.05.0 (from versions: none) ERROR: No matching distribution found for filprofiler==2022.05.0 ...
The problem: different hardware and wheel availability
So why do the Linux machine and Mac have such different outcomes? It’s not the operating system; it’s the result of a different CPU instruction set combined with lack of binary wheels.
CPU instruction sets
The CPU instruction set is the language the CPU speaks, the set of binary instructions it can interpret. My Linux computer is running an Intel chip, which uses the x86_64 instruction set, also known as AMD64 since it was first created by AMD. CPUs from Intel and AMD uses this instruction set.
Older Macs use this instruction set as well, but new Macs with M1 or M2 processors use the ARM64 instruction set, aka aarch64; Apple calls these “Apple Silicon” CPUs just to throw in a little meaningless terminology confusion. ARM64 and x86_64 instruction sets are different languages; a CPU that speaks one can’t understand the other.
That means binary code like an executable or Python extension compiled for x86_64 can’t run on an ARM64 CPU, and vice versa.
Note: AMD64 and ARM64 are visually similar, but different instruction sets, so be careful when reading those names.
How Docker deals with CPU instruction sets (the simple version)
Since executable code needs to match the CPU, you need different Docker images for different CPU instruction sets. Even though on both machines the base image was
python:3.9-slimin practice different images were chosen.
If we look at the images for the
python:3.9-slim tag, we can see there are actually multiple images available, covering multiple different architectures. The relevant ones for our purposes are the
linux/amd64 image, used on Intel or AMD CPUs, and the
linux/arm64/v8 used on newer Macs. Depending what CPU you’re using, Docker will pull the respective image.
Why is there
linux in both cases? Aren’t we on macOS? In order to meet its build-once-run-everywhere promise, Docker typically runs on Linux. Since macOS is not Linux, on macOS this is done by running a virtual machine in the background, and then the Docker images run inside the virtual machine. So whether you’re on a Mac, Linux, or Windows, you typically’ll be running
linux Docker images.
In short: on an Intel/AMD PC or Mac,
docker pull will pull the
linux/amd64 image. On a newer Mac using M1/M2/Silicon chips,
docker pull will the pull the
Note: Docker also supports Windows-oriented images, but that’s out of scope for this article so I won’t mention it against.
Python, Docker and CPU instruction sets
How does Python usage interact with Docker images and CPU instrunction sets? If we’re using pure Python code, it’s almost invisible.
- On Intel or AMD CPUs,
python:3.9-slimis the x86_64 aka amd64 image, which has a Python executable compiled for x86_64.
- On ARM64 machines like my Mac Mini,
python:3.9-slimis the ARM64 image, which has a Python executable compiled for ARM64.
In either case, pure Python will just work, because it’s interpreted at runtime: there’s no CPU-specific machine code, it’s just text that the Python interpreter knows how to run.
The problems start when we start using compiled Python extensions.
These are machine code, and therefore you need a version that is specific to your particular CPU instruction set.
The impact of Python wheel and source availability
In order to make it easier to install compiled Python extensions, package maintainers can upload pre-compiled “wheels” to PyPI. As you might expect, wheels are typically specific to a particular CPU instruction set. And the wheels we care about for Docker packaging will always be the Linux wheels, since Docker images we’re focusing on are always Linux-based.
We can now explain the two sets of symptoms we saw earlier.
pip install filprofiler==2022.05.0 on a M1/M2 Mac inside Docker,
pip will look at the available files for that version, and then:
- Try to download a
aarch64wheel, since using Docker means it’s actually running on a Linux virtualmachine.
- Since that version of the Fil memory profiler doesn’t have any such wheel uploaded,
pipwill then look for a source
- Since it can find that either (I’ve never gotten around to making one), installation will fail with a “no matching distribution” error message.
If you were running on a x86_64 machine—an older Mac, or a PC—it would instead be looking instead for
amd64 wheels, which do exist.
pip install murmurhash==1.0.6 on a M1/M2 Mac inside Docker, again it looks at the available files:
- It wants to download a
- Since none exist, it will download the source tarball.
- It unpacks it, and tries to compile the package from source.
- At this point it fails, because there’s no
gcccompiler installed in the build image I was using.
In short, a lack of
aarch64 wheels for these packages explains both the errors we were seeing.
arm64 wheels are not sufficient; since we’re running inside Docker, we need Linux wheels.
Solution #1: Get the code to install
So how can we get the code installed if there aren’t wheels?
First, it’s possible the relevant wheels are available in newer versions of the libraries. So try upgrading, and see if that helps. If there is no
aarch64 wheel, politely file a bug with the upstream maintainer, and with any luck they will—eventually, when and if they have time—build these wheels for future release.
Second, if that doesn’t work, you can just make sure the source code packages compile. If you have a compiler installed in your Docker image and any required native libraries and development headers, you can compile a native package from the source code. Basically, you add a
RUN apt-get upgrade && apt-get install -y gcc and iterate until the package compiles successfully. The problem is that image builds will get slower (solvable with caching), and the images will be larger (solvable with multi-stage builds).
Third, you can pre-compile wheels, store them somewhere, and install those directly instead of downloading the packages from PyPI.
Solution #2: Run x86_64 Docker images instead
The other option is to run x86_64 Docker images on your ARM64 Mac machine, using emulation. Docker is packaged with software that will translate or emulate x86_64 machine code into ARM64 machine code on the fly; it’s slow, but the code will run.
You can enable this on the command-line:
macos:% docker build --platform linux/amd64 -t myimage . ... macos:% docker run --platform linux/amd64 myimage
Or with an environment variable:
macos:% export DOCKER_DEFAULT_PLATFORM=linux/amd64 macos:% docker build -t myimage . ... macos:% docker run myimage
Or you can set this in the
FROM --platform=linux/amd64 python:3.9-slim RUN pip install murmurhash==1.0.6
In Compose you can also use the
platform option per-service.
With any of these options you will end up installing
amd64 wheels, which are what most Python packages will provide at the moment. And that means you’ll be able to build your image.
The problem with this approach is that the emulation slows down your runtime.
How much slower it is? Once benchmark I ran was only 6% of the speed of the host machine! This is partially offset by the fact that the M1/M2 chips have such fast single-core performance compared to Intel-based Macs. But it’s still slow.
There is hope: Apple has a much faster translation tool called Rosetta. At the moment it’s limited to Mac binaries, but in macOS “Ventura” 13, it will also support Linux binaries. When that happens hopefully Docker will be able to benefit from a speed boost as well.
If you go with this approach, which of the configuration options above should you use? In general, the environment variable is too heavy-handed and should be avoided, since it will impact all images you build or run. Given the speed impact, you don’t for example want to run your
postgres image with emulation, to no benefit. You’re much better off specifying on a per-image basis whether it’s emulated or not.
A side note for Python package maintainers
For maintainers of Python open source packages, the new Macs have added extra work: providing macOS wheels compiled for ARM64. Both Fil (which I maintain) and
murmurhash provide these wheels in their latest version.
However, as we’ve seen, macOS ARM64 are not sufficient to fully support ARM64 Mac users.
If an ARM64 Mac user runs Docker, they will also want
aarch64 wheels… and both Fil and
murmurhash lack those as of June 2022. This isn’t an accusation or complaint, since I maintain Fil I’m as guilty of this as anyone else (and I maintain other packages with the same issue.) Since ARM64-based build machines are still not readily available everywhere it’s annoying to setup.
Nonetheless, best practices as this point should be providing both macOS and Linux ARM64 wheels.
If you’re using
cibuildwheel to build wheels, there’s some documentation of how to do this. If you’re packaging Rust code with Maturin, the GitHub Action messense/maturin-action can also do cross-compilation.
Beyond Mac users,
aarch64 wheels are also helpful for users who are switching to ARM64-based Linux machines in the cloud. AWS, for example, claims its Graviton 3 machines have “up to 40% better price performance over comparable current generation x86-based instances.”
In general, the current situation is not ideal: all the options will likely result in more complex or slower Docker builds, and emulation will also result in slower runtime. With any luck, this will improve if Docker can take advantage of the x86_64 Linux speedups in macOS 13.