It's been almost a year since I've bought the MacBook Pro M1 (arm64
processor) for my daily Java development as a freelance consultant. I had my first contact with the Apple M1 when one of my course students raised an issue that the build doesn't pass on Apple's new flagship laptop. I was first shocked to encounter hardware incompatibilities in 2021. To solve those problems (not my main intent, but I told myself so) and experience if that processor is really that fast, I decided to buy the MacBook Pro.
This article will share my initial pitfalls when working with the Apple M1 and a collection of valuable tricks and workarounds for developing and testing Java applications.
Note: Throughout this article, both arm64
or aarch64
refer to the Apple M1 chip. I'll refer to the traditional Intel/AMD processors as x64
(you may find the following synonyms: x86_64
, amd64
).
Installing Java Development Tools
The first thing we install as Java developers on a new machine is a JDK. While searching for a arm64
compatible JDK back in 2021, the Azul Zulu JDK build popped up first.
There's no difference in the installation process of a JDK compared to an x64
Mac. Once we've downloaded and installed a compatible JDK build, we have the baseline for our Java development for our Apple M1:
1 2 3 4 | java -version openjdk version "17" 2021-09-14 LTS OpenJDK Runtime Environment Zulu17.28+13-CA (build 17+35-LTS) OpenJDK 64-Bit Server VM Zulu17.28+13-CA (build 17+35-LTS, mixed mode, sharing) |
Apart from the Azul Zulu JDK, many other JDK vendors have followed and now offer an arm64 build:
- Amazon Corretto
- Eclipse Temurin (Adoptium project, former AdoptOpenJDK)
- Microsoft's build of the OpenJDK
- etc.
The brew formula for openjdk is also installing a compatible Apple M1 JDK build. For managing different Java versions, sdkman or jEnv is an excellent option.
For those lucky developers that have the chance to work and experiment with the GraalVM, the support for Apple M1 has also landed.
With Apple's Rosetta 2 emulation, we can also try to work with an x64
JDK build. However, we may run into issues and/or won't get the expected performance.
What's next is Docker. Installing a recent Docker for Mac version comes with Apple M1 support. In the early days, there were some minor issues. But having worked with it for almost a year now, many have been continuously fixed.
All other development tools that we use on a daily basis either provide an arm64
build or the emulated x64
version works fine: IntelliJ IDEA, Visual Studio Code, Eclipse, Slack, Notion, Docker for Mac, Spotify, Firefox, Microsoft Teams, Postman.
Rule one: Whenever there's a native arm64
build of any development tool for the M1, go for it. Only try to get the x64
build running if there's no arm64
build available (yet).
To further verify the compatibility and optimization for various other tools for Apple's M1, checkout out isapplesiliconready.
Integration Tests with Docker and Testcontainers
When it comes to running our integration tests that use Testcontainers, the first thing we stumble over with our M1 machine are potential test failures because of missing arm64
platform support.
Docker images are built for specific processor architectures. We can identify the available platforms by looking at the OS/Arch column on Docker Hub for a particular Docker image:
The example above is a screenshot of the official Keycloak Docker image. It only comes with support for x64
machines.
If we try to run this Docker image on our arm64
Apple M1 machine, we get the following warning:
1 2 | $ docker run jboss/keycloak:16.1.1 WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested |
While we get past this warning and Docker tries to run an image for a different platform, it internally uses emulation to try to get the x64 container up and running. However, this emulation for x64
support for arm64
is a best effort.
Images for a different platform may or may not work on our Apple M1. There's no guarantee that the container will start and function as expected.
The Keycloak image, for example, unfortunately, crashes during the bootstrap phase:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # [thread 378 also had an error] [thread 374 also had an error] # A fatal error has been detected by the Java Runtime Environment: # # [thread 381 also had an error] [thread 331 also had an error] [thread 372 also had an error] [thread 377 also had an error] SIGSEGV (0xb) at pc=0x000000401de90af2, pid=310, tid=334 # # JRE version: OpenJDK Runtime Environment 18.9 (11.0.14+9) (build 11.0.14+9-LTS) # Java VM: OpenJDK 64-Bit Server VM 18.9 (11.0.14+9-LTS, mixed mode, sharing, tiered, compressed oops, g1 gc, linux-amd64) # Problematic frame: # J 546 c2 java.lang.AbstractStringBuilder.append(Ljava/lang/String;)Ljava/lang/AbstractStringBuilder; |
When running our test suite for the first time on an Apple M1, we have to identify the Docker containers that fail to start.
If the image itself doesn't provide arm64 support, we can look for an alternative. For the most commonly used Docker images, these are potential alternatives:
- PostgreSQL: The standard images come with
arm64
support, no changes required - Webdriver images: Standalone arm64 support comes with seleniarm images
- Keycloak: arm64 build for the jboss variant starting with version 17.0.1. There are also community multi-architecture builds (richardjkendall or mihaibob) available
- LocalStack: Starting with version 0.13, LocalStack publishes multi-architecture Docker images
- MySQL: Starting with version 8, the oracle version provides an arm64 build. Another alternative is the MariaDB image
- MongoDB: The standard images come with
arm64
support, no changes required - Kafka: No
arm64
build for the bitnami image, but the wurstmeister build supportsarm64
Dynamically Replace Docker Images For ARM64 Support
Whenever we find ourselves looking for an alternative to the default Testcontainers Docker image, we need to override the default image. If we're working on a project on our own or know that the entire team is using an arm64
processor, we can hardcode the arm64
compatible Docker image. Otherwise, we can make the image substitution more dynamic and only replace the Docker image when running on the specific architecture.
Let's use the Selenium Webdriver container as an example for a dynamic override:
1 2 3 4 5 6 7 8 | @Container static BrowserWebDriverContainer<?> webDriverContainer = new BrowserWebDriverContainer<>( System.getProperty("os.arch").equals("aarch64") ? DockerImageName.parse("seleniarm/standalone-chromium") .asCompatibleSubstituteFor("selenium/standalone-chrome") : DockerImageName.parse("selenium/standalone-chrome") ).withCapabilities(new ChromeOptions()); |
Based on the system property os.arch
we decided which image to use for our BrowserWebDriverContainer
.
As an alternative to this workaround, we can use Testcontainers Cloud. With Testcontainers Cloud, we run the backing containers for our integration tests in the cloud. There's no change required for our test. The containers will just run on someone else machine. Since its private beta, I had the chance to demo this amazing tool and am convinced that this will drive the productivity (e.g., faster builds) for testing with Testcontainers even further.
As a last resort, we can also disable specific tests if they won't run at all on our M1 processor. Based on the os.arch
Java system property we can detect the processor architecture and use a JUnit Jupiter annotation to disable tests for our M1:
1 2 3 | @DisabledIfSystemProperty(named = "os.arch", matches = "aarch64", disabledReason = "No ARM64 support") class SomeIT { } |
We can use this annotation on top of a test class or for a particular test. While this is not an optimal solution, at least it helps the Apple M1 developers on our team to see a green build.
These test disabling annotations should be temporary. We must check if there's a solution available (e.g. a compatible Docker image) from time to time. The final build on our CI pipeline must ensure to run all tests (usually a x64
machine).
Docker Client JNA Failure with Testcontainers
Another pitfall we may fall into before we can actually run our Java integration test with Testcontainers is the following error:
1 2 3 4 5 6 7 8 | 06:49:41.241 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy - Could not find a valid Docker environment. Please check configuration. Attempted configurations were: 06:49:41.241 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy - UnixSocketClientProviderStrategy: failed with exception RuntimeException (java.lang.UnsatisfiedLinkError: /Users/rieckpil/Library/Caches/JNA/temp/jna11180227188626594160.tmp: dlopen(/Users/rieckpil/Library/Caches/JNA/temp/jna11180227188626594160.tmp, 0x0001): tried: '/Users/rieckpil/Library/Caches/JNA/temp/jna11180227188626594160.tmp' (fat file, but missing compatible architecture (have 'i386,x86_64', need 'arm64e')), '/usr/lib/jna11180227188626594160.tmp' (no such file)). Root cause UnsatisfiedLinkError (/Users/rieckpil/Library/Caches/JNA/temp/jna11180227188626594160.tmp: dlopen(/Users/rieckpil/Library/Caches/JNA/temp/jna11180227188626594160.tmp, 0x0001): tried: '/Users/rieckpil/Library/Caches/JNA/temp/jna11180227188626594160.tmp' (fat file, but missing compatible architecture (have 'i386,x86_64', need 'arm64e')), '/usr/lib/jna11180227188626594160.tmp' (no such file)) 06:49:41.241 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy - UnixSocketClientProviderStrategy: failed with exception RuntimeException (java.lang.NoClassDefFoundError: Could not initialize class org.testcontainers.shaded.com.github.dockerjava.okhttp.UnixSocketFactory$1). Root cause NoClassDefFoundError (Could not initialize class org.testcontainers.shaded.com.github.dockerjava.okhttp.UnixSocketFactory$1) |
The underlying issue is a JNA (Java Native Access) dependency incompatibility with Apple's M1 chip. Testcontainers transitively depends on this library. Starting with JNA 5.7.0, this issue is resolved.
To fix this problem for our projects, we can either upgrade our Testcontainers version (preferred) or manually override the JNA version. Starting with Testcontainers 1.15.3, Tesctonainers transitively depends on a JNA version (5.7.0) that is working for the Apple M1.
Suppose we still see the same test failure even after bumping the Testcontainers version. In that case, the chances are high that another dependency also transitively depends on JNA and overrides the version.
In this situation, we can either exclude the JNA dependency from any other dependency that includes it or use the dependencyManagement
section to force the resolution of a specific version of it:
1 2 3 4 5 6 7 8 9 | <dependencyManagement> <dependencies> <dependency> <groupId>net.java.dev.jna</groupId> <artifactId>jna</artifactId> <version>5.7.0</version> </dependency> </dependencies> </dependencyManagement> |
To locate dependencies that transitively include JNA, run mvn dependency:tree
and then search for JNA in the result.
Local Development Environment with Docker Compose
Many projects use a docker-compose.yml
for running the required infrastructure when locally developing or testing a Java application.
Depending on the amount and variety of required infrastructure (e.g., messaging queues, databases, caches, etc.), tweaking the docker-compose.yml
to work for both x64
and arm64
may not be an option.
The same Docker image arm64
availability issue as for testing applies here. Sometimes the community Docker builds only support one platform and not both.
As a possible workaround, we can introduce a second docker-compose-arm64.yml
file to support team members working with an M1 (or any other arm64
machine).
I'm using this concept for the Testing Spring Boot Application Masterclass, where the Amazon SQS and Keycloak image needs to be replaced with an arm64 compatible image.
This adds some small maintenance effort as the team has to keep the two Docker compose files in sync. However, as soon as the availability and adoption of arm64
images is improving, the dedicated arm64
Docker compose file can be deleted.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | version: '3.8' services: # ... keycloak: image: mihaibob/keycloak:15.0.1 environment: - KEYCLOAK_USER=keycloak - KEYCLOAK_PASSWORD=keycloak - DB_VENDOR=h2 - JAVA_OPTS=-Dkeycloak.migration.action=import -Dkeycloak.migration.provider=singleFile -Dkeycloak.migration.file=/tmp/keycloak-dump.json volumes: - type: bind source: ./tmp/keycloak-dump.json target: /tmp/keycloak-dump.json read_only: true ports: - "8888:8080" sqs: image: softwaremill/elasticmq-native volumes: - type: bind source: ./tmp/sqs-queue-definition.conf target: /opt/elasticmq.conf read_only: true ports: - "9324:9324" - "9325:9325" |
We can then pass the underlying compose file to our docker-compose
start command:
1 | docker-compose --file docker-compose-arm64-support.yml up |
The downside of this additional compose file is the duplicated maintenance. We have to ensure both our composes files (x64
and arm64
) are in sync. Similar to the workarounds for making our Testcontainers integration tests pass, this should also be a temporary solution that we reassess from time to time.
Java Development: Spring Boot Netty Warning on Startup
Upon starting a Spring Boot application on an Apple M1, we may encounter an ERROR
log from Netty with a big stack trace during the bootstrap phase:
1 2 3 4 5 6 7 8 9 | 2022-03-21 06:52:09.885 ERROR 17220 --- [main] i.n.r.d.DnsServerAddressStreamProviders : Unable to load io.netty.resolver.dns.macos.MacOSDnsServerAddressStreamProvider, fallback to system defaults. This may result in incorrect DNS resolutions on MacOS. java.lang.reflect.InvocationTargetException: null at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method) ~[na:na] at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:77) ~[na:na] at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) ~[na:na] at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:499) ~[na:na] at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480) ~[na:na] at io.netty.resolver.dns.DnsServerAddressStreamProviders.<clinit>(DnsServerAddressStreamProviders.java:64) |
While our application should still function as expected, this log is noisy and may confuse developers. As long as we don't run our Spring Boot application on an Apple M1 in production, it's also less critical if we don't fix it.
For those that want to fix this startup error message, make sure to use Netty > 4.1.68.Final
and add the following dependency to our project:
1 2 3 4 5 6 7 | <!-- Fix noisy ERROR log on ARM64 processors during startup. See https://github.com/netty/netty/issues/11020 --> <dependency> <groupId>io.netty</groupId> <artifactId>netty-resolver-dns-native-macos</artifactId> <version>${netty.version}</version> <classifier>osx-aarch_64</classifier> </dependency> |
After making this change, the Spring Boot application should start again with a clean bootstrap log.
Building Multiplatform Docker Images on an Apple M1
When building Docker images, the resulted image will only work on the platform we're working on. Any output of docker build -t myimage .
on an Apple M1 will only work on another arm64
machine.
While this is completely obvious, it took me a 2 hour debug session to be aware of it. I was building a new feature for a Spring Boot application (Stratospheric) and wanted to push the resulting Docker image directly to the ECR (Elastic Container Registry) from my local machine. While we have a running CI/CD in place, I wanted to short-circuit this process and save time. After pushing the image to the container registry, I encountered weird error logs, and the ECS (Elastic Container Service) task was unable to start.
That's because the underlying EC2 instance is an x64
machine. Even the obvious things sometimes take it the hard way to understand them.
Whenever we find ourselves in a similar situation, where we want to create a Docker image for a different processor architecture, we have two options:
- Build the target Docker image on a CI/CD server running on an
x64
machine (or any architecture) - Use Docker's buildx, a CLI plugin, to get access to all features of the Moby BuildKit (e.g., multiplatform images)
We won't go further into the first option, as a CI/CD workflow (e.g., GitHub actions) should be already in place for (hopefully) most projects.
The second option is more interesting to us as this will allow us to build locally arm64
and x64
compatible Docker images.
As a prerequisite for this to work, we have to ensure our Docker base image (FROM
part of the Dockerfile) provides a variant for all architectures we want to build the final Docker image for. We can verify this by visiting Docker Hub and the tags section of our Docker base image.
Taking eclipse-temurin
(OpenJDK build of the Adoptium project, former AdoptOpenJDK) as a Docker base image example, we can see that this image supports various architectures:
Once our Dockerfile
use a base image with support for multiple architectures, we can use the Docker buildx
command to build the image for multiple platforms:
1 | docker buildx build --platform linux/amd64,linux/arm64 -t todo-app:latest . |
In the example above, we build both an arm64
and x64
image for our Java Spring Boot application:
1 2 3 4 5 6 | FROM eclipse-temurin:17-jre ARG JAR_FILE=build/libs/*.jar COPY ${JAR_FILE} app.jar ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=aws", "/app.jar"] |
We can also directly push the result into a container registry with:
1 | docker buildx build --platform linux/amd64,linux/arm64 --push -t some.ecr.amazonaws.com/todo-app:latest . |
Using this technique, we now can build new Docker images locally and deploy them to run on x64
machines in the cloud. This gives us great flexibility for the Java development on our Apple M1.
Building ARM64 Images on GitHub Actions
If we're in the lucky position to develop and publish our own Docker images, we may want to adopt arm64
builds to increase the overall adoption rate.
We're using a custom ActiveMQ Docker image for the Stratospheric project for testing and local development purposes. We were all using a classic Intel processor during the inception phase of this project and when creating this Docker image. Things changed with my MacBook Pro purchase, and therefore, we started looking into multiplatform support.
The ActiveMQ message broker runs on the JVM. Hence any ingredient of our Dockerfile must also be available for an arm64 architecture. For our example, we were using fabric8/java-alpine-openjdk11-jre
as the base image. Unfortunately, this image only supports linux/amd64
(i.e. x64
) and hence we had to replace it with a JRE base image that supports all platforms we want to build our image for.
We went for eclipse-temurin:11-jre-focal
as the base Docker image as it supports multiple architectures:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | FROM eclipse-temurin:11-jre-focal # ... RUN tar xzf $ACTIVEMQ-bin.tar.gz -C /opt && \ ln -s /opt/$ACTIVEMQ $ACTIVEMQ_HOME && \ groupadd activemq && \ useradd -m -d $ACTIVEMQ_HOME -g activemq activemq && \ chown -R activemq:activemq /opt/$ACTIVEMQ && \ chown -h activemq:activemq $ACTIVEMQ_HOME EXPOSE 1883 5672 8161 61613 61614 61616 USER activemq WORKDIR $ACTIVEMQ_HOME CMD ["/bin/sh", "-c", "bin/activemq console"] |
Given the multiplatform support from the base image, we can now locally build an arm64
variant of the Docker image (using buildx
of the last section) and push it to a Docker registry.
As an alternative, we can automate this process on GitHub Actions and build a multiplatform image on each commit. The Docker team provides ready-to-use actions to build and push a multiplatform Docker image.
For our custom ActiveMQ image, we adjusted our GitHub Actions as the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | name: Publish Docker image on: [push] jobs: build_push_to_registry: name: Push Docker image to Docker Hub runs-on: ubuntu-latest steps: - name: Check out uses: actions/checkout@v2 - name: Set up QEMU uses: docker/setup-qemu-action@v1 with: platforms: arm64, amd64 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 - name: Login to Docker Hub uses: docker/login-action@v1 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_ACCESS_TOKEN }} - name: Build Docker image and push to Docker Hub uses: docker/build-push-action@v2 with: context: . file: ./Dockerfile platforms: linux/amd64, linux/arm64 push: ${{ github.ref == 'refs/heads/main' }} tags: stratospheric/activemq-docker-image:latest |
These five steps automate everything from checking out the source code, building a multiplatform image for arm64
and x64
and then finally pushing this image to Docker Hub.
What's left for us is to configure a username and access token for Docker Hub. We store them as GitHub secrets for our repository.
Given the GitHub automation, we deploy new versions of our Docker image to DockerHub on every push to our main branch:
Summary: Java Development on an Apple M1
Having worked with an Apple M1 primarily for Java development for almost a year now, I don't regret the choice. It took me some time to resolve the initial hurdles and get everything running. The mentioned strategies in this article solved all my testing and developing concerns for Java applications on the M1.
The good news is that with every passing day, more tools adopt arm64
as Apple continues to release new flagship products running on arm64
processors. It's only a matter of time until the last Java development tool works perfectly fine on an Apple M1.
The performance of the laptop (Apple MacBook Pro M1 2021) is excellent, and a huge productivity boost as my Java builds, and tests run faster compared to an x64
Mac. I still keep my old Ubuntu desktop PC around as there are occasionally tasks where I need a good old x64
machine.
Let me know about your tips & tricks for Java development on an Apple M1 in the comments.
Joyful testing,
Philip