DockerでNode.jsアプリをデプロイする:実践から学んだTips & Tricks

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

初めて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を使うcipackage-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時に勝手に再起動し続けるアプリの大きな差になります。

Share: