An M-series Mac builds arm64 images natively — and every cheap Fargate task,
Lambda, or EC2 instance you'll actually deploy to runs amd64. This is the first place that
mismatch bites, and it costs nothing to hit it now: docker buildx cross-compiles for the
target architecture right on your laptop, under QEMU emulation, with no cloud account involved at all.
The second half of the lesson is a habit that has nothing to do with architecture — a build stage full of
compilers and headers should never be the image you ship.
Nothing leaves your machine. This guide builds two local Docker images — one the naive way, one the way
you'll actually ship — so you can compare them directly. Pick a build below; the readout recomputes the
way every other cloud guide's cost readout does, except here there's no dollar cost to show — the thing
worth watching is image size, build time, and layer count. The numbers are illustrative (approx.;
your docker images will differ by a few tens of MB), the ratio is not.
This guide never touches AWS and never spends a cent, so the usual first line — "cloud-02's zero-spend budget alarm exists" — does not apply yet: cloud-02 is the next cloud item and the first thing to do before any item that does touch AWS. What you do need on the Mac:
docker buildx version printing something (buildx
ships built in on any recent install).0. A one-endpoint FastAPI service to have something real to build — two files:
# requirements.txt (pin exact versions in a real project: pip freeze > requirements.txt)
fastapi>=0.115
uvicorn[standard]>=0.30
# app/main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"ok": True}
1. .dockerignore — keep the build context small and keep secrets/tests out of any
layer:
# .dockerignore
.venv
__pycache__/
*.pyc
.git
.env
tests/
.pytest_cache/
.mypy_cache/
*.md
2. The multi-stage Dockerfile — a builder stage with the compiler toolchain some
Python packages need to build from source, and a slim runtime stage that copies out only the installed
packages and the app:
# syntax=docker/dockerfile:1 # ---- builder: gcc + headers so any source-only wheel can compile -- none of this ships ---- FROM python:3.12-slim AS builder RUN apt-get update && apt-get install -y --no-install-recommends build-essential \ && rm -rf /var/lib/apt/lists/* WORKDIR /build COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt # ---- runtime: only the installed packages and the app code land here ---- FROM python:3.12-slim AS runtime RUN useradd --create-home --uid 1000 appuser WORKDIR /app COPY --from=builder /install /usr/local COPY app/ ./app/ USER appuser EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)" CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
3. The naive comparison point, Dockerfile.single — one FROM, so the
toolchain, pip's cache and everything COPY . . drags in all stay in the image that ships:
# Dockerfile.single -- do NOT ship this; it exists to be measured against
FROM python:3.12-slim
RUN apt-get update && apt-get install -y build-essential
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
4. Build both ways and compare — the single stage, the real one, then the cross-build:
# the naive comparison point docker build -t myservice:single -f Dockerfile.single . # the multi-stage build, native arch docker build -t myservice:native . # one-time: a builder instance that can actually target another platform docker buildx create --name xbuilder --use docker buildx inspect --bootstrap # cross-build for the x86_64 hosts Fargate/ECS/Lambda actually run on. # --load puts the result in the local image store; it only works for ONE platform -- # a multi-platform build (--platform linux/amd64,linux/arm64) needs --push to a registry instead. docker buildx build --platform linux/amd64 -t myservice:amd64 --load .
5. check.sh — run this against myservice:amd64 before you trust any of
the numbers above:
#!/usr/bin/env bash
set -euo pipefail
IMAGE="${1:-myservice:amd64}"
SIZE_BYTES=$(docker image inspect "$IMAGE" --format='{{.Size}}')
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
echo "image size: ${SIZE_MB} MB"
[ "$SIZE_MB" -lt 250 ] || { echo "FAIL: image is ${SIZE_MB} MB, want < 250 MB"; exit 1; }
USR=$(docker image inspect "$IMAGE" --format='{{.Config.User}}')
[ -n "$USR" ] && [ "$USR" != "root" ] || { echo "FAIL: image has no non-root USER set"; exit 1; }
HC=$(docker image inspect "$IMAGE" --format='{{json .Config.Healthcheck}}')
[ "$HC" != "null" ] || { echo "FAIL: no HEALTHCHECK set"; exit 1; }
ARCH=$(docker run --rm --platform linux/amd64 "$IMAGE" python -c "import platform; print(platform.machine())")
[ "$ARCH" = "x86_64" ] || { echo "FAIL: expected x86_64 under emulation, got ${ARCH}"; exit 1; }
echo "OK: ${SIZE_MB} MB, user ${USR}, healthcheck present, arch ${ARCH}"
bash check.sh myservice:amd64 prints OK: <N> MB, user appuser, healthcheck
present, arch x86_64 — that last field is the proof the cross-build actually produced an x86
image, not just an x86 tag on an arm64 build.docker image inspect myservice:amd64 --format='{{.Architecture}}' prints
amd64 (compare against myservice:native, which prints arm64 on an
M-series Mac).docker images shows myservice:single at roughly 450 MB and
myservice:amd64/myservice:native under 250 MB — the builder stage's ~180
MB of compiler toolchain never reached either final image.Nothing here costs money to leave running, but disk space isn't free and a stray buildx builder is easy to forget:
# remove the images this guide built docker rmi myservice:single myservice:native myservice:amd64 # remove the cross-platform builder instance docker buildx rm xbuilder # reclaim any dangling layers left behind by the builds above docker image prune -f
After teardown: nothing remains anywhere — no AWS resource was ever created, so there is nothing to check in Cost Explorer. The only thing teardown reclaims is local disk space.
This is self-attestation — the site cannot see your Docker daemon, so checking the box and pressing the button is you telling The Path you actually ran it.
--platform flag
and a buildx builder instance, not a different Mac. Both lessons matter together: the ECR
push in a later cloud item will refuse an arm64 image on purpose, and the smaller runtime image is what
makes that push and every cold start after it faster.