← 記事一覧に戻る

Docker Compose + Node.js でホットリロードが効かない・ファイル変更が反映されない時の原因と対策

Docker Compose + Node.js でホットリロードが効かない問題

Docker Compose を使ったNode.js開発環境でよく遭遇するのが「ホストでファイルを編集してもコンテナに反映されない」「nodemon / vite / ts-node-dev を使っているのにリロードされない」という問題です。

この記事では、よくある5つの原因とそれぞれの対策をコード例付きで解説します。


症状のパターン

  • ホストでファイルを保存してもサーバーが再起動しない
  • nodemon のログに変更検知が出ない
  • コンテナを再起動すると反映されるが、自動リロードされない
  • node_modules がマウント後に消える・インストール済みパッケージが見つからない

原因1: volumes のバインドマウントが設定されていない

最もよくある原因です。docker-compose.yml でソースコードをホスト→コンテナにマウントしていないと、コンテナ内はdocker build時のスナップショットのままになります。

悪い例(ホットリロード不可):

services:
  app:
    build: .
    ports:
      - "3000:3000"
    # volumesが無いので常にビルド時のコードが使われる

良い例:

services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/app                        # ホストの現在ディレクトリをマウント
      - /app/node_modules             # node_modulesは除外(後述)

原因2: node_modules がホスト側で上書きされる

./:/app でルートごとマウントすると、ホストの node_modules(存在しない・または異なるアーキテクチャのバイナリ)でコンテナの node_modules が上書きされます。その結果、コンテナ内でパッケージが見つからなくなります。

解決策: named volume で node_modules を保護する

services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - node_modules_cache:/app/node_modules  # named volumeで保護

volumes:
  node_modules_cache:

この設定により node_modules はコンテナ内でビルドされたものが使われ、ホスト側に引きずられません。


原因3: Linuxカーネルの inotify が効かない(WSL2 / 古いDocker Desktop)

Linuxの inotify はファイルシステムのイベントを監視するしくみですが、Docker Desktop(特にWindows上のWSL2環境)や一部のLinuxホストでは、マウントされたボリュームに対して inotify イベントが正しく伝播しないことがあります。

その場合、nodemonvite--watch がイベントを受け取れず、変更を検知できません。

解決策: ポーリングモードに切り替える

nodemon の場合:

{
  "watch": ["src"],
  "ext": "ts,js,json",
  "exec": "ts-node src/index.ts",
  "legacyWatch": true
}

legacyWatch: true はポーリングで変更を検出します(CPUは若干上がりますが確実に動きます)。

Vite の場合:

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  server: {
    watch: {
      usePolling: true,       // ポーリングを有効化
      interval: 300,          // ミリ秒単位(デフォルト: 100)
    },
  },
})

環境変数で制御したい場合:

services:
  app:
    environment:
      - CHOKIDAR_USEPOLLING=true   # chokidar(nodemon/viteが内部で使用)
      - CHOKIDAR_INTERVAL=300

原因4: Dockerfile の WORKDIR とマウント先がズレている

Dockerfile で WORKDIR /app を設定しているのに、docker-compose.yml./:/workspace とマウントしていると、コードが別のパスに置かれてプロセスが古いファイルを参照し続けます。

Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npx", "nodemon", "src/index.ts"]

docker-compose.yml(WORKDIR と一致させる):

services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/app               # Dockerfileの WORKDIR /app と一致
      - /app/node_modules

原因5: シグナルが正しく転送されない(PID 1問題)

Node.jsプロセスがPID 1として起動すると、nodemon がSIGTERM/SIGHUPを送ってもプロセスが終了せず、再起動できないことがあります。

# 悪い例: シェル形式(PID 1はshになる)
CMD nodemon src/index.ts

# 良い例: exec形式(PID 1がnodemonになる)
CMD ["npx", "nodemon", "src/index.ts"]

あるいは tini を init として使う方法もあります:

FROM node:20-alpine

# tiniをインストール
RUN apk add --no-cache tini

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["npx", "nodemon", "src/index.ts"]

または docker-compose.ymlinit: true オプションでも同様の効果が得られます:

services:
  app:
    build: .
    init: true          # tiniと同等のinit processを使う
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules

実践的な完成形 docker-compose.yml

上記の対策をまとめると、以下のような構成が安定して動きます:

services:
  app:
    build:
      context: .
      target: dev          # マルチステージビルドを使う場合
    init: true
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - node_modules_cache:/app/node_modules
    environment:
      - NODE_ENV=development
      - CHOKIDAR_USEPOLLING=true
      - CHOKIDAR_INTERVAL=300
    command: npx nodemon src/index.ts

volumes:
  node_modules_cache:

チェックリスト

ホットリロードが効かない時は、以下の順番で確認してください:

  1. volumes にバインドマウントが設定されているか./:/app
  2. node_modules が named volume で保護されているか/app/node_modules
  3. WSL2 / Docker Desktop 環境でポーリングが必要かCHOKIDAR_USEPOLLING=true
  4. WORKDIR とマウント先のパスが一致しているか
  5. CMD が exec 形式か、または init: true が設定されているか

まとめ

Docker Compose + Node.js のホットリロード問題は、設定ミスの組み合わせで起きることがほとんどです。特に node_modules のnamed volume分離WSL2環境でのポーリング有効化 は見落としがちなので、最初から設定しておくと後々のトラブルを防げます。

環境ごとに docker-compose.override.yml を使ってポーリング設定を分けるのもよいプラクティスです:

# docker-compose.override.yml(開発環境専用、gitignoreしても可)
services:
  app:
    environment:
      - CHOKIDAR_USEPOLLING=true

これにより本番用の docker-compose.yml を汚さずに開発環境専用の設定を追加できます。