Tech Learning Daily

2026-11-03 (Tue) — 第 109 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Container 🌿 基礎 ⏱ 約 7 分

Mac では動いたイメージなのに、なぜ本番サーバーでは exec format error で起動しないのか?

Apple Silicon の Mac で docker build してレジストリに push し、本番の EC2 で起動したら、ログに exec /usr/local/bin/app: exec format error の 1 行だけを残してコンテナが落ちた。手元では何度も動作確認したイメージだ。「コンテナはどこでも同じように動く」はずなのに、なぜ同じイメージが場所によって起動すらしないのか。

🎯 3 行まとめ
  • コンテナはホストのカーネルと 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 をワークフローに明記しておくのが確実性が高い。

⌨️ 手を動かす(5 分)

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 が自動定義するビルド引数。前者はビルドするマシン、後者は作りたいイメージのプラットフォーム。
🔗 もっと深く
最近の記事

同じサーバーに 200 バイト送るだけなのに、なぜ 2 回に分けて write すると 40ms 遅れるのか?

Nagle アルゴリズムと delayed ACK が互いを待ち合うと、2 回に分けた小さな write が Linux では約 40ms 止まる。仕組みと TCP_NODELAY・write の集約による対策、言語ごとの既定値を解説する。
Network🌿 基礎⏱ 約 7 分2026-11-02

終了時刻から開始時刻を引いただけなのに、なぜ処理時間がマイナスになるのか?

OS には現在時刻を表す壁時計(CLOCK_REALTIME)と経過時間専用のモノトニッククロックがあり、壁時計は NTP のステップ補正や手動設定で後ろへ飛ぶため時刻の引き算は負になりうる。Cloudflare のうるう秒障害、Go 1.9 の透過的モノトニック時刻と UTC()・JSON 化で剥がれる落とし穴、Ruby/JS/Python の使い分けを解説。
Lang🌿 基礎⏱ 約 7 分2026-11-01

同じ長いプロンプトを送っただけなのに、なぜ 2 回目の LLM 呼び出しは速く安くなるのか?

LLM は因果マスクのおかげで過去トークンの Key と Value を保存して使い回せる(KV キャッシュ)。プロンプトキャッシュはそれをリクエストをまたいで保存し、先頭から完全一致した範囲だけを再利用する。Anthropic API の cache_control と寿命・料金倍率、動的な値を後ろに置く設計を解説。
AI🌿 基礎⏱ 約 7 分2026-10-31

http:// と打ったのに、なぜブラウザはサーバーに聞く前に https:// へ書き換えるのか?

HTTP→HTTPS のリダイレクトは平文の初回リクエストを SSL ストリッピングで横取りされうる。HSTS はブラウザに HTTPS 専用と記憶させ送信前に URL を書き換えさせる。max-age の解除の難しさ、Rails force_ssl 既定の includeSubDomains、プリロードリストの取り消しの重さを解説。
Security🌿 基礎⏱ 約 7 分2026-10-30