Building Multi-Arch Geospatial Images
Cloud arm64 instances are cheaper per core than amd64 and a bathymetric gridding job is embarrassingly parallel, so the arithmetic is attractive. The obstacle is that a geospatial image is not portable the way a pure-Python one is: the wheels differ, the library versions available differ, and the numerical results can differ in the last bits. This page builds both architectures and proves they agree, within the containerization workflow.
Root cause: the stack below Python is not the same stack
A geospatial container is a thin Python layer over GDAL, PROJ, GEOS and their dependencies, all compiled. Three things vary by architecture:
Wheel availability. rasterio and fiona ship manylinux wheels bundling their own GDAL. Coverage for aarch64 is good now and lags for new releases, and for less common packages — pdal, laspy extras, some netCDF stacks — it can be absent entirely. A missing wheel means pip compiles from source, which needs a toolchain in the image and produces a build that links against whatever system GDAL is present rather than the bundled one.
Distribution package versions. apt on arm64 sometimes carries a different GDAL point release than on amd64 for the same distribution snapshot. A pipeline pinned to libgdal-dev rather than to a version gets two different libraries from one Dockerfile.
Floating point. Both architectures are IEEE 754, so basic arithmetic agrees. Transcendental functions do not: libm implementations differ, and a PROJ transformation involving trigonometric functions can differ in the last one or two bits between architectures. That is far below survey accuracy and it is not zero, which matters for any test asserting exact equality.
docker run --rm --platform linux/amd64 my-image python -c \
"from pyproj import Transformer; print(repr(Transformer.from_crs('EPSG:4326','EPSG:32618',always_xy=True).transform(-75.0941,38.7870)))"
# (492013.8624549816, 4293201.155595882)
docker run --rm --platform linux/arm64 my-image python -c "$(: same)"
# (492013.8624549817, 4293201.155595882)
One unit in the last place, on the easting. Irrelevant to a survey and fatal to a test written with ==.
Step-by-step build
1. Buildx with native builders
Emulated builds work and are slow: compiling GDAL bindings under QEMU is roughly an order of magnitude slower than native, which turns a five-minute build into an hour and makes CI unusable.
# One builder per architecture, on native hardware.
docker buildx create --name marine --node marine-amd64 \
--platform linux/amd64 --driver docker-container
docker buildx create --name marine --append --node marine-arm64 \
--platform linux/arm64 --driver docker-container \
ssh://build@arm-runner.internal
docker buildx build --builder marine \
--platform linux/amd64,linux/arm64 \
--tag ghcr.io/org/marine-pipeline:2026.03 \
--provenance=true --sbom=true \
--push .
--provenance=true is worth the flag. It records which builder produced each architecture’s image, which is the first question asked when the two behave differently.
2. Assert the stack inside the image, per architecture
The Dockerfile check that catches drift has to run in the built image, not in the build script:
"""selfcheck.py — run at image build time; the build fails if the stack drifts."""
import platform
import sys
EXPECTED = {"gdal": "3.9.2", "proj": "9.4.1", "geos": "3.12.1"}
TOLERANCE_M = 1e-6
def main() -> int:
from osgeo import gdal
import pyproj
import shapely
actual = {
"gdal": gdal.__version__.split("e")[0],
"proj": pyproj.proj_version_str,
"geos": shapely.geos_version_string.split("-")[0],
}
drift = {k: (v, actual[k]) for k, v in EXPECTED.items() if not actual[k].startswith(v)}
if drift:
print(f"FAIL {platform.machine()}: version drift {drift}", file=sys.stderr)
return 1
# A reference transform, compared against a value fixed in the repository.
tf = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:32618", always_xy=True)
e, n = tf.transform(-75.0941, 38.7870)
if abs(e - 492013.862455) > TOLERANCE_M or abs(n - 4293201.155596) > TOLERANCE_M:
print(f"FAIL {platform.machine()}: reference transform {e:.6f}, {n:.6f}", file=sys.stderr)
return 1
print(f"ok {platform.machine()}: {actual}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
A micrometre tolerance rather than exact equality is the deliberate choice. It is far tighter than any survey requirement and loose enough to absorb the last-bit libm differences, so the check fails on a genuine version drift and not on the architecture itself.
3. Record both digests, not the tag
A multi-arch tag is a manifest list pointing at two images with two digests. Recording the tag records neither:
docker buildx imagetools inspect ghcr.io/org/marine-pipeline:2026.03 --format \
'{{range .Manifest.Manifests}}{{.Platform.Architecture}} {{.Digest}}{{"\n"}}{{end}}'
# amd64 sha256:a41c...
# arm64 sha256:7fe0...
Both belong in the run manifest, alongside the architecture the job actually ran on. Without them, “we ran image 2026.03” does not identify the bytes that executed, which is the whole point of pinning by digest as pinning GDAL and PROJ versions sets out.
Verification and acceptance test
import subprocess
import pytest
IMAGE = "ghcr.io/org/marine-pipeline:2026.03"
REFERENCE = (492013.862455, 4293201.155596)
@pytest.mark.parametrize("arch", ["linux/amd64", "linux/arm64"])
def test_reference_transform_agrees_across_architectures(arch):
out = subprocess.run(
["docker", "run", "--rm", "--platform", arch, IMAGE, "python", "/selfcheck.py"],
capture_output=True, text=True, check=False)
assert out.returncode == 0, f"{arch}: {out.stderr.strip()}"
@pytest.mark.parametrize("arch", ["linux/amd64", "linux/arm64"])
def test_proj_grids_are_present(arch):
out = subprocess.run(
["docker", "run", "--rm", "--platform", arch, IMAGE,
"sh", "-c", "ls /usr/share/proj/*.tif | wc -l"],
capture_output=True, text=True, check=True)
assert int(out.stdout.strip()) > 0, f"{arch}: no PROJ transformation grids in the image"
The grid check is separate from the version check on purpose: a rebuild that changes the base image can drop the grids while every library version stays identical, and that failure changes coordinates by about a metre with no other symptom.
Is the second architecture worth building at all?
Multi-arch is not free: two builders, two sets of wheels to verify, two digests to track, and a CI matrix that doubles. The decision deserves the same arithmetic as any other, and for a marine pipeline it usually turns on three numbers.
The price difference. Typically twenty to forty per cent per vCPU-hour in favour of arm64 on the major clouds, before any spot discount. For a pipeline whose compute bill is dominated by a nightly gridding job, that is the headline.
Per-core performance on this workload. Geospatial work is memory-bandwidth-bound as often as it is compute-bound, and the two architectures differ in cache and memory behaviour. The honest figure is measured on the actual pipeline, not on a benchmark: a job that is thirty per cent cheaper per core and twenty per cent slower per core has saved twelve per cent, not thirty.
The verification cost, amortised. The self-check above is written once. The CI matrix runs on every build. For a pipeline deployed weekly the overhead is negligible; for one deployed a few times a year it can exceed the saving.
def arm_saving(price_ratio: float, throughput_ratio: float,
ci_minutes_extra: float, builds_per_month: int,
compute_hours_per_month: float, ci_cost_per_min: float,
compute_cost_per_hour: float) -> dict[str, float]:
"""Net monthly saving from adding an arm64 target. Negative means don't."""
compute_saving = compute_hours_per_month * compute_cost_per_hour * (
1.0 - price_ratio / throughput_ratio)
ci_cost = builds_per_month * ci_minutes_extra * ci_cost_per_min
return {"compute_saving": compute_saving, "ci_cost": ci_cost,
"net": compute_saving - ci_cost}
Dividing the price ratio by the throughput ratio is the step people skip. A twenty-five per cent cheaper core that delivers ninety per cent of the throughput saves about seventeen per cent, not twenty-five, and on a workload where the gap is larger the saving can vanish entirely.
Edge cases and gotchas
--platformondocker runuses emulation silently. Running an arm64 image on an amd64 host works through QEMU and is roughly ten times slower. A CI job that “tests both architectures” on one runner is testing emulation, which shares the libm of neither.- The same wheel tag can bundle different GDAL builds. A
manylinuxwheel bundles its own GDAL, and the bundled version can differ between architectures for the same package version. The runtime assertion catches it; readingrequirements.txtdoes not. - Mixed-architecture node pools need a nodeSelector or a tolerance for both. A Kubernetes Job that assumes one architecture will schedule onto the other and either fail to pull or run the image you did not test. Constrain the pool, or verify both, and prefer the second — the cost advantage of arm64 disappears if half the fleet cannot use it.
Related
- Pinning GDAL & PROJ Versions in Docker — the version contract this build has to hold across two architectures
- Reducing Geospatial Docker Image Size — the multi-stage build both architectures share
- Kubernetes Job Scheduling for Bathymetric Processing — where a mixed-architecture node pool becomes a scheduling constraint