Dock Stay
手元では動くのに CI で動かない
動かない時

手元では動くのに CI で動かない

「ローカルでは動くのに CI で落ちる」の典型パターンを、アーキテクチャ差・ベースイメージ差・環境変数・タイムゾーンの 4 軸で切り分けます。

手元では動くのに CI で動かない diagram

症状

ローカル(Mac や Windows の Docker Desktop)でビルド・実行すると正常に動くが、GitHub Actions などの CI 環境では以下のようなエラーで落ちる。

  • exec format error
  • qemu: 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.19alpine:3.20 に上げるタイミングで glibc 互換ライブラリの仕様が変わることがあり、ネイティブモジュールが動かなくなるパターンが定期的に発生します。

原因 3: 環境変数の未注入

ローカルでは .env ファイルや docker composeenvironment: で値が入っているが、CI では GitHub Secrets の設定漏れ、Actions の env: 渡し忘れ、docker run-e 指定忘れで値が入らないケース。

.env ファイルが .dockerignore で除外されていてイメージには焼き込まれないのに、ローカル実行時だけ Compose が読み込んでいる、という状況だとローカルとイメージで挙動が食い違います。

原因 4: タイムゾーン

ローカル Docker Desktop はホスト OS のタイムゾーン(JST 等)を参照することが多いですが、素のベースイメージは UTC です。

new Date().toISOString() 系のテストや、日付を跨ぐ境界条件のテストが CI でだけ失敗することがあります。

また、alpinetzdata パッケージが入っていないため、アプリが TZ=Asia/Tokyo を指定しても解決できず UTC のままになる、というパターンもよく起きます。

確認コマンド

まずローカルと CI の両方で以下を実行し、差分を並べて比べます。

bash
# 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/TokyoRUN apt-get install -y tzdata をセットで。alpine なら apk add --no-cache tzdata を忘れずに。テストは原則 UTC 固定で書くのが無難です