🐳

Docker Compose の Watch 機能の利用と Nest.js の HMR 対応

2024/09/28に公開

はじめに

Docker Compose に Watch 機能があることを知ったので Ubuntu 22.04 で動作確認をしてみました。確認にあたっては Nest.js のアプリへ HMR(Hot Module Replacement) 対応したものを用意して動かしてみました。

動作環境の情報は次のようになります。

$ docker compose version
Docker Compose version v2.29.2-desktop.2
$ cat /etc/os-release 
PRETTY_NAME="Ubuntu 22.04.5 LTS"
NAME="Ubuntu"
VERSION_ID="22.04"
VERSION="22.04.5 LTS (Jammy Jellyfish)"
VERSION_CODENAME=jammy
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=jammy

Docker の機能の範囲内で Watch 機能が使えるのは、ありがたいことだと考えています。Docker の bind mount 機能の利用が前提となる開発環境で、Watch 機能をサポートするフレームワークを使っていないけど、手軽に Watch 機能を使いたい場合で活用できそうです。

世の中では、Docker の bind mount 機能は結構使われているようなので、この機能を積極的に使っている人は、Watch 機能も積極的に利用したいと思う人も多いかと思います。個人的には開発コンテナー(Dev Container)と Docker ボリュームを使っているため、そこまで利用が必要となる場面は多くないと考えています。

Compose Watch

Docker Compose の watch 機能についてのドキュメントは次の URL に説明があります。ただし、Docker Compose の古いバージョンでは使えません。

ドキュメントを見ると、下記のコマンドが使えるコンテナーでのみ利用可能だとのことです。

  • stat
  • mkdir
  • rmdir

Compose Watch は Docker ホストのファイル変更を監視して、Docker コンテナー内のファイルを自動更新したり、Docker イメージを自動でリビルドしてコンテナーの再起動をしたりできます。

Docker ホストのファイルを編集して、それを Docker コンテナーへ反映しながら開発するスタイルの場合に重宝する機能になります。

開発コンテナー(Dev Container)と Docker ボリュームを使って開発していると、Docker ホストのファイルを編集したりしないので、この機能は使えません。

Nest.js アプリの利用

Compose Watch の機能を試すにあたり、Node.js 環境で動作する Nest.js のサンプルを用意して利用することにします。
ここでは、Nest.js のサンプルアプリとして app001 を用意して動作確認します。

app001 アプリの作成

まずは、サンプルで用意するアプリを作成します。Nest.js のアプリを作成するにあたっては、次の資料を参考にしました。

@nestjs/cli のコマンドを実行すれば良いので、ここでは npm exec コマンドを使って nest new 相当のコマンドを実行します。

パラメーターには、使用するパッケージマネージャーを NPM とするために、--package-manager の省略系である -pnpm を指定します。続いて、プロジェクト名の app001 を指定します。

npm exec @nestjs/cli -- new -p npm app001

実際に実行するとインストールするパッケージとして @nestjs/cli が表示されます。Ok to proceed? (y) のプロンプトが表示されたら y を入力して先に進めます。手元では次のようになりました。

$ npm exec @nestjs/cli -- new --help
Need to install the following packages:
  @nestjs/cli@10.4.5
Ok to proceed? (y) y
⚡  We will scaffold your app in a few seconds..

CREATE app001/.eslintrc.js (663 bytes)
(略)
CREATE app001/test/app.e2e-spec.ts (630 bytes)

✔ Installation in progress... ☕

🚀  Successfully created project app001
👉  Get started with the following commands:

$ cd app001
$ npm run start

                                         
                          Thanks for installing Nest 🙏
                 Please consider donating to our open collective
                        to help us maintain this package.
                                         
                                         
               🍷  Donate: https://opencollective.com/nest

これで Nest.js の雛形アプリが作成されます。

app001 アプリの動作確認

次に、作成した app001 アプリの動作確認をしましょう。

まず、app001 ディレクトリーへ移動します。

cd app001

それから npm run start コマンドを実行して開発用 Web サーバーを起動します。

npm run start

実際に起動すると次のようになります。

$ npm run start

> app001@0.0.1 start
> nest start

[Nest] 102375  - 2024/09/28 9:48:15     LOG [NestFactory] Starting Nest application...
[Nest] 102375  - 2024/09/28 9:48:15     LOG [InstanceLoader] AppModule dependencies initialized +7ms
[Nest] 102375  - 2024/09/28 9:48:15     LOG [RoutesResolver] AppController {/}: +3ms
[Nest] 102375  - 2024/09/28 9:48:15     LOG [RouterExplorer] Mapped {/, GET} route +1ms
[Nest] 102375  - 2024/09/28 9:48:15     LOG [NestApplication] Nest application successfully started +1ms

開発用 Web サーバーが起動したら、Web ブラウザを起動して http://localhost:3000/ を開きます。画面に Hello World! と表示されたら成功です。

なお、開発用 Web サーバーを停止するには Ctrl+C を入力(Ctrl と C を同時に入力)します。動作確認したら停止しておきましょう。

nest start コマンド

Nest.js の開発用 Web サーバーを起動するコマンドについては、package.json 内の script:start:nest start コマンドを指定することで実現しています。このコマンドでは -b swc というオプションを指定することでビルドを高速に行うこともできます。

それを使いたい場合は、次のようにします。

npm run start -- -b swc

また、開発中に自動で変更を検知してサーバーへ自動反映するための watch モードもあります。その機能を有効にして開発用 Web サーバーを起動するには、次のように npm run start:dev コマンドを実行します。

npm run start:dev

watch モードでの開発は小規模なものなら、これで十分なのですが、大規模なアプリの開発ではモジュール単位で置き換えできる HMR 機能を使いたいことがあります。

そのため、app001 について、HMR 対応をすることにします。

HMR 対応

Nest.js アプリを開発するにあたって、効率よく開発するためには Hot Module Replacement 機能を利用します。公式のドキュメントでは次の URL に説明があります。

内部的には webpack の HotModuleReplacement プラグインの機能を使って実現されています。webpack の HotModuleReplacement プラグインの機能についてのドキュメントは次の URL にあります。

基本的には公式ドキュメントの説明と同じにすれば良いです。ここでは手元で確認するときに、少し追加したものがあるので、それについても紹介します。

作業は app001 ディレクトリーをカレントディレクトリーとして行います。ここでは ${NEST_PROJ_DIR} と表記することにします。

cd ${NEST_PROJ_DIR}

まず、webpack 関連の必要なパッケージを開発用に追加します。

npm i --save-dev webpack-node-externals run-script-webpack-plugin webpack

この後、main.ts のコード修正をしますが、そこで webpack パッケージ内のモジュールを使うので、型情報のパッケージも開発用に追加します。

npm i --save-dev @types/webpack-env

それから、${NEST_PROJ_DIR}/webpack-hmr.config.js を用意します。内容は次のようにします。一部追加した内容はありますが、基本的に公式ドキュメントで紹介されているものをそのまま使っています。

const nodeExternals = require('webpack-node-externals');
const { RunScriptWebpackPlugin } = require('run-script-webpack-plugin');

module.exports = function (options, webpack) {
  return {
    ...options,
    devServer: {
      host: '0.0.0.0',
    },
    entry: ['webpack/hot/poll?100', options.entry],
    externals: [
      nodeExternals({
        allowlist: ['webpack/hot/poll?100'],
      }),
    ],
    plugins: [
      ...options.plugins,
      new webpack.HotModuleReplacementPlugin(),
      new webpack.WatchIgnorePlugin({
        paths: [/\.js$/, /\.d\.ts$/],
      }),
      new RunScriptWebpackPlugin({
        name: options.output.filename,
        autoRestart: false,
      }),
    ],
  };
};

ここでは、公式ドキュメントの内容に追加して devServer: { host: '0.0.0.0', } を指定しました。これは、後で Docker コンテナー内で app001 アプリを起動するときに必要なものです。Docker コンテナー内で起動しないのなら、この指定は不要なので、本当は環境変数などで指定変更が可能なようにしておくのが良いです。

他の指定については、説明をすると長くなるので、説明は省略します。興味がある場合は webpack 関連のドキュメントを確認してください。

次に ${NEST_PROJ_DIR}/src/main.ts を修正します。HMR を適用するためには、コードでも対応が必要です。

ここでは HMR モジュールが使えるようになっている場合は、その HMR 機能を有効化する処理を追加して、次のようにしました。

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);

  // 追加 -- ここから
  if (module.hot) {
    module.hot.accept();
    module.hot.dispose(() => app.close());
  }
  // 追加 -- ここまで
}
bootstrap();

それから ${NEST_PROJ_DIR}/package.json を修正します。

{
    // 略
    "start": "nest start --bind 0.0.0.0",
    "start:dev-no-hmr": "nest start --watch --bind 0.0.0.0",
    "start:dev": "nest build --builder webpack --webpackPath webpack-hmr.config.js --watch",
    // 略
}

もとから用意されていた "start:dev":"start:dev-no-hmr": へ移動しました。

また、ここでは Docker コンテナー内で動かすことも想定して、"start":"start:dev-no-hmr": では --bind 0.0.0.0 をつけるようにしています。こちらも、Docker ホストでも開発用 Web サーバーを動かすなら、script:"start:docker":(略) のようなスクリプトを追加して、使い分けをした方が良いです。ここでは、基本的には Docker コンテナー内で動かすという想定をして、このようにしてあります。

さらに、"start:dev": については nest start ではなく nest build コマンドを使うようにしました。オプション指定で、ビルドに webpack を使い、webpack-hmr.config.js を webpack の設定ファイルとして使うようにしています。

以上の変更について対応ができたら、次は HMR 対応版の動作確認をします。

HMR 対応版の動作確認

それでは、HMR 対応版の動作確認をします。

スクリプトを書き換えてあるので、 npm run start:dev を実行すると、HMR 対応版が動作します。

npm run start:dev

実際に実行すると次のようになります。

$ npm run start:dev

> app001@0.0.1 start:dev
> nest build --builder webpack --webpackPath webpack-hmr.config.js --watch


 Info  Webpack is building your sources...

webpack 5.94.0 compiled successfully in 1489 ms
[Nest] 143335  - 2024/09/28 10:33:39     LOG [NestFactory] Starting Nest application...
[Nest] 143335  - 2024/09/28 10:33:39     LOG [InstanceLoader] AppModule dependencies initialized +9ms
[Nest] 143335  - 2024/09/28 10:33:39     LOG [RoutesResolver] AppController {/}: +2ms
[Nest] 143335  - 2024/09/28 10:33:39     LOG [RouterExplorer] Mapped {/, GET} route +2ms
[Nest] 143335  - 2024/09/28 10:33:39     LOG [NestApplication] Nest application successfully started +1ms

今回は前回とちがって nest build コマンドが実行されていることがわかります。

開発用の Web サーバーが起動したら、Web ブラウザで http://localhost:3000/ へアクセスして Hello World! が表示されることを確認します。

それからエディタ上で ${NEST_PROJ_DIR}/src/app.service.ts を修正します。ここでは次のように 'Hello World!' ではなく 'Hello World! 1' を return するように変更しました。

import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World! 1';
    // return 'Hello World!';
  }
}

すると npm run start:dev を実行しているターミナルに出力が追加されます。内容から、HMR 機能により自動でモジュールが置き換わったことがわかります。

$ npm run start:dev
(略)
[Nest] 143335  - 2024/09/28 10:33:39     LOG [NestApplication] Nest application successfully started +1ms

 Info  Webpack is building your sources...

Entrypoint main 45.1 KiB = main.js 43.8 KiB 0.3710a1b9257f6fa4ee50.hot-update.js 1.31 KiB
webpack 5.94.0 compiled successfully in 110 ms
[Nest] 143335  - 2024/09/28 10:35:58     LOG [NestFactory] Starting Nest application... +139385ms
[HMR] Updated modules:
[HMR]  - 8
[HMR]  - 7
[HMR]  - 5
[HMR]  - 3
[HMR] Update applied.
[Nest] 143335  - 2024/09/28 10:35:58     LOG [InstanceLoader] AppModule dependencies initialized +5ms
[Nest] 143335  - 2024/09/28 10:35:58     LOG [RoutesResolver] AppController {/}: +0ms
[Nest] 143335  - 2024/09/28 10:35:58     LOG [RouterExplorer] Mapped {/, GET} route +1ms
[Nest] 143335  - 2024/09/28 10:35:58     LOG [NestApplication] Nest application successfully started +0ms

今回作成している Nest.js の app001 アプリはサーバー機能を提供するものなので、サーバー機能が更新されるだけで、クライアントである Web アプリには自動更新通知はされません。

React アプリなどでは、HMR 機能を有効にすると、クライアント側でのレンダリングに影響する場合に自動反映がされる仕組みが動くようになりますが、今回のサンプルではそういった部分はありません。

そのため、実際に main.ts の変更が反映されていることを確認するためには、Web ブラウザで http://localhost:3000/ のページをリロードする必要があります。リロードして Hello World! 1 が表示されたら成功です。

停止方法は nest start コマンドと同じで Ctrl+C となります。動作確認したら停止しておきましょう。

Compose Watch を使って app001 の開発

それでは app001 用の compose.yaml ファイルを用意しましょう。ここで用意するディレクトリーの構成は次のとおりです。

compose-watch/
├── .dockerignore ... Docker イメージには含めないファイルを指定
├── Dockerfile ... app001 用 Docker イメージ作成用 Dockerfile
├── app001/ ... Nest.js の app001 アプリのプロジェクトディレクトリー
└── compose.yaml ... app001 用 compose.yaml

先ほど作成した app001 を含むディレクトリーに Dockerfilecompose.yaml ファイルを用意します。

ファイルの用意

最初に Dockerfile を用意します。ここで用意する Dockerfile は次の Docker Compose の公式ドキュメントにあるものを参考にして作成しました。

内容は次のようになります。ソースコードは app001 にあること、/app ディレクトリーの所有者は app ユーザーにしておくのが良いこと、などを考慮して変更してあります。

# Run as a non-privileged user
FROM node:20
RUN useradd -ms /bin/sh -u 1001 app && mkdir /app && chown -R app:app /app
USER app

# Install dependencies
WORKDIR /app
COPY --chown=app:app app001/package.json app001/package-lock.json ./
RUN npm install

# Copy source files into application directory
COPY --chown=app:app ./app001 /app

次に .dockerignore を用意します。Docker イメージを作成するときに Docker ホストからコピーするのは app001 だけなので、この中にあるファイルで不要なものを記述します。

npm install を実行して必要なパッケージをインストールするので、Docker ホスト側にある node_modules は含めないようにします。

*/node_modules*

.gitignoreREADME.md なども不要ですが、このイメージは開発時に使うコンテナー用のものなので、そのまま含めておくことにします。

実行時に使うコンテナーは Node.js なら distroless 系イメージ、slim 系イメージ、alpine のイメージなどを使うことがありますが、開発時はそういった機能制限が厳しくされたイメージを使うと効率が悪くなるので、避けるのが普通です。そういった実行時に使うコンテナー用のイメージを作成するときは、きちんと除外するようにしましょう。

次に compose.yaml を用意します。内容は次のようになります。

name: compose-watch
services:
  web:
    build: .
    command: npm run start:dev
    develop:
      watch:
        - action: sync
          path: ./app001
          target: /app
          ignore:
            - app001/package.json
            - app001/package-lock.json
            - app001/node_modules/
        - action: rebuild
          path: app001/package.json
    ports:
      - 127.0.0.1:3000:3000

Compose Watch の機能は次の部分です。

    develop:
      watch:
        - action: sync
          path: ./app001
          target: /app
          ignore:
            - app001/package.json
            - app001/package-lock.json
            - app001/node_modules/
        - action: rebuild
          path: app001/package.json

ここでは、ファイルの同期をする action: sync と、Docker イメージのリビルドをする action: rebuild の機能を使うようにしています。Docker イメージのリビルドがあるため web サービスには build: の指定も必要となっています。

action: sync については、監視対象として同期のソースとなるものを path:、Docker コンテナー内でソースを反映するディレクトリーを target: で指定します。ignore: には監視対象外とするファイルのパスを指定します。path: 内のファイルに変更があったら、Docker コンテナーへ自動で反映されます。

action: rebuild については、監視対象となるものを path: に指定します。path: 内のファイルに変更があったら、Docker コンテナーを停止して Docker イメージをリビルドしてから、Docker コンテナーを再起動します。

動作確認

必要なファイルを用意したら Compose Watch の動作確認をしてみましょう。

最初からウォッチモードで起動することもできますが、問題が起きたときのことも考えて、順番に進めます。

Docker イメージの用意

まずは、docker compose build コマンドで Docker イメージを用意します。

$ docker compose build
[+] Building 34.2s (13/13) FINISHED                                                                                          docker:default
 => [web internal] load build definition from Dockerfile     
(略)
 => [web] exporting to                                                  1.2s
 => => exporting layers                                                 1.2s
 => => writing image sha256:(略)0f21e04584a38329d551bd47e50c03c31f472 0.0s
 => => naming to docker.io/library/compose-watch-web (略)             0.0s
 => [web] resolving provenance for metadata file      

writing image の行が表示されて、エラーがなく終了したらビルド成功です。

サービスをウォッチーモードで起動

Docker イメージが用意できたら、サービスをウォッチモードで起動する docker compose up --watch コマンドを実行します。

docker compose up --watch

実際に実行すると次のようになり、app001 の開発用 Web サーバーが起動します。

$ docker compose up --watch
[+] Running 2/2
 ✔ Network compose-watch_default  Created                                                                                              0.1s 
 ✔ Container compose-watch-web-1  Created                                                                                              0.0s 
Attaching to web-1
        ⦿ Watch enabled
web-1   | 
web-1   | > app001@0.0.1 start:dev
web-1   | > nest build --builder webpack --webpackPath webpack-hmr.config.js --watch
web-1   | 
web-1   | 
web-1   |  Info  Webpack is building your sources...
web-1   | 
web-1   | webpack 5.94.0 compiled successfully in 1568 ms
web-1   | [Nest] 44  - 09/28/2024, 2:38:35 AM     LOG [NestFactory] Starting Nest application...
web-1   | [Nest] 44  - 09/28/2024, 2:38:35 AM     LOG [InstanceLoader] AppModule dependencies initialized +6ms
web-1   | [Nest] 44  - 09/28/2024, 2:38:35 AM     LOG [RoutesResolver] AppController {/}: +4ms
web-1   | [Nest] 44  - 09/28/2024, 2:38:35 AM     LOG [RouterExplorer] Mapped {/, GET} route +3ms
web-1   | [Nest] 44  - 09/28/2024, 2:38:35 AM     LOG [NestApplication] Nest application successfully started +6ms

curl コマンドで確認

別のターミナルを起動して、コンテナー内で curl http://localhost:3000/ というコマンドを実行して app001 が正しく結果を返すことを確認します。

docker compose -p compose-watch exec web curl http://localhost:3000/

実際に実行すると次のようになります。結果が Hello World! 1 となっているので、app001 は動作しているということになります。

$ docker compose -p compose-watch exec web curl http://localhost:3000/
Hello World! 1

なお、Docker ホスト側の Web ブラウザを使って確認することもできます。その場合は http://127.0.0.1:3000 へアクセスします。

action: sync の機能の確認

次に、action: sync の機能について確認しましょう。

監視対象としているパスに含まれる ${NEST_PROJ_DIR}/src/app.service.ts を Docker ホスト側のエディタで編集して保存します。Hello World! 1Hello World! 2 とするなどで良いでしょう。

すると、action: sync の機能により Dcoker コンテナー内のファイルへファイル保存内容が自動で反映されます。、また、これに反応して HMR が機能して、自動で開発 Web サーバーに変更が反映されます。

変更の反映を確認するには curl コマンドを再実行します。結果に変更が反映されているはずです。

$ docker compose -p compose-watch exec web curl http://localhost:3000/
Hello World! 2

action: rebuild の機能の確認

次に action: rebuild の機能について確認しましょう。

監視対象としているパスに含まれる ${NEST_PROJ_DIR}/package.json を Docker ホスト側のエディタで編集して保存します。"script": に含まれる "start": の行から --bind 0.0.0.0 を削除するなどで良いでしょう。

すると、action: rebuild の機能により Docker コンテナーが停止して Docker イメージのリビルドが始まります。また、Docker イメージのビルドが成功すると、自動で Docker コンテナーが起動して app001 アプリの開発 Web サーバーが起動します。

Discussion