2026年8月8日

2026年8月8日

DockerでNode.jsアプリを動かす方法|Dockerfileの書き方と、止まらない・つながらない問題の防ぎ方

Node.jsアプリをDockerで動かすには、公式の node イメージをもとにDockerfileを書き、package.json を先にコピーして npm ci で依存関係を入れ、最後にアプリのコードをコピーして node コマンドで起動します。この順番にするだけでビルドが速くなり、イメージも小さく保てます。この記事では、小さなサンプルアプリを使って、Dockerfileの各行の意味、.dockerignore、外からつながらない・docker stop で止まらないといった定番のつまずき、開発時のComposeの書き方までを説明します。

前提はDocker EngineまたはDocker Desktopと、Docker Compose v2です。Node.jsは偶数番のLTS(長期サポート)版を使うのが基本で、例ではNode.js 24を使っています(2026年10月現在)。どの版がLTSの期間中かはNode.jsの公式サイトのリリース予定表で確認してください。

サンプルアプリ

依存パッケージなしで動く最小のHTTPサーバーです。ポイントは、待ち受けるアドレスと、終了シグナルへの対応です。

// server.js
const http = require('node:http');

const port = Number(process.env.PORT) || 3000;

const server = http.createServer((req, res) => {
  if (req.url === '/healthz') {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    return res.end('ok');
  }
  res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
  res.end('Hello from Docker\n');
});

// 0.0.0.0 で待ち受ける(コンテナの外から届くようにするため)
server.listen(port, '0.0.0.0', () => {
  console.log(`listening on ${port}`);
});

// docker stop で送られる SIGTERM を受けて、処理中のリクエストを終えてから終了する
process.on('SIGTERM', () => {
  console.log('SIGTERM received, shutting down');
  server.close(() => process.exit(0));
});

package.json には "start": "node server.js" のようなスクリプトがあり、npm install で package-lock.json ができている状態を想定します。

Dockerfileを書く

FROM node:24-slim

ENV NODE_ENV=production
WORKDIR /app

# 依存関係の定義だけを先にコピーしてインストールする
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

# アプリ本体をコピーする
COPY --chown=node:node . .

USER node
EXPOSE 3000
CMD ["node", "server.js"]

各行には次の意味があります。

  • **FROM node:24-slim**:Debianベースで、余分なパッケージを省いた版です。さらに小さい -alpine もありますが、AlpineはCライブラリがmuslで、ネイティブモジュール(画像処理や暗号化のライブラリなど)がビルドできない、挙動が違うといった問題が起きることがあります。迷ったらslimから始めるのが無難です。
  • **COPY package.json package-lock.json → npm ci → COPY . . の順番**:Dockerはレイヤーごとにキャッシュを使います。依存関係の定義が変わらない限り npm ci のレイヤーは再利用されるので、コードを1行直しただけのビルドが速くなります。COPY . . を先に書くと、コードを変えるたびに依存関係のインストールからやり直しになります。
  • **npm ci --omit=dev**:package-lock.json のとおりに入れる、再現性のあるインストールです。--omit=dev で開発用パッケージ(テストツールなど)を本番イメージから外します。
  • **USER node**:公式イメージには node というroot以外のユーザーが用意されています。アプリをrootで動かさないことで、脆弱性を突かれたときの影響を小さくできます。
  • **CMD ["node", "server.js"]**:配列の書き方(exec形式)にすると、シェルを挟まずに node が直接起動します。理由は後述のシグナルの節で説明します。

.dockerignore で送るファイルを絞る

ビルドのときには、ディレクトリの中身がDockerに送られます。ホストの node_modules まで送ると時間がかかるうえ、COPY . . でコンテナ内に入れた依存関係をホスト用のもので上書きしてしまいます。.dockerignore を用意しておきます。

node_modules
npm-debug.log
.git
.env
Dockerfile
compose.yaml

.env を含めておくのは、APIキーなどの秘密情報をイメージに焼き込まないためです。イメージに入ったファイルは、あとから消しても過去のレイヤーに残ります。秘密情報は実行時に環境変数やsecretsで渡します。

ビルドして起動する

docker build -t my-node-app .
docker run -d --name app -p 127.0.0.1:3000:3000 my-node-app
curl http://localhost:3000/
docker logs app

Hello from Docker が返れば成功です。console.log の出力は標準出力に出るので、docker logs で確認できます。アプリのログはファイルではなく標準出力に出す作りにしておくと、コンテナでは扱いやすくなります。

よくあるつまずき

コンテナは動いているのに外からつながらない

もっとも多いのが、アプリが localhost(127.0.0.1)で待ち受けているケースです。コンテナの中の localhost はコンテナ自身だけを指すため、-p でポートを公開しても外からの接続は届きません。listen のアドレスを 0.0.0.0 にする、またはアドレスを省略します。フレームワークの開発サーバーも、既定では localhost で待ち受けるものがあるので、ホストを指定するオプションを確認してください。

次に多いのが、-p ホスト側:コンテナ側 の左右の取り違えと、アプリの待ち受けポートと -p のコンテナ側の番号のずれです。docker ps の PORTS 欄と、docker logs に出る待ち受けポートを見比べます。

docker stop に10秒かかる

docker stop はまずSIGTERMを送り、既定では10秒待っても終わらなければSIGKILLで強制終了します。毎回10秒かかるのは、SIGTERMが無視されているサインです。

コンテナの中で最初に起動したプロセス(PID 1)は、自分でシグナルの処理を書いていない場合、SIGTERMを受けても終了しないというLinuxの特別な扱いを受けます。そのため、サンプルのように process.on('SIGTERM', ...) で終了処理を書いておくことが大切です。

また、CMD npm start やシェル形式の CMD node server.js だと、PID 1がnpmやシェルになり、シグナルがNodeに正しく届かないことがあります。exec形式で node を直接起動するのが確実性の高い方法です。終了処理を書けない既存アプリでは、docker run --init(Composeでは init: true)を付けると、シグナルを中継する小さな初期化プロセスがPID 1になります。

ネイティブモジュールのビルドに失敗する

npm ci の途中で gyp や make のエラーが出る場合は、ビルド用のツール(python3、make、g++ など)がイメージに入っていないのが原因です。ビルド用のステージでだけツールを入れ、完成した node_modules を実行用のステージにコピーする「マルチステージビルド」にすると、実行用のイメージを小さく保てます。TypeScriptなどでビルド工程があるアプリも同じ考え方で分けられます。

ヘルスチェックを付ける

slimやalpineのイメージには curl が入っていないことが多いので、ヘルスチェックはNode自身で書くと追加のインストールが要りません。Node.js 18以降は fetch が標準で使えます。

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

docker ps の STATUS に healthy と出れば、ヘルスチェックが通っています。

開発中はComposeでコードをマウントする

開発中に毎回ビルドし直すのは手間なので、Composeでホストのコードをマウントし、ファイルの変更で自動再起動させます。Node.jsには --watch オプションがあり、ファイルの変更を検知して再起動できます。

services:
  app:
    build: .
    command: ["node", "--watch", "server.js"]
    environment:
      NODE_ENV: development
      PORT: 3000
    ports:
      - "127.0.0.1:3000:3000"
    volumes:
      - ./:/app
      - /app/node_modules
    init: true

- /app/node_modules の行は、ホストのディレクトリで /app を上書きしたときに、イメージの中で入れた node_modules が隠れてしまうのを防ぐための指定です。これがないと、ホストに node_modules がない場合は「モジュールが見つからない」エラーになり、ある場合はホストのOS向けにビルドされたネイティブモジュールが読み込まれてエラーになることがあります。

依存関係を追加したときは、docker compose build でイメージを作り直す必要があります。また、上のDockerfileは --omit=dev で開発用パッケージを外しているため、テストツールやビルドツールを開発中にも使う場合は、開発用のステージを別に用意して build.target で指定するなどの工夫が要ります。Composeには、ファイルの変更を検知して同期や再ビルドを行う docker compose watch という機能もあるので、規模が大きい場合は使い分けを検討してください。

まとめ

  • package.json と package-lock.json を先にコピーして npm ci、そのあとでコードをコピーすると、キャッシュが効いてビルドが速くなります。
  • .dockerignore で node_modules と .env を除外し、秘密情報をイメージに入れないようにします。
  • アプリは 0.0.0.0 で待ち受け、USER node でroot以外のユーザーとして動かします。
  • CMD はexec形式で node を直接起動し、SIGTERMの終了処理を書くか --init を付けます。
  • 開発時はコードをマウントし、node_modules は匿名ボリュームで守ります。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲