Docker Compose の Watch 機能の利用と Nest.js の HMR 対応
はじめに
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
の省略系である -p
で npm
を指定します。続いて、プロジェクト名の 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":
では --bind 0.0.0.0
をつけるようにしています。こちらも、Docker ホストでも開発用 Web サーバーを動かすなら、script:
へ "start:docker":(略)
のようなスクリプトを追加して、使い分けをした方が良いです。ここでは、基本的には Docker コンテナー内で動かすという想定をして、このようにしてあります。
さらに、"start:dev":
については nest start
ではなく nest buld
コマンドを使うようにしました。オプション指定で、ビルドに 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.yal
ファイルを用意しましょう。ここで用意するディレクトリーの構成は次のとおりです。
compose-watch/
├── .dockerignore ... Docker イメージには含めないファイルを指定
├── Dockerfile ... app001 用 Docker イメージ作成用 Dockerfile
├── app001/ ... Nest.js の app001 アプリのプロジェクトディレクトリー
└── compose.yaml ... app001 用 compose.yaml
先ほど作成した app001
を含むディレクトリーに Dockerfile
と compose.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 app001/package.json app001/package-lock.json ./
RUN npm install
# Copy source files into application directory
COPY ./app001 /app
次に .dockerignore
を用意します。Docker イメージを作成するときに Docker ホストからコピーするのは app001
だけなので、この中にあるファイルで不要なものを記述します。
npm install
を実行して必要なパッケージをインストールするので、Docker ホスト側にある node_modules
は含めないようにします。
*/node_modules*
.gitignore
や README.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! 1
を Hello 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