Server Development on Apple Silicon

In my last post, I outlined our journey of getting our server products, PSPDFKit Web Server-Backed and PSPDFKit Processor, running on ARM-based hardware. Today, I want to share how we got Apple silicon working on our Server-based products and give you my perspective of where it works and where it doesn’t.
Getting Set Up
The first thing that needs to be discussed is getting set up. The tech stack we use for our server-based products consists of:
- Erlang and Elixir for running our main Server application
- Our
pspdfkitd
binary, which is based on our shared core that we use for all PDF-related operations - Node.js for running tests against our HTTP API
- Ruby for internal tooling around development
Let’s see if we can make this work on Apple silicon.
External Dependencies
We use the asdf version manager(opens in a new tab) for managing all our dependencies. For context, this was our .tool-versions
file when we started this:
nodejs 10.15.1elixir 1.11.2-otp-23erlang 23.2.1ruby 2.7.1
Let’s try installing the above on an M1 MacBook — after adding the relevant plugins, of course:
$ asdf install/Users/user/.asdf/plugins/nodejs/bin/../lib/utils.sh: line 62: printf: write error: Broken pipe % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 100 4229 0 4229 0 0 13511 0 --:--:-- --:--:-- --:--:-- 13511 % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 100 10860 0 10860 0 0 32612 0 --:--:-- --:--:-- --:--:-- 32612Binary not found for version 10.15.1
So we already hit our first road block: The Node.js plugin for asdf(opens in a new tab) relies on prebuilt binaries for each supported platform. Sadly, at the time of writing, there wasn’t a binary available for ARM-based MacBooks. Luckily, Node itself does support ARM, so if you build it yourself, it should work. Let’s try using nvm(opens in a new tab):
$ nvm install 10.15.1…#error Target architecture arm64 is only supported on arm64 and x64 host ^1 error generated.1 error generated.make[1]: *** [/Users/user/.nvm/.cache/src/node-v10.15.1/files/out/Release/obj.host/v8_libbase/deps/v8/src/base/file-utils.o] Error 1make[1]: *** [/Users/user/.nvm/.cache/src/node-v10.15.1/files/out/Release/obj.host/v8_libbase/deps/v8/src/base/functional.o] Error 11 error generated.make[1]: *** [/Users/user/.nvm/.cache/src/node-v10.15.1/files/out/Release/obj.host/v8_libbase/deps/v8/src/base/ieee754.o] Error 1make: *** [node] Error 2nvm: install v10.15.1 failed!
Still no luck. After some additional research, I found a suggestion that said using a newer version of Node might actually work. Let’s give that a shot:
$ nvm install 15.11.0…Now using node v15.11.0 (npm v7.6.0)$ node --versionv15.11.0
It took a couple of minutes, but we now have Node running on our M1 MacBook. Let’s remove the asdf plugin and try installing our dependencies again:
$ asdf plugin remove nodejs$ asdf installDownloading openssl-1.1.1i.tar.gz...-> https://dqw8nmjcqpjn7.cloudfront.net/e8be6a35fe41d10603c3cc635e93289ed00bf34b79671a3a4de64fcee00d5242Installing openssl-1.1.1i...Installed openssl-1.1.1i to /Users/user/.asdf/installs/ruby/2.7.1
Downloading ruby-2.7.1.tar.bz2...-> https://cache.ruby-lang.org/pub/ruby/2.7/ruby-2.7.1.tar.bz2Installing ruby-2.7.1...ruby-build: using readline from homebrew
BUILD FAILED (macOS 11.2.1 using ruby-build 20201225)
Inspect or clean up the working tree at /var/folders/m3/bbbz7q152k75f4d51lnh5vc00000gn/T/ruby-build.20210311160726.52882.JhuVIJResults logged to /var/folders/m3/bbbz7q152k75f4d51lnh5vc00000gn/T/ruby-build.20210311160726.52882.log
Last 10 log lines:compiling psych_parser.ccompiling psych_to_ruby.ccompiling psych_yaml_tree.ccompiling ../.././ext/psych/yaml/reader.ccompiling ../.././ext/psych/yaml/scanner.ccompiling ../.././ext/psych/yaml/writer.clinking shared-object stringio.bundlelinking shared-object zlib.bundlelinking shared-object psych.bundlemake: *** [build-ext] Error 2
It’s clear that we’re still in uncharted territory here. After some more research, it turned out that a simple version bump is all that’s required. Changing our Ruby version from 2.7.1
to 2.7.2
seems to do the trick:
$ asdf installDownloading openssl-1.1.1i.tar.gz...-> https://dqw8nmjcqpjn7.cloudfront.net/e8be6a35fe41d10603c3cc635e93289ed00bf34b79671a3a4de64fcee00d5242Installing openssl-1.1.1i...Installed openssl-1.1.1i to /Users/user/.asdf/installs/ruby/2.7.2
Downloading ruby-2.7.2.tar.bz2...-> https://cache.ruby-lang.org/pub/ruby/2.7/ruby-2.7.2.tar.bz2Installing ruby-2.7.2...ruby-build: using readline from homebrewInstalled ruby-2.7.2 to /Users/user/.asdf/installs/ruby/2.7.2
$ ruby --versionruby 2.7.2p137 (2020-10-01 revision 5445e04352) [arm64-darwin20]
Another 10 minutes later and we have Ruby running as well. Interestingly, if you check the changelog(opens in a new tab), you’ll find no references to any fixes made for ARM support, and indeed, some other people report that even 2.7.2 only installs in Rosetta. However, I had no issues, and as you can see from the output, it is indeed running as a native ARM64 binary. Let’s see if Erlang and Elixir will cause issues as well:
$ asdf_23.2.1 is not a kerl-managed Erlang/OTP installationNo build named asdf_23.2.1Extracting source codeBuilding Erlang/OTP 23.2.1 (asdf_23.2.1), please wait...…
Erlang/OTP 23.2.1 (asdf_23.2.1) has been successfully builtInstalling Erlang/OTP 23.2.1 (asdf_23.2.1) in /Users/user/.asdf/installs/erlang/23.2.1...
$ erlErlang/OTP 23 [erts-11.1.5] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1]
Eshell V11.1.5 (abort with ^G)1>
That was painless. Seems like Erlang already supports Apple silicon in our current version. What does Elixir say?
$ asdf install==> Checking whether specified Elixir release exists...==> Downloading 1.11.2-otp-23 to /var/folders/m3/bbbz7q152k75f4d51lnh5vc00000gn/T//elixir-precompiled-1.11.2-otp-23.zip % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 100 5720k 100 5720k 0 0 8897k 0 --:--:-- --:--:-- --:--:-- 8883k==> Copying release into place
$ elixir --versionErlang/OTP 23 [erts-11.1.5] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1]
Elixir 1.11.2 (compiled with Erlang/OTP 23)
Now we’re all done, and we have our dependencies installed and running!
The takeaway here is that unless your tech stack is really esoteric (or using very old versions), chances are it should be relatively easy for you to make it work on Apple silicon. If you already try to stay up to date with your versions, you’ll most likely not even need to do anything beyond installing them as usual.
Our pspdfkitd Binary
With all our external dependencies installed, there was one more component we required when working on PSPDFKit Web Server-Backed or Processor, and that’s our PDF processing tool. This is what handles all PDF-related operations. In our release, it’s packaged inside the Docker image we ship, but when working locally, we need it in our path. Usually, we put it in the /usr/local/
folder, but on the M1 MacBook, by default, /usr/local/bin/
, /usr/local/share/
, and /usr/local/lib/
are owned by root
. And since Homebrew(opens in a new tab) also puts ARM binaries in its own /opt/homebrew/
folder, we decided it’s best to follow suit and not require sudo
.
We don’t have a native ARM version of pspdfkitd
for macOS yet (we do have one for Linux, which we use for our Web Server-Backed and Processor releases), but it runs with no issues in Rosetta 2(opens in a new tab). So all we had to do was adjust our installation script to use the new paths as outlined above. It looks something like this in action:
if [[ $OSTYPE == darwin* && `uname -m` == "arm64" ]]; then # M1 MacBook use /opt/homebrew mv /tmp/pspdfkitd /opt/homebrew/binelse # Use /usr/local mv /tmp/pspdfkitd /usr/local/binfi
We rely on Homebrew having already added /opt/homebrew/
to the path on our development machines, so we don’t need any additional setup.
The Development Experience
Considering setting up wasn’t much harder than on a regular Mac, how is the development experience? It has its ups and downs; let’s take a closer look.
The Good: Writing and Running Code
This is where the new M1 MacBooks really shine: They’re completely silent, they barely get warm, and they’re more than fast enough to handle working on our Elixir codebase. You’ve got all the tools you need to work, since things like Visual Studio Code already have Apple silicon-compatible versions. And even things that don’t yet, such as SmartGit (the git program of choice for me), work perfectly fine in Rosetta 2.
Now, there are already many websites that have benchmarked the performance of the new Apple silicon Macs against their Intel counterparts, so I won’t get too much into that. But if you work with Elixir and are worried about compile times, fear not: I’ve got the data for you. I’m comparing my 2019 16" MacBook Pro (2,4 GHz 8-Core Intel Core i9, 32 GB 2667 MHz DDR4) with my 2020 MacBook Air (M1, 16 GB). Let’s look at the time
output of a clean compile of PSPDFKit Web Server-Backed.
2019 MacBook Pro Results:
$ time mix compilemix compile 299.75s user 21.70s system 362% cpu 1:28.72 totalmix compile 297.25s user 21.79s system 365% cpu 1:27.23 totalmix compile 299.36s user 22.15s system 359% cpu 1:29.33 totalmix compile 310.94s user 22.60s system 362% cpu 1:32.00 total
2020 MacBook Air Results:
$ time mix compilemix compile 102.73s user 23.90s system 213% cpu 59.451 totalmix compile 103.15s user 22.52s system 199% cpu 1:02.96 totalmix compile 103.15s user 22.52s system 199% cpu 1:02.96 totalmix compile 101.87s user 23.41s system 214% cpu 58.460 total
Now those results are just impressive, though it’s also a slightly unfair comparison. This is because the 2019 MacBook I used for comparison has been my main workhorse for almost a year at this point, and it has much more stuff installed on it and running in the background. Meanwhile, the M1 MacBook Air is essentially straight out of the box with only the bare minimum installed and running. That being said, the giant 16" MacBook taking three times as long for a clean compile is still a bad result, even considering other stuff running.
There isn’t much else to add here. If you work with Elixir and run it straight from the command line (as opposed to in Docker, which we’ll get to next), the new M1 MacBook is great.
The Bad: Docker
This brings me to what isn’t great, and that’s Docker(opens in a new tab). While there’s a Docker version that runs on Apple silicon(opens in a new tab), and all images that support ARM64 work with it, it’s much slower than on Intel. For us, during development, that doesn’t really matter, since we run our entire server directly on the Mac. However, on occasion, we have to check if changes we make to our Docker build work, and that means building Docker images.
Let’s compare how long it takes to build our base image using the same hardware as above. Building this Docker image includes compiling all runtime dependencies we need for PSPDFKit Web Server-Backed — such as Ruby — and pulling dependencies via APT.
2019 MacBook Pro Results:
$ time docker-compose build --no-cache base
real 6m11.356suser 0m0.966ssys 0m0.366s
real 6m2.826suser 0m1.004ssys 0m0.387s
2020 MacBook Air Results:
$ time docker-compose build --no-cache base
real 11m58.859suser 0m4.526ssys 0m1.748s
real 12m19.955suser 0m4.544ssys 0m1.833s
As you can see, neither is really fast (to be fair, our CI also takes six minutes to build this image), but the M1 MacBook is much slower here, taking twice as long. This, I think, comes down to the file system performance for Docker on Mac being not great, which makes it an even bigger bottleneck on the M1 MacBook. That being said, it’s still early for Docker on Apple silicon, and there isn’t even a stable release out yet, so this surely will get better in the future. For now, I’d recommend using remote builders(opens in a new tab) when using Docker on Apple silicon. As long as your connection is relatively fast, this will be much quicker than trying to build locally, and it’s a great way to see if your build context is bloated.
As for running Docker images, as long as they’re built for ARM64, this works just fine (this includes PSPDFKit Web Server-Backed and Processor as well). There isn’t anything to complain about performance-wise either; it’s just building them that’s too slow for productive work.
With that, let’s wrap up.
Conclusion
Assuming your tools are supported on Apple silicon and you don’t rely heavily on Docker, the new M1 Macs are great for development. Many of the tools we as developers rely on are natively supported, and for those tools that aren’t yet, Rosetta 2 works well. I only looked into Elixir development today, but Apple silicon should do great with almost anything.
I hope this gave you an idea of what to expect if you decide to pick up one of the new M1 Macs for development. Be sure to let us know if there’s anything else we can answer.
If you want to learn more about ARM at PSPDFKit, you can also check out our previous post, where we looked at how we added ARM support to our PSPDFKit Web Server-Backed and Processor products.