手元では動くのに CI で動かない
「ローカルでは動くのに CI で落ちる」の典型パターンを、アーキテクチャ差・ベースイメージ差・環境変数・タイムゾーンの 4 軸で切り分けます。
症状
ローカル(Mac や Windows の Docker Desktop)でビルド・実行すると正常に動くが、GitHub Actions などの CI 環境では以下のようなエラーで落ちる。
exec format errorqemu: uncaught target signal 11- ビルドは通るがテスト実行時にだけ失敗する
- アプリが起動するが時刻ログが数時間ずれている
- 環境変数が読めず設定値が
undefined
原因 1: CPU アーキテクチャの違い(arm64 vs amd64)
Apple Silicon Mac(M1/M2/M3)の Docker Desktop はデフォルトで linux/arm64 のイメージをビルドします。
一方、多くの CI ランナー(GitHub Actions の ubuntu-latest など)は linux/amd64 です。
Dockerfile 内で FROM node:20 のようにアーキ指定なしのタグを参照していると、ローカルでは arm64 バリアント、CI では amd64 バリアントが引かれ、同じタグでも中身のバイナリが別物 になります。
ネイティブ拡張を含む npm パッケージ(better-sqlite3, sharp, bcrypt 等)や Python wheel、Go のクロスビルドで不整合が出やすい領域です。
原因 2: ベースイメージのバージョン差
FROM node:20 のようなフローティングタグは、プル時点で最新のマイナー・パッチバージョンが引かれます。
ローカルで数日前にプルしたキャッシュと、CI で今引いた最新版で、glibc のバージョンや同梱コマンドのパスが異なる場合があります。
特にアルパインベース(node:20-alpine)はベースを alpine:3.19 → alpine:3.20 に上げるタイミングで glibc 互換ライブラリの仕様が変わることがあり、ネイティブモジュールが動かなくなるパターンが定期的に発生します。
原因 3: 環境変数の未注入
ローカルでは .env ファイルや docker compose の environment: で値が入っているが、CI では GitHub Secrets の設定漏れ、Actions の env: 渡し忘れ、docker run の -e 指定忘れで値が入らないケース。
.env ファイルが .dockerignore で除外されていてイメージには焼き込まれないのに、ローカル実行時だけ Compose が読み込んでいる、という状況だとローカルとイメージで挙動が食い違います。
原因 4: タイムゾーン
ローカル Docker Desktop はホスト OS のタイムゾーン(JST 等)を参照することが多いですが、素のベースイメージは UTC です。
new Date().toISOString() 系のテストや、日付を跨ぐ境界条件のテストが CI でだけ失敗することがあります。
また、alpine は tzdata パッケージが入っていないため、アプリが TZ=Asia/Tokyo を指定しても解決できず UTC のままになる、というパターンもよく起きます。
確認コマンド
まずローカルと CI の両方で以下を実行し、差分を並べて比べます。
# CPU アーキ / OS / カーネル
docker run --rm <image> uname -a
docker image inspect <image> --format '{{.Architecture}} {{.Os}}'
# glibc / musl バージョン
docker run --rm <image> ldd --version || true
# ベースイメージの digest(floating tag の実体を確認)
docker image inspect <image> --format '{{index .RepoDigests 0}}'
# 環境変数の確認(コンテナ内から見える値)
docker run --rm <image> env | sort
# タイムゾーン
docker run --rm <image> date
docker run --rm <image> sh -c 'echo $TZ; cat /etc/timezone 2>/dev/null || true'解決策
- ベースイメージは digest で固定する:
FROM node:20.11.1-bookworm-slim@sha256:...まで書けば floating な差分はなくなります。最低でもnode:20.11.1-bookworm-slimのようにマイナー+ディストリビューションまでは固定します --platformを明示する: マルチアーキ配布しないならローカルも CI もFROM --platform=linux/amd64 node:20-bookworm-slimに揃える。マルチアーキが必要ならbuildxでビルド時に両方作る(trouble-arm64-vs-amd64参照)- 環境変数は Compose と CI で一元管理: ローカルは
docker compose --env-file .env、CI は GitHub Secrets →env:→ コンテナ-eと明示的に流す。.env.exampleをリポジトリに置き、必須変数の欠落を起動時にアプリ側で検出する - タイムゾーン:
debian/ubuntuベースならENV TZ=Asia/TokyoとRUN apt-get install -y tzdataをセットで。alpineならapk add --no-cache tzdataを忘れずに。テストは原則 UTC 固定で書くのが無難です
