初めてNode.jsをデプロイしたときに直面した問題
実際のプロジェクトでDocker Composeを初めて使ったとき、今思えば笑えるような基本的なミスをたくさんやらかしました。ビルドしたイメージが1GBを超えていたり、コンテナが数時間動いた後に理由もわからず止まったり、環境変数をDockerfileに直接ハードコードしていたり。ローカルでは動くのにサーバーに上げると動かない、という状態でした。
DockerでNode.jsのデプロイを始めて、まさに同じような状況に陥っているなら——この記事はそんなあなたのために書きました。
Node.jsをコンテナ化するときに問題が起きやすい理由
DockerとNode.jsは、必ずしも相性が良いとは言えません。初心者がほぼ必ず踏んでしまう落とし穴がいくつかあります:
- node_modulesが重すぎる:このディレクトリは数百MBになることもあり、そのままイメージにコピーするのは無駄です。
- ベースイメージの選択ミス:
node:latestはデフォルトでフルのDebianが含まれており——不要なほど重くなります。 - プロセスがPID 1でない:コンテナ内でNodeが動いていても
SIGTERMシグナルを正しく受け取れず、グレースフルシャットダウンが失敗します。 - 環境変数の漏洩:
DB_PASSWORDをDockerfileにハードコードしてGitHubにpushする——これは実際に起きている話で、自分も目にしたことがあります。 - rootで実行している:その必要はなく、もし悪用された場合、non-rootユーザーと比べてダメージははるかに大きくなります。
DockerでNode.jsをデプロイする方法
方法1:シンプルなDockerfile(productionには非推奨)
ほとんどの人が最初にやる方法です:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "index.js"]
ビルドして実行:
docker build -t myapp .
docker run -p 3000:3000 myapp
動きはします——ただしイメージのサイズは約1.1GBにもなります。ホストマシンのnode_modulesがそのままコピーされるため、OSが異なるとnative modulesでエラーが起きやすくなります。さらに、シグナルの終了処理も行われていません。
方法2:マルチステージビルド(改善されているが、まだ不十分)
マルチステージビルドはビルドとランタイムのステップを分けることで、イメージサイズを大幅に削減できます:
# ステージ1:ビルド
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# ステージ2:ランタイム
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
CMD ["node", "index.js"]
alpineを使うことでイメージは約200MBに削減できます。ただし、まだrootで動いており、シグナルの処理も適切ではありません。
本番対応Dockerfile:実際に使っているテンプレート
何度もデバッグと改良を重ねた末に、現在productionで安定稼働しているDockerfileがこちらです:
ステップ1:.dockerignoreを作成する
まずこのファイルを作成しましょう——よく忘れがちなステップですが、不要なファイルをイメージにコピーしないために重要です:
node_modules
npm-debug.log
.git
.env
*.md
dist
.DS_Store
ステップ2:本番対応Dockerfile
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
FROM node:18-alpine
WORKDIR /app
# 専用ユーザーを作成し、rootは使わない
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# ファイルの所有者を変更する
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
# tiniまたは--initを使ってPID 1を正しく処理する
CMD ["node", "--max-old-space-size=512", "index.js"]
ステップ3:developmentとproduction向けのDocker Compose
Composeを使えば、長いフラグを手動でdocker runに渡すよりも、環境変数やボリュームをはるかにスッキリ管理できます:
version: '3.9'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
env_file:
- .env # 環境変数は.envファイルから読み込み、ハードコードしない
environment:
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 512M
ステップ4:アプリにヘルスチェックエンドポイントを追加する
このルートはDockerにアプリが正常に動作して応答できることを知らせます。コンテナを再起動した後のデバッグに便利なので、uptimeも一緒に返すようにしています:
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', uptime: process.uptime() });
});
ステップ5:グレースフルシャットダウンの実装
ここが最も見落とされがちな部分です——そして、デプロイのたびにリクエストが知らないうちにdropされる原因でもあります。DockerがSIGTERMを送ったとき、アプリは突然終了するのではなく、適切に処理しなければなりません:
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERMを受信しました。グレースフルシャットダウン中...');
server.close(() => {
console.log('サーバーを閉じました。');
process.exit(0);
});
});
ビルドと実行
# イメージをビルド
docker compose build
# デタッチモードで起動
docker compose up -d
# ログを確認
docker compose logs -f app
# ヘルスチェックのステータスを確認
docker inspect --format='{{json .State.Health}}' コンテナ名
頭痛を避けるための実践的なコツ
- 常にバージョンを固定する:互換性のないパッチリリースで壊れないよう、
node:18-alpineではなくnode:18.20-alpineのように指定します。 npm installの代わりにnpm ciを使う:ciはpackage-lock.jsonの通りに正確にインストールし、依存関係を勝手にアップグレードしません——reproducibleなビルドに重要です。.envファイルは絶対にコミットしない:.gitignoreに追加し、テンプレートとして.env.exampleを使いましょう。- メモリに上限を設ける:Node.jsはメモリリークがあるとデフォルトでRAMを使い果たす可能性があります。
--max-old-space-sizeと、Compose内のdeploy.resources.limits.memoryを設定しましょう。 - レイヤーキャッシュを活用する:先に
package*.jsonをコピーしてnpm ciを実行し、その後でコードをコピーします。こうするとDockerがnode_modulesレイヤーをキャッシュし、依存関係を変えなければ再ビルドは数秒で完了します。
ビルド後のイメージ確認
# イメージのサイズを確認
docker images myapp
# どのユーザーでプロセスが実行されているか確認
docker exec コンテナ名 whoami
# セキュリティの脆弱性をスキャン(Docker Scoutがある場合)
docker scout cves myapp
上記の手順を正しく実践すれば、イメージのサイズは約150〜250MBに抑えられます——デフォルトの方法と比べて約80%の削減です。non-rootユーザーで動作し、ヘルスチェックを備え、再起動時も適切に終了します。どれも些細なことに聞こえるかもしれませんが、積み重なれば、数ヶ月間安定稼働するコンテナと、夜中の3時に勝手に再起動し続けるアプリの大きな差になります。

