One Dockerfile, Two Architectures: What Actually Breaks When You Move Containers to ARM
TL;DR — Key Takeaways
-
A successful build doesn’t mean a working container. Docker can produce valid amd64 and arm64 images while hiding architecture-specific binaries and dependencies that fail at runtime.
-
Native dependencies are the migration minefield. Hardcoded downloads, incompatible shared libraries and architecture-specific Java packages can cause crashes or silent performance degradation.
-
Test on ARM before production does it for you. Native ARM runners, dependency audits and architecture-aware Kubernetes scheduling help expose migration failures before they become outages.
Moving a service from x86 to ARM looks like a one-line change. You add a platform flag to the build command, the build succeeds and the registry shows an image that supports both architectures. Most teams stop there and book the compute savings.
A successful build tells you very little about whether the image works. Your application code is portable. Almost everything underneath it is not. Failures show up at runtime, on only one architecture, and often in production because production is the first place the ARM variant actually runs.
A Successful Build Doesn’t Produce One Image
When you run a build like this:
docker buildx build –platform linux/amd64,linux/arm64 -t myapp:1.0 –push .
You don’t get one image. You get two, along with a manifest list that points at both. When a node pulls myapp:1.0, the container runtime reads that list and selects the entry matching its own architecture.
Those two images were produced by two separate build processes. Nothing compares them afterward. If the arm64 variant is broken, the build still reports success, the push still succeeds and the tag still looks healthy.
The first useful habit is to stop trusting the tag and look at what is behind it:
docker manifest inspect myapp:1.0
Where it Actually Breaks
Architecture Strings Written Into the Dockerfile
Any line that downloads a binary by URL carries an assumption:
RUN curl -fsSL https://example.com/tool-linux-x86_64.tar.gz | tar xz -C /opt
This layer builds cleanly on both architectures because fetching and unpacking a file does not require running it. You get an arm64 image containing an x86 binary, and you find out the first time something invokes it.
The fix is to use the automatic build arguments the builder already provides:
ARG TARGETARCH
RUN curl -fsSL https://example.com/tool-linux-${TARGETARCH}.tar.gz | tar xz -C /opt
Be careful here. TARGETARCH resolves to amd64 and arm64, while many vendors publish files named x86_64 and aarch64. A small mapping step is usually needed, and skipping it produces a confident-looking build that downloads nothing useful.
Native Libraries That Arrive as Application Dependencies
This is the layer most teams forget because it lives in the build file rather than the Dockerfile. Netty is the example everyone eventually encounters. Its native transport is published per architecture, and plenty of projects pin the classifier by hand:
<classifier>linux-x86_64</classifier>
On an ARM host, that library will not load. You either get an UnsatisfiedLinkError naming a missing shared object or a silent fallback to a slower, pure Java implementation. The silent case is worse because throughput degrades and nothing fails.
The naming here is its own trap. The ARM classifier is spelled linux-aarch_64, with an underscore before the 64, and the Netty projects have carried open issues about that exact spelling appearing inconsistently in their own build files. If a maintained project can get this wrong in its own POM, a dependency block copied from a blog post three years ago has no chance.
Some libraries have no ARM answer at all. The publicly maintained guidance on running Java on ARM calls out commons-crypto specifically, noting that a JAR supporting ARM can be built by hand but that public artifact repositories do not carry an updated version. A dependency like that does not fail your build — it fails your service.
Database clients deserve the same scrutiny. Oracle does publish an Instant Client for Linux on aarch64, but it is a separate download with its own release notes and its own list of unsupported features. The comfortable assumption that every environment runs the same client binary stops being true the moment one node group changes architecture.
The Contents of Your Base Image
A base image tag can be genuinely multi-architecture while a package you install on top of it is not or exists at a different version in the ARM repository. Both builds succeed. The images are not equivalent.
Why Your Pipeline Doesn’t Catch This
Most build infrastructure still runs on x86. Emulation makes the ARM build possible, but it is slow enough that teams usually run it only for the final image and keep the test suite on the native architecture. As a result, the arm64 image gets built, scanned, pushed and deployed without ever having executed a single test.
Deployment hides the difference too. On managed container services, CPU architecture is one field in a task definition. In Kubernetes, it is a node selector or nothing at all. In a cluster with mixed nodes and no constraint, the same deployment can succeed on some pods and fail on others, depending on where the scheduler places them. That is a genuinely difficult failure to reason about at three in the morning.
A Short Checklist Before you Switch
- Run docker manifest inspect and confirm that both entries exist with the architectures you expect.
- Run the test suite natively on the target architecture, not under emulation. One ARM runner is enough.
- Replace every hardcoded architecture string in the Dockerfile with TARGETARCH plus an explicit mapping.
- Audit the shared objects that actually end up in the image. Build the ARM variant, then look inside it and let the file command tell you what each one really is:
find / -name “*.so” -exec file {} \; 2>/dev/null | grep -iv aarch64
Anything that comes back is a library that will not load. Open-source porting advisors exist that scan a project’s build files for the same problem before you get this far.
Constrain scheduling during rollout so a workload runs on one architecture at a time. Mixed placement is useful later, but not while you are still finding out what breaks.
The Real Cost of the Migration
ARM migration is usually presented as a pricing decision, and the savings are real and easy to measure. The work that earns those savings is not a pricing exercise at all. It is a dependency audit.
Every stack accumulates quiet assumptions about the processor it runs on, and almost none of them live in your application code. They live in a download URL, a pinned classifier, a vendor client library or a package that has only ever shipped for one architecture. The build tool will not find them for you because, from its point of view, nothing went wrong.
Find them before the scheduler does.
Frequently Asked Questions
Why do Docker multi-architecture builds fail at runtime?
Multi-architecture Docker builds create separate images for each target platform. Although both images may build successfully, architecture-specific binaries, native libraries or incompatible dependencies can cause the ARM version to fail when executed.
How can DevOps teams identify incompatible dependencies during an ARM migration?
Teams should inspect Docker manifests, review hardcoded architecture references, check native library compatibility and audit binaries inside ARM images. Running integration and performance tests on native ARM hardware is essential for detecting failures that build pipelines may miss.
What is the safest way to migrate Kubernetes workloads from x86 to ARM?
Start by validating ARM container images and testing applications on native ARM infrastructure. Use Kubernetes node selectors or affinity rules to control workload placement, introduce ARM nodes gradually and monitor application performance and reliability before expanding the rollout.


