NodeJSのDocker構築 ~開発環境パターンとCI構築まで~


2年間育ててきたDockerfileがいい感じになってきたのでノウハウ共有します。サンプルコードはNestJSを利用していますが、Expressなどの他のフレームワークでも参考になると思います(NestJSの前はExpressで運用していたので)。あとprisma初めて使ったので書き方違うかもです。その時は指摘ください。

サンプルコード

https://github.com/RNm-dove/nestjs_docker_sample

この記事がカバーしているのは

  • ローカルで Docker for Desktop を使って開発をする。
  • CIサーバー(GitHub Actions)で Docker を使ってテストを行う。
    です。

Dockerを使ってことがある人向けの記事になってます。DockerとDocker for Desktopの解説はしません。

Docker for Desktopを使って開発環境を作るときのパターン

NodeJSでDockerを使った開発環境を作るときにいくつかのパターンがあります。

バインドマウントとボリュームマウントの違いを事前に理解しておいてください。

https://amateur-engineer-blog.com/docer-compose-volumes/

node_modulesを含めたすべてのファイルをホストマシンとコンテナでバインドマウントする。

docker-compose
version: '3.7'

services:
  # 開発用
  app:
    build:
      context: .
      dockerfile: ./Dockerfile
    ports:
      - 3000:3000
    init: true
    volumes:
      # バインドマウント
      - .:/home/node/nestjs_docker_sample

node_modules以外のすべてのファイルをホストマシンとコンテナでバインドマウントする。node_modulesはボリュームマウントする。

docker-compose
version: '3.7'

services:
  # 開発用
  app:
    build:
      context: .
      dockerfile: ./Dockerfile
    ports:
      - '3000:3000'
    init: true
    volumes:
      # バインドマウント node_modules以外のすべてのファイルを指定
      - ./src:/home/node/nestjs_docker_sample/src
      - ./test:/home/node/nestjs_docker_sample/test
      # ...省略
      # ボリュームマウント
      - node_modules:/home/node/nestjs_docker_sample/node_modules
      
volumes:
  node_modules:

node_modulesを含めたすべてのファイルをホストマシンとコンテナでバインドマウントする。コンテナ側のnode_modulesはホスト側のnode_modulesで上書きされないようにディレクトリ構造を工夫してボリュームマウントする。

コンテナ側のディレクトリ構造
| - /home/node/nestjs_docker_sample
    | - package.json
    | - package-lock.json
    | - node_modules
    | - app
        | - src
            | - index.js
            | - ...
ホストマシン側のディレクトリ構造
| - nestjs_docker_sample
    | - Dockerfile
    | - package.json
    | - package-lock.json
    | - node_modules
    | - src
        | - index.js
        | - ...
docker-compose
version: '3.7'

services:
  # 開発用
  app:
    build:
      context: .
      dockerfile: ./Dockerfile
    ports:
      - '3000:3000'
    init: true
    volumes:
      # バンドマウント
      - ./:/home/node/nestjs_docker_sample/app/
      # ボリュームマウント
      # the volume above prevents our host system's node_modules to be mounted
      - node_modules:/home/node/nestjs_docker_sample/app/node_modules/
    command: bash -c "rm -rf /usr/local/app/node_modules/* && nodemon index.js"
      
volumes:
  node_modules:

node_modulesのみをボリュームマウントする。開発はVSCodeのリモートコンテナを利用し、コンテナ内で開発する。

docker-compose
version: '3.7'

services:
  # 開発用
  app:
    build:
      context: .
      dockerfile: ./Dockerfile
    ports:
      - '3000:3000'
    init: true
    volumes:
      # ボリュームマウント
      - 'node_modules:/home/node/nestjs_docker_sample/node_modules
      
volumes:
  node_modules:

2番目と3番目はほぼ同じことやってますね。

なぜこんなにパターンがあるかというと、開発のユースケースが異なるからです。

開発環境ユースケース

コンテナオンリーで開発したい場合

まずは、node_modulesのみをボリュームマウントする。つぎに、VSCodeリモートコンテナを使うか、コンテナ内部でvim開発をします。

VSCodeリモートコンテナ画期的なんですが、エディタの拡張機能を準備し直す必要があるのと、若干ラグいのがデメリットですね。

コマンドもnpm installもすべてコンテナ内部で実行します。VSCodeリモートコンテナを利用しているとgitのユーザーとかどうするんでしょうか。

コンテナに環境あるけど、ホストエディタ上でリンタやフォーマッタを効かせたい。

node_modulesファイル群がホストマシン側にないと、リンタやフォーマッタがうまく動かないときがあります。また型エラーが表示されてホストエディタ上での開発は辛くなります。そこでホストマシン側にもnode_modulesファイルを保持します。

この場合は2つのパターンがあります。

問題はバインドマウントするとコンテナ側のファイルがホストマシン側のファイルで強制的に上書きされてしまうことです。これは初回マウント時に困ります。

  1. Dockerfile上でnpm ciを書いていると、コンテナの生成時にnode_modulesにライブラリが入った状態で生成される。
  2. node_modulesをバインドマウントの対象にして、docker-composeを立ち上げる。
  3. 初回マウント時は、ホストマシン側のnode_modulesは空っぽ。
  4. ホストマシン側のnode_modulesでコンテナ側のnode_modulesが上書きされる。
  5. コンテナ側のnode_modulesが空っぽになる。

という流れです。これはバインドマウントしたあとで、手動でコンテナに入って、npm ciをすれば解決できます。ホストマシン側のnode_modulesにライブラリが入り、以降のバインドマウント時にはそれで上書きされても問題がないからです。ただ、せっかくDockerfileでnpm ciしてるのにもう一度バインドマウント後にnpm ciするのは二度手間ですね。

これに関しては解決策が2つあります。Dockerfileのマルチステージビルドを利用して、開発時のイメージにnpm ciを含めない方法と、ディレクトリ構造を工夫して、node_modulesフォルダをホストマシン側とコンテナ側で衝突させない方法です。

また、ホストマシン側とnode_modulesをバインドマウントするとかなり遅くなるという報告もあります。ここらへんはこの記事に詳しくのってあります。もしDockerとMacの相性に関して詳しい方がいれば教えて下さい。

https://burnedikt.com/dockerized-node-development-and-mounting-node-volumes/

パターン1 ホストマシンによる強制上書きを受け入れる

かなりシンプルな構築になります。node_modulesを含めたすべてのファイルをバインドマウントするだけです。npm install系は遅くなりますが、それよりもシンプルに使いやすいので僕の好みです。Dockerfile過程でインストールしたnode_modulesを上書きしてしまう問題にかんしては、Dockerfileの書き方で回避できます。

npm install系やコマンドはコンテナに入って実行し、gitはコンテナ外で実行します。

パターン2 ホストマシンによる強制上書きは受け入れられない

かなり複雑な構築になります。ホスト側とコンテナ側のディレクトリ構造を変えることで、バインドマウント時にnode_modulesが衝突しないように工夫します。そして、それぞれでnpm ciを実行します。また、コンテナ側のnode_modulesをボリュームマウントしてIOパフォーマンスの悪化を防ぎます。かなり複雑になるので僕はおすすめしません。やり方はこちらの記事に書いています。

https://burnedikt.com/dockerized-node-development-and-mounting-node-volumes/

こちらの方法だとホストマシン側でもnpm ciするので、ホストマシンでもコマンドが実行できそうです。

コンテナに環境あるけど、ホストエディタ上でリンタやフォーマッタを効かせなくてもいい。

node_modulesを除くすべてのファイルをバインドマウントして、node_modulesだけボリュームマウントします。

https://gotohayato.com/content/544/

しかし、ホストエディタ上でリンタやフォーマッタやの拡張機能が効かなくなる可能性があります。

結局どれがいいのか

僕のおすすめはコンテナオンリーでVSCodeのリモートコンテナ機能を利用するか、node_modulesを含めたすべてのファイルをバインドマウントする方法です。

この記事では「コンテナに環境あるけど、ホストエディタで開発したい。リンタやフォーマッタをかけたい場合」のパターン1の方法を解説していきます。

Dockerfileとdocker-composeを作成する

マルチステージビルドを利用します。BuildKitは使い方がよくわかってません。もしBuildKit使ったうまいDockerfileの使い方がわかる方がいれば記事をお待ちしています。アプリ名はnestjs_docker_sampleにしています。

ポイント

  • nodeベースのDockerイメージには、nodeというホームディレクトリとユーザーが存在するためそれを使う。
  • グローバルインストールするには環境変数の設定が必要。
  • 開発ステージの時点でcli系をグローバルインストールしておくことで、コンテナないでコマンドを利用できる。
  • 開発環境ではDockerfile上でnpm ciしない。新規開発者はイメージビルドしてdocker-compose upしたあとに、npm ciしてもらう。
  • CI上で実行する専用のテスト環境をDockerfileとdocker-composeそれぞれで作る。
  • 専用のテスト環境ではDockerfile上でnpm ciをし、docker-composeでそれをボリュームマウントする。

Dockerfileです。Prismaは今回サンプルで作るのに初めて触ったので、もしかしたらProdステージ動かないかもです。

infra/node/Dockerfile
###############
#    base     #
###############
# 本番のベース。ここではosの必須ライブラリ以外は何もいれない。
FROM node:14.19-alpine3.15 as base

ENV LANG=ja_JP.UTF-8
ENV HOME=/home/node
ENV APP_HOME="$HOME/nestjs_docker_sample"

WORKDIR $APP_HOME

# port 番号 いまDockerfileにEXPOSE使っても効果ないって聞いた。ただinformativeなだけ。
# https://shinkufencer.hateblo.jp/entry/2019/01/31/233000
EXPOSE 3000

# global install curlはローカルで簡単にAPIチェックできるように入れた。
# gitはjestのwatchモードに必要
# postgresql-clientはDBでpostgres使ってるなら必要
RUN apk upgrade --no-cache && \
    apk add --update --no-cache \
    postgresql-client curl git

# https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#global-npm-dependencies
# npmのグローバル設定
ENV NPM_CONFIG_PREFIX=/home/node/.npm-global
ENV PATH=$PATH:/home/node/.npm-global/bin

# package系のコピー
COPY package*.json ./
# .npmrcがあるならここでコピー
# COPY .npmrc ./

# すべてのファイルをnodeユーザーのものに
RUN chown -R node:node .

USER node

RUN echo "WORKDIR is $WORKDIR . HOME is $HOME . LANG is $LANG ." && npm config list

###############
#     dev     #
###############
# docker-composeでコードを共有する前提
# このステージでnpm ciしても、結局マウント時にホスト側のnode_modulesで塗りつぶされてしまうのでやらない。
FROM base as dev
ENV NODE_ENV=development

# グローバルインストールしたい系のやつはここでインストール
RUN npm i -g @nestjs/cli
RUN npm i -g prisma

###############
#     test    #
###############
# docker-composeでnode_modules以外のコードを共有する前提。
# docker-composeのprebuild-testに対応。
FROM dev as test
ENV NODE_ENV=test

RUN npm ci

###############
#    build    #
###############
# ソースコードをビルドする。
# ビルド時にテストファイルを除外しているので、テストはビルド前に行う。
FROM test as build

COPY --chown=node . .

RUN npm run build

###############
#    prod     #
###############
# ターゲットを指定しなければデフォルトで実行される
# dependenciesのみインストールされている
FROM base as prod
ENV NODE_ENV=production

# 設定ファイル系。実行に必要なやつをコピペする。
# もしかしたらここにprisma関連のソースコード必要かも。本番は検証していないです。
COPY --from=build /$APP_HOME/dist  /$APP_HOME/.dockerignore ./

RUN npm ci --only=production \
    && npm cache clean --force

# アプリ実行コマンド
CMD ["node", "src/main.js"]

docker-composeです。

docker-compose
version: '3.7'

services:
  # 開発用
  app:
    build:
      context: .
      dockerfile: ./infra/node/Dockerfile
      # マルチステージビルドのターゲットを指定
      target: dev
    ports:
      - '3000:3000'
      # デバッガ用
      - '9229:9229'
    # PID1問題に対応
    init: true
    volumes:
      # 開発環境ではnode_modulesをバインドマウントさせる。イメージビルド時はnpm ciは実行していない。開発初期にコンテナ上でnpm ciを行う。
      - '.:/home/node/nestjs_docker_sample'
    env_file:
      - .env.local
    command: npm run start:dev

  # CIテストで利用前提。
  prebuild-test:
    build:
      context: .
      dockerfile: ./infra/node/Dockerfile
      target: test
    ports:
      - '3000:3000'
    init: true
    volumes:
      - ./:/home/node/nestjs_docker_sample
      - ./coverage:/home/node/nestjs_docker_sample/coverage
      # https://stackoverflow.com/questions/30043'872/docker-compose-node-modules-not-present-in-a-volume-after-npm-install-succeeds
      # テスト環境ではnode_modulesをバインドマウントさせない。ホスト側のnode_modulesをバインドマウントしてしまうと、イメージビルド時でインストールしたnpm ciがまっさらになってしまうのでCI上でもう一度インストールする必要がある。ボリュームマウントを利用。
      - node_modules:/home/node/nestjs_docker_sample/node_modules
    env_file:
      - .env.local
    command: npm run ci:test

  postgres:
    build: ./infra/postgres
    volumes:
      - pg-data:/var/lib/postgresql/data
      - ./infra/postgres/initdb:/docker-entrypoint-initdb.d
    ports:
      - '5432:5432'
    environment:
      - POSTGRES_HOST_AUTH_METHOD=trust
      
volumes:
  pg-data:
    driver: 'local'
  node_modules:

GitHub Actionを構築する

.github/workflows/docker-image.yml
name: Docker Image CI

on: push

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: echo docker and compose version
        run: docker -v && docker-compose -v
      - name: build stateful server and migrate
        run: docker-compose up -d --build postgres
      - name: create coverage dir
        run: mkdir -p coverage && chmod 777 coverage
      - name: run migrate & test
        run: docker-compose run prebuild-test
      # Save coverage report in Coveralls
      #- name: Upload coverage to Codecov
      #  uses: codecov/codecov-action@v2
      #  env:
      #    CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

makefile でdocker-composeのコマンドをまとめる

Makefile
.PHONY: init
init:
	make clean
	docker-compose build
	docker-compose run --rm app npm ci
	docker-compose run --rm app prisma migrate deploy

.PHONY: clean
clearn:
	docker-compose down --volumes

.PHONY: dev
dev:
	docker-compose down app
	docker-compose up app

.PHONY: unit
unit:
	docker-compose run --rm app npm run test

.PHONY: e2e
e2e:
	docker-compose run --rm app npm run test:e2e

.PHONY: infra
infra:
	docker-compose down postgres 
	docker-compose up postgres

.PHONY: bash
bash:
	docker-compose up --no-start app 
	docker-compose start app
	docker-compose exec app sh

開発の仕方

初期設定

$ make init

開発中

$ make dev

終わったら Cntl-c でkill

e2eテスト

$ make infra

別タブで

$ make e2e

ユニットテスト

$ make unit

コマンド実行

npm install とか prisma-cli とか nest-cli のコマンドを実行したいとき

$ make bash

でコンテナに入って実行する。おわったら exit する。