Mac では動いたイメージなのに、なぜ本番サーバーでは exec format error で起動しないのか?
Apple Silicon の Mac で docker build してレジストリに push し、本番の EC2 で起動したら、ログに exec /usr/local/bin/app: exec format error の 1 行だけを残してコンテナが落ちた。手元では何度も動作確認したイメージだ。「コンテナはどこでも同じように動く」はずなのに、なぜ同じイメージが場所によって起動すらしないのか。
- コンテナはホストのカーネルと CPU をそのまま使うので、中のバイナリは CPU アーキテクチャ(arm64 / amd64)ごとに別物になる。合わない機械語をカーネルが実行できないと
exec format errorになる。 - 1 つのイメージ名に複数アーキテクチャを載せる仕組みが「イメージインデックス(マニフェストリスト)」で、pull するホストが自分の
os/architectureに合う中身を選ぶ。Mac で普通に build するとアーキテクチャは arm64 だけになる。 - Mac で amd64 イメージが動くのはエミュレーションが仕込まれているからで、本番サーバーには通常それが無い。
docker buildx build --platform linux/amd64,linux/arm64で両方入りのイメージを作るか、本番に合わせて--platformを明示する。
コンテナは CPU までは隠してくれない
コンテナは VM と違い、ゲスト OS を持たずにホストの Linux カーネル上で直接プロセスを動かす。名前空間と cgroup で「見える範囲」と「使える量」を区切るだけなので、プロセスが実行する機械語はホストの CPU がそのまま解釈する。つまりコンテナが隠すのはファイルシステムやプロセスの見え方までで、CPU の命令セットの違いは隠さない。
ここで出てくるのが CPU アーキテクチャ、つまり CPU が理解する命令セットの種類だ。Intel / AMD のサーバーは amd64(x86_64 とも呼ぶ)、Apple Silicon の Mac や AWS Graviton は arm64(aarch64)である。Go のバイナリや Ruby の C 拡張(nokogiri など)は、コンパイル時にどちらか一方の機械語として出力される。
Linux の実行ファイル形式 ELF は、ファイル先頭のヘッダに「この機械語はどのアーキテクチャ向けか」を記録している。カーネルは execve でプログラムを起動するときにこのヘッダを確かめ、自分の CPU 向けでなければ読み込みを拒否してエラー ENOEXEC を返す。このエラーの説明文字列が「Exec format error」だ。冒頭のログは、コンテナランタイムがエントリポイントを起動しようとしてカーネルに断られたことを示している。
1 つのタグに複数のイメージが入っている
それなら node:22 や alpine のような公式イメージは、なぜ Mac でもサーバーでも同じタグで動くのか。答えは、そのタグが指しているのが 1 つのイメージではなく「目次」だからだ。イメージインデックス(Docker の用語ではマニフェストリスト)とは、アーキテクチャごとのイメージマニフェストへの参照を並べた JSON で、OCI Image Spec では各要素に platform.os・platform.architecture(必須)と platform.variant(任意)を持つと定められている。
registry: myapp:1.0
┌──────────────────────────────────┐
│ image index (manifest list) │
│ ├ linux/amd64 ─▶ manifest A │
│ └ linux/arm64/v8 ─▶ manifest B │
└──────────────────────────────────┘
▲ ▲
pull │ │ pull
┌──────┴──────┐ ┌──────┴──────┐
│ EC2 (amd64) │ │ Mac (arm64) │
│ → A を取得 │ │ → B を取得 │
└─────────────┘ └─────────────┘
Mac で普通に build すると…
myapp:1.0 ─▶ manifest B (arm64) のみ
EC2 が pull → B を実行 → ENOEXEC
docker pull はまずこの目次を受け取り、ホストのアーキテクチャに合う要素を選んで中身をダウンロードする。Docker のドキュメントは「ホストのアーキテクチャに基づいて正しい variant を自動で選ぶ」と説明している。合う要素が無ければ no matching manifest for linux/amd64 in the manifest list entries のようなエラーで pull 自体が失敗する。
問題は、目次を持たない単一アーキテクチャのイメージだ。--platform を付けずに docker build すると、イメージはビルドしたマシンのプラットフォーム向けに作られる。Apple Silicon なら linux/arm64 だけになる。これを amd64 のサーバーで pull すると、選ぶ候補が 1 つしか無いので Docker は警告を出しつつそのまま取得し、起動した瞬間にカーネルに拒否される。pull は成功し、エラーは起動時にしか出ないので、デプロイの段階では気づきにくい。
イメージインデックスは、同じ本の日本語版と英語版を 1 つの ISBN でまとめた書店の棚札だ。客が「この本を」と頼むと、書店員は客の読める言語の版を棚札から選んで渡す。Mac で普通に build したイメージは、日本語版しか刷っていないのに同じ ISBN で出荷した状態で、英語しか読めない客の手にも日本語版がそのまま届き、開いた瞬間に読めないと分かる。
なぜ Mac では amd64 イメージが動いてしまうのか
逆向き、つまり amd64 専用のイメージを Mac で動かすと、多くの場合は普通に動く。ここから「アーキテクチャの違いは Docker が吸収してくれる」と考えたくなる。しかし実際に吸収しているのは Docker Desktop が用意したエミュレーション環境で、Docker 一般の機能ではない。
その仕組みが Linux カーネルの binfmt_misc だ。これはファイル先頭のバイト列(マジックナンバー)のパターンと、それに一致したときに代わりに起動する「インタプリタ」を登録しておく機能で、登録先は /proc/sys/fs/binfmt_misc/register である。amd64 の ELF ヘッダのパターンに CPU エミュレータの QEMU を登録しておくと、カーネルは amd64 バイナリを直接実行する代わりに QEMU に渡し、QEMU が命令を 1 つずつ arm64 に翻訳しながら実行する。Docker Desktop にはこれに加えて Apple の Rosetta を使って amd64 を高速に動かす設定もある。
本番の素の Linux サーバーには、この登録が通常は入っていない。だから「Mac は arm64 も amd64 も動く」「サーバーは自分のアーキテクチャしか動かない」という非対称が生まれる。手元の動作確認が通ったことは、本番と同じアーキテクチャで動いた証拠にならない。なお QEMU によるエミュレーションは、Docker のドキュメントが「ネイティブより大幅に遅くなりうる」と注意しているとおり、コンパイルや圧縮のような計算の重い処理で特に遅い。
--platform で何が変わるか
対策は、作るイメージのプラットフォームを明示することだ。--platform にカンマ区切りで複数指定すると、BuildKit はプラットフォームごとにビルドしてイメージインデックスにまとめる。複数プラットフォームのイメージはローカルの従来型イメージストアに載せられないため、レジストリへ直接 --push するのが基本になる(Docker Desktop と Docker Engine 29.0 以降は既定の containerd イメージストアがインデックスを扱える)。
# 本番が amd64 だけなら、まずはこれで十分 docker build --platform linux/amd64 -t myapp:1.0 . # amd64 と arm64 の両方を 1 つのタグに載せる docker buildx build --platform linux/amd64,linux/arm64 \ -t registry.example.com/myapp:1.0 --push .
Mac で amd64 向けをビルドすると、Dockerfile の RUN は全部エミュレーション上で走るので遅い。Go のようにクロスコンパイルできる言語なら、ビルド用ステージだけをビルドマシンのネイティブ環境で動かし、出力するバイナリの種類だけを切り替えられる。BuildKit が自動で定義するビルド引数 BUILDPLATFORM(ビルドするマシン)と TARGETOS / TARGETARCH(作りたいイメージ)を使う。
FROM --platform=$BUILDPLATFORM golang:1.23 AS build ARG TARGETOS TARGETARCH WORKDIR /src COPY . . RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /app . FROM gcr.io/distroless/static COPY --from=build /app /app ENTRYPOINT ["/app"]
FROM --platform=$BUILDPLATFORM を外すと、ビルドステージ自体が対象アーキテクチャのイメージで動くため、arm64 の Mac で amd64 をビルドするときにコンパイラまでエミュレーションで走ることになる。最終ステージには --platform を書かないので、ターゲットごとのベースイメージが使われる。Ruby のネイティブ拡張のようにクロスコンパイルしにくいものは、エミュレーションを我慢するか、CI で各アーキテクチャのネイティブマシンにビルドを任せることになる。
典型は「チームで自分だけ Apple Silicon の Mac から手元で build & push した」「ECS や EKS のノードを安さで Graviton(arm64)に替えたら、amd64 だけで配っていた社内イメージが起動しない」の 2 パターンだ。Kubernetes では Pod が CrashLoopBackOff になり、kubectl logs に exec format error の 1 行だけが出る。アプリのバグを疑う前に、イメージの対応プラットフォームとノードのアーキテクチャ(kubectl get node -L kubernetes.io/arch)を突き合わせると早い。予防策としては、手元からの push をやめて CI でビルドし、--platform をワークフローに明記しておくのが確実性が高い。
Docker Desktop が入った Apple Silicon の Mac で、同じタグの中に複数アーキテクチャが入っていること、そして --platform で中身が入れ替わることを確かめる。
# 1. alpine の目次(イメージインデックス)を見る
docker buildx imagetools inspect alpine:3.20 | grep Platform
# 2. 既定(ホストに合う版)と amd64 版で uname -m を比べる
docker run --rm alpine:3.20 uname -m
docker run --rm --platform linux/amd64 alpine:3.20 uname -m
# 3. 取得したイメージの中身のアーキテクチャを確認する
docker image inspect alpine:3.20 --format '{{.Os}}/{{.Architecture}}'
1 では linux/amd64・linux/arm64/v8 など複数の Platform: 行が並ぶはずだ。2 では 1 回目が aarch64、2 回目が x86_64 と表示され、同じタグから別の機械語のイメージが選ばれたこと、そして amd64 版がエミュレーションで動いたことが分かる。amd64 の Linux マシンがあれば、Mac で --platform 無しに build したイメージを持っていって起動すると exec format error を再現できる。
- コンテナにすれば CPU の違いも吸収される — コンテナはホストのカーネルと CPU を直接使う。吸収されているように見えるのは、Docker Desktop などが binfmt_misc でエミュレータを登録している環境に限られる。
- pull が成功したのだからそのホストで動くイメージだ — 単一アーキテクチャのイメージは、ホストと合わなくても警告付きで pull できてしまう。失敗するのは起動時のカーネルの
execveだ。 - exec format error は常にアーキテクチャ違いが原因だ — 最も多い原因ではあるが、シェルスクリプトの先頭の
#!行が欠けている・壊れている場合も、カーネルが形式を認識できず同じエラーになる。
- CPU アーキテクチャ
- CPU が理解する命令セットの種類。サーバーで主に使われるのは amd64(x86_64)と arm64(aarch64)。
- ELF
- Linux の実行ファイル形式。ヘッダに対象アーキテクチャが記録され、カーネルが起動時に確認する。
- exec format error(ENOEXEC)
- カーネルが実行ファイルの形式を認識できず起動を拒否したときのエラー。
- イメージインデックス(マニフェストリスト)
- 1 つのタグの下に、os / architecture ごとのイメージマニフェストへの参照を並べた目次。
- binfmt_misc
- ファイル先頭のバイト列に応じて代わりのインタプリタ(QEMU など)を起動させる Linux カーネルの機能。
- BUILDPLATFORM / TARGETPLATFORM
- BuildKit が自動定義するビルド引数。前者はビルドするマシン、後者は作りたいイメージのプラットフォーム。
- Docker Docs: Multi-platform builds — QEMU・複数ネイティブノード・クロスコンパイルの 3 戦略と Go の Dockerfile 例が公式に整理されている。
- OCI Image Spec: Image Index —
manifests[].platformの各フィールドと、複数一致したときは最初の要素を使うという選択規則の一次仕様。 - Linux Kernel Docs: Kernel Support for miscellaneous Binary Formats (binfmt_misc) — Mac で amd64 イメージが動く裏側の、マジックナンバー照合とインタプリタ登録の書式。
- Docker Docs: docker buildx imagetools inspect — レジストリ上のイメージがどのプラットフォームを持つかを確かめるコマンドの使い方と出力例。