「ローカルでは動いてたんで大丈夫です!」
そう自信満々に言い残して金曜日の夕方にリリースを完了し、意気揚々と居酒屋へ繰り出した。しかし深夜2時、ビールで回った頭を叩き起こしたのは、容赦なく鳴り響くPagerDutyのアラートだった。冷や汗でパジャマを濡らしながら、震える手でロールバックボタンを連打する――。
あの、心臓が握り潰されるような絶望を、あなたには味わってほしくない。
「Dockerを使っているから、どこでも同じように動くはず」
そんな甘い幻想を抱いていた時期が、私にもあった。だが、Dockerは魔法の銀の弾丸ではない。本質を理解せず雑に扱うと、ローカル(特にApple Silicon Mac)と本番環境(Linux/x86_64)のギャップに足元をすくわれ、いとも簡単にシステムは沈む。
この記事では、私が実際のプロジェクトや個人開発で血を流しながら踏み抜いた**「Docker環境差異の致命的な罠」**と、それを力技ではなく、エレガントに解決するために行き着いたDocker Composeのガチ実践テクニックを共有する。
罠1:Apple Siliconがもたらした「Exec format error」と、安易な解決策の罠
M1/M2/M3 Mac(ARM64)の普及に伴い、開発環境と本番環境(一般的にはIntel/AMDのx86_64)の「CPUアーキテクチャの乖離」が深刻な問題として浮上してきた。
ローカルのMacで完璧に動いていたコンテナを、意気揚々と本番のLinuxサーバーにデプロイした直後、コンテナが再起動を繰り返し、ログに以下の一行が刻まれる。
standard_init_linux.go:228: exec user process caused: exec format error
原因は、ローカル環境(ARM64)向けにビルドされたイメージを、異なるアーキテクチャ(x86_64)の本番環境で実行しようとしたことによる。
思考停止の platform: linux/amd64 が開発体験(DX)を破壊する
ネットの技術記事を検索すると、よくこんな「解決策」が書かれている。
「
docker-compose.ymlにplatform: linux/amd64を書けば解決します!」
確かに、これで動く。Apple Silicon Mac上でx86_64のシミュレーション(Rosetta 2 / QEMU)が走り、本番と同じアーキテクチャで動作する。
しかし、これは罠だ。
この設定を強制すると、ローカルでのビルドや実行速度が極端に遅くなる。パッケージのインストール(pip install や npm install)に通常の数倍から十倍近い時間がかかり、開発時のホットリロードすらもたつくようになる。開発の生産性を犠牲にして環境を合わせるのは、本末転倒だ。
プロの実践アプローチ:マルチステージビルドと環境の分離
正しいアプローチは、**「開発環境ではローカルのネイティブアーキテクチャ(ARM64)で動かし、本番用のイメージはCI/CDパイプラインで本番用(amd64)にビルドする」**設計にすることだ。
どうしてもローカルで本番と同じアーキテクチャを検証したい場合のみ、オーバーライドファイル(docker-compose.override.yml)や環境変数を使って制御する。
1. 基本となる docker-compose.yml(開発用:ネイティブで高速動作)
version: '3.8'
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/mydb
depends_on:
db:
condition: service_healthy
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_DB=mydb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
2. 本番環境や検証環境用:docker-compose.prod.yml(アーキテクチャを明示)
本番環境や、ローカルで本番同様の挙動を厳密にテストしたい場合は、設定ファイルを重ね合わせる(マージする)。
# docker-compose.prod.yml
services:
web:
platform: linux/amd64
db:
platform: linux/amd64
実行時は、以下のようにファイルを複数指定して立ち上げる。
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
これにより、普段の開発(DX)の快適さを維持しつつ、本番環境との一貫性を担保することができる。
罠2:Permission Deniedと、セキュリティを犠牲にしないUID/GIDの動的解決
次に開発者を苦しめるのが、ホスト(ローカルPC)とコンテナ間での**「ファイルのパーミッション問題」**だ。
- コンテナ内で生成されたログやキャッシュファイルが、ホスト側(Mac/Linux)から編集・削除できない。
- 逆に、ホスト側で編集したソースコードを、コンテナ内の非rootユーザー(
nodeやpythonプロセス)が読み込めず、Permission Deniedでクラッシュする。
ここで「面倒だから」と、本番サーバーのディレクトリ権限を chmod -R 777 に変更するような悪魔の誘惑に負けてはならない。それはセキュリティ監査で即座にレッドカードを突きつけられる、脆弱性だらけのサーバーを生み出す原因になる。
原因:ホストとコンテナ内のユーザーID(UID)の不一致
MacのDocker Desktopは、仮想マシンを挟むことでバインドマウント時のUIDをある程度自動でマッピングしてくれる。しかし、Linuxデスクトップ環境や、CI/CD用のセルフホストRunner、そして本番のLinuxサーバー環境では、この問題がダイレクトに牙を剥く。
コンテナ内のデフォルトユーザー(通常は root、UID: 0)が書き込んだファイルは、ホスト側からは root 所有に見えるため、一般ユーザーであるあなた(UID: 1000 など)が触れなくなるのだ。
解決策:.env と user 指定によるスマートなUID/GIDの注入
この問題は、コンテナ起動時にホストのUID/GIDを動的に注入し、コンテナ内のプロセスをその権限で実行することで完全に解決できる。
まず、プロジェクトのルートに以下のような .env ファイルを作成する(または、起動スクリプトで自動生成する)。
# .env
UID=1000
GID=1000
Tips: Linux環境であれば、
.bashrcなどにexport UID=$(id -u)およびexport GID=$(id -g)を設定しておくと、Docker Composeが自動的にシェルの環境変数を読み込んでくれる。
次に、docker-compose.yml で user 命令を動的にマッピングする。
services:
web:
build: .
# ホストのUID/GIDでコンテナ内プロセスを実行する
user: "${UID}:${GID}"
volumes:
- .:/app
environment:
- HOME=/tmp # 非rootユーザー実行時のキャッシュ書き込み先を確保
Dockerfile側の対応(Non-rootユーザーの準備)
Dockerfile側でも、特定のUIDで動作できるようにユーザーを事前に定義しておくのがセキュリティ上のベストプラクティスだ。
FROM python:3.11-slim
# アプリケーションを実行する非特権ユーザーを作成
RUN groupadd -g 1000 appgroup && \
useradd -u 1000 -g appgroup -m appuser
WORKDIR /app
# 依存関係のインストール
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 所有権をappuserに変更してコピー
COPY --chown=appuser:appgroup . .
# 開発時は docker-compose.yml の `user` 指定によって、
# このappuserのUID/GIDがホスト側のユーザー(1000等)に動的に上書きされる
USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
このアプローチの美しさは、**「コンテナ内に不要なroot権限を残さず、開発時も本番時もセキュリティ基準を満たした状態で、パーミッションエラーを根絶できる」**点にある。
罠3:depends_on を信じる者は救われない。データベース起動待ちの真実
Docker Composeで最も頻出するトラブルの一つが、**「コンテナの起動順序」**に関する勘違いだ。
「depends_on に db を書いているのに、アプリがデータベースへの接続エラーで起動に失敗する」
そんな経験はないだろうか。
# ダメな例
services:
web:
build: .
depends_on:
- db # これだけでは「dbコンテナが起動した」だけで、「dbが利用可能」な状態は保証されない
Docker Composeにおける depends_on のデフォルトの挙動は、**「依存先コンテナのプロセスが開始されたか」**しか見ていない。PostgreSQLやMySQLなどのデータベースは、プロセスが開始されてから、内部の初期化処理(データディレクトリの作成やマイグレーションの準備)を終えて接続を受け付けられるようになるまで、数秒から数十秒のタイムラグがある。
この「準備中」の隙間にアプリケーション(Web)が接続を試みると、当然のようにコネクションエラーで即死する。
解決策1:healthcheck と condition: service_healthy の併用
最もクリーンな解決策は、データベース側に「自身が正常に稼働しているか」を判定する healthcheck を定義し、アプリケーション側でそのヘルスチェックをクリアするまで起動を待機させることだ。
services:
web:
build: .
ports:
- "8000:8000"
depends_on:
db:
# dbコンテナが「healthy」ステータスになるまで起動をブロックする
condition: service_healthy
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_DB=mydb
volumes:
- pgdata:/var/lib/postgresql/
---
---
参考になったらLGTMお願いします。誤り・補足はコメントにどうぞ。