開発者を悩ませる悪夢:「exec format error」エラー
Apple Silicon(Mシリーズチップ)搭載のMacBookを使っているエンジニアなら、一度はこの洗礼を受けたことがあるはずです。ローカル環境では問題なくサクサク動作し、docker buildからDocker Hubへのプッシュまで何一つ引っかかることなく完了したとします。
しかし悲劇は、UbuntuのVPS(DigitalOceanの2 vCPU DropletやAWSのx86ベースEC2など)にSSH接続した瞬間に始まります。意気揚々とイメージをpullし、docker runを実行した途端、コンテナは即座に異常終了して素っ気ないエラーログを吐き出します。
standard_init_linux.go:228: exec user process caused: exec format error
慌ててソースコードを見直したり、スクリプトにchmod 777を実行してみたり、Dockerfileの構文エラーを隅々まで探したりしても、コードには何の間違いもありません。真の原因は、2台のマシン間における「CPUアーキテクチャの違い」にあります。
原因の解明:舞台裏で何が起きているのか?
デフォルト設定では、docker buildコマンドは作業中のマシン自体のCPU命令セットに合わせてアプリケーションをビルドします。
- Apple Silicon Mac(M1〜M4):
linux/arm64アーキテクチャを使用 - 一般的なクラウドVPS(Intel Xeon、AMD EPYCなど):
linux/amd64(x86_64)アーキテクチャで稼働
ARMチップ向けにコンパイルされたバイナリは、x86_64のCPUでは理解できません。サーバー上のLinuxカーネルがARMバイナリを実行ファイルとしてメモリに読み込もうとしても、命令セットが異なるため処理できず、その結果カーネルはexec format errorをスローしてコンテナを即座にクラッシュさせてしまうのです。
2つの一時的な回避策とそのデメリット
1. CI/CD Runner経由でターゲットサーバー上で直接ビルドする
ローカルでのビルドをやめ、Gitへプッシュした後にx86サーバー上のGitHub ActionsやGitLab Runnerにビルドを任せる方法です。この方法で環境の不一致は解消されますが、相応のコストがかかります。
例えば本番稼働中の2 vCPU VPSでNode.jsやGolangのビルドパイプラインを実行すると、CPU使用率が100%に張り付き、サーバーの動作が重くなってビルドに7〜10分もかかってしまいます。さらに、将来的にコスト削減(約20%カット)のためにAWS Graviton(ARM64)へ移行することになった場合、CI/CDスクリプトを最初から書き直さなければならなくなります。
2. –platform フラグでアーキテクチャを強制指定する
Docker Desktopでは、QEMUエミュレータを利用してターゲットアーキテクチャを指定してビルドできます。
docker build --platform linux/amd64 -t my-app:latest .
これでMacがx86環境をエミュレートし、サーバーが必要とするイメージを生成してくれます。しかし、これもあくまで対症療法に過ぎません。
このコマンドでは1つのアーキテクチャ向けのイメージしか生成されません。Docker Hubにプッシュすると、AMD64版が既存のARM64版を上書きしてしまいます。チームの別の開発者がそのイメージをMacにpullしてローカル実行しようとすると、QEMUエミュレーション層を介するためパフォーマンスが極端に低下し、不可解なキャッシュエラーが発生することもあります。
根本的なベストプラクティス:Docker Buildxの活用
Docker Buildxは、BuildKitの機能をフル活用できる次世代CLIプラグインです。1つのコマンドを実行するだけで、linux/amd64とlinux/arm64の両方に対応したイメージを同時にビルドできます。
最大の特徴は、Dockerがこれら複数の子イメージを1つのManifest List(共通のタグを持つOCIインデックスリスト)としてまとめる点です。Mac上でイメージをpullするとDockerデーモンが自動的にARM64版を選択し、Linux x86サーバーで同じタグをpullすると自動的にAMD64版を取得します。どの環境でもネイティブ速度でスムーズに動作します。
ステップ1:環境の準備とQEMUのセットアップ
macOSやWindowsでDocker Desktopを利用している場合、Buildxは標準で組み込まれています。次のコマンドで確認してみましょう。
docker buildx version
ビルド用RunnerとしてLinuxサーバー(Ubuntu/Debian)をセットアップする場合は、異なるアーキテクチャのエミュレーションを処理できるよう、QEMUのbinfmtモジュールを登録しておきます。
docker run --privileged --rm tonistiigi/binfmt --install all
ステップ2:新しいBuilderインスタンスを作成する
従来のDockerデーモンに紐づくデフォルトのbuilderは、リモートレジストリへのManifest Listの直接出力をサポートしていません。そのため、containerドライバーを使用した専用のbuilderを作成する必要があります。
# multi-builderという名前で新しいbuilderを作成
docker buildx create --name multi-builder --driver docker-container --bootstrap --use
# サポートされているプラットフォーム一覧を確認
docker buildx inspect --bootstrap
ターミナル上に、サポートされているプラットフォームの一覧(linux/amd64, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/arm/v7, linux/arm/v6など)が出力されます。
ステップ3:Dockerfileをマルチプラットフォーム向けに最適化する
Dockerfileで使用するベースイメージがマルチアーキテクチャに対応している必要があります。幸いなことに、node:alpine、python:slim、golang:alpineなどの公式イメージの大半はすでに対応済みです。
# Node.jsアプリケーション向けの標準的なDockerfileの例
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
ステップ4:ビルドしてレジストリへ直接プッシュする
ローカルのDockerストレージは、同一タグ配下に複数アーキテクチャのManifest Listを保持する仕組みに未対応です。そのため、Buildxでは--pushフラグを指定してリモートレジストリへ直接プッシュする必要があります。
# 事前にレジストリへログイン
docker login
# amd64とarm64を同時にビルドし、即座にプッシュ
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t username/my-app:1.0.0 \
-t username/my-app:latest \
--push .
実践における注意点: QEMU経由で異なるアーキテクチャをエミュレートするため、ビルド時間は通常の約1.5〜2倍長くなることがあります。処理が完了するまでしばらくお待ちください。
ステップ5:Docker Hub上のManifestを確認する
プッシュが完了したら、実際に正しく登録されているか以下のコマンドでイメージの構造を検証します。
docker buildx imagetools inspect username/my-app:latest
CLIからManifest Listを含むJSONデータが返されます。このJSON構造を見やすく整形して確認したい場合は、toolcraft.app/ja/tools/developer/json-formatter などのオンラインJSONフォーマッターにコピーするのが便利です。オブジェクトを展開すると、architecture: amd64 と architecture: arm64 の2つのブロックが明確に分かれて定義されていることが確認できます。
これで、開発用のMacでも本番用のLinuxサーバーでも、同じコマンドを実行するだけで済みます。
docker run -d -p 3000:3000 username/my-app:latest
それぞれの環境がハードウェアに完全に適合したバイナリを自動的に取得・実行するため、あの厄介な「exec format error」は完全に過去のものとなります。