DockerでNode.js環境構築する際、コンテナ生成時にpackage.jsonからパッケージを展開する方法

## 環境
- Windows10 Home 20H2
- WSL2インストール済み
- Docker Desktop for Windows
- VSCode

## 参考にさせて頂いた記事、サイト
- [Docker、ボリューム(Volume)について真面目に調べた - Qiita](https://qiita.com/gounx2/items/23b0dc8b8b95cc629f32)
- [Dockerを使ってNode開発環境を作る](https://auth0.com/blog/jp-use-docker-to-create-a-node-development-environment/)
- [DockerfileのCMD、docker-compose.yamlのcommandに、複数コマンドを複数行で書く - miuuuu’s blog](https://miuuuu.hatenablog.com/entry/2020/03/21/013627)

## 当記事のきっかけ
前の記事で、自分なりにdocker使えるような気がし始めてきたところですが、疑問が生じました。
前の記事(※)では、空のnode.jsコンテナがあり、そのコンテナ内でnpm initでpackage.jsonを初期化、順次必要なパッケージをインストールしていき、package.jsonに依存関係を記録していきました。

あらかじめ依存関係を記録したpackage.jsonや初期のコードがあり、それをコンテナに展開させるにはどうするのかを、自分なりに試そうと思いました。
※[自分がDockerについて調べて、やっと使えるようになった気がした手順 - Qiita](https://qiita.com/YamaDash82/items/b459ec502d8390708f45)

## やりたいこと
- 初期のコード、依存関係が登録されたpackage.jsonが先にある。
- コンテナ生成時に必要なパッケージ、初期コードがコンテナに展開される。
- コンテナを削除しても、ソースは残る。(永続化)

## 実践

###ファイル構造(ホスト)

```
(root)
  |
  |--src/ (←このフォルダ以下をコンテナにマウントする。)
  |   |--dist/(※distフォルダ以下は、typescriptをトランスパイルした際に作成される。初めは存在しない。)
  |   |   |--server.js
  |   |
  |   |--nodo_modules(※npm install実行時にpackage.jsonをもとに展開される。初めは存在しない。)
  |   |
  |   |--src/
  |   |   |-server.ts
  |   |
  |   |--nodemon.json
  |   |--package.json
  |   |--tsconfig.json
  |   |--webpack.config.json
  |
  |--docker.compose.yml
```

### dcoker-compose.yml

```yml:docker-compose.yml
version: '3'
services: 
  web-server:
    image: node:14
    container_name: docker-node-c1
    volumes:
      - ./src:/app
    ports:
      - 3000:3000
    working_dir: /app
    command: >
      bash -c "npm install && npm run build && npm run dev"
```

### その他ファイルを準備する

```typescript:(root)/src/src/server.ts
import * as Express from 'express';

const app = Express();

app.get('/', (req: Express.Request, res: Express.Response, next: Express.NextFunction) => {
  res.send('Hello World.');
});

const portNo = process.env.PORT || 3000;

app.listen(portNo, () => {
  console.log(`app running on port ${portNo}.`);
});

export default app;
```

```json:(root)/src/nodemon.json
{
  "watch": [
    "dist"
  ], 
  "ext": "ts, js, json", 
  "exec": "node ./dist/server.js"
}
```

```json:(root)/src/package.json
{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "webpack --config webpack.config.js",
    "dev": "nodemon -L",
    "start": "node ./dist/server.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1"
  },
  "devDependencies": {
    "@types/express": "^4.17.12",
    "nodemon": "^2.0.7",
    "ts-loader": "^9.2.3",
    "typescript": "^4.3.2",
    "webpack": "^5.39.0",
    "webpack-cli": "^4.7.2",
    "webpack-node-externals": "^3.0.0"
  }
}
```

```json:(root)/src/tsconfig.json
{
  "compilerOptions": {
    "sourceMap": true, 
    "noImplicitAny": true, 
    "module": "es2015", 
    "target": "es2017", 
    "jsx": "react", 
    "lib": ["es2018", "dom"], 
    "moduleResolution": "node", 
    "removeComments": true, 
    "strict": true, 
    "noUnusedLocals": false, 
    "noUnusedParameters": false, 
    "noImplicitReturns": true, 
    "noFallthroughCasesInSwitch": true, 
    "strictFunctionTypes": false 
  }
}
```

```javascript:webpack.config.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  mode: 'development', 
  target: 'node', 
  externals: [ nodeExternals() ], 
  devtool: 'eval-source-map', 
  module: {
    rules: [
      {
        loader: 'ts-loader', 
        test: /\.ts$/, 
        exclude: [/node_modules/], 
        options: { configFile: 'tsconfig.json'}
      }
    ]
  }, 
  resolve: {
    extensions: ['.ts', '.js', '.json']
  }, 
  entry: './src/server.ts', 
  output: {
    filename: 'server.js', 
    path: path.resolve(__dirname, 'dist')
  }, 
  node: {
    __dirname: false
  }
};
```

### コンテナを生成する

ターミナルで以下のコマンドを実行します。

```powershell
docker compose up
```

VSCodeのエクスプローラ部を確認すると、srcフォルダにnode-modulesフォルダが生成され、配下にnpm installによりウンロードされたファイルが表示されます。
初期コードのTypeScriptファイルserver.tsがトランスパイルされ、distフォルダが生成され、server.jsファイルが出来上がります。
生成されたコンテナ内でnode.jsサーバが起動します。
ブラウザにて、localhost:3000にアクセスすると、"Hello World."が表示されます。

### 実験
#### コードの修正が反映するかの実験
server.tsを修正します。

```typescript:(root)/src/server.ts
//...省略...
app.get('/', (req: Express.Request, res: Express.Response, next: Express.NextFunction) => {
  //res.send('Hello World.');
  res.send('hogehoge.');
});
//...省略...
```

VSCodeで別ターミナルを開き、コンテ内に入りトランスパイルします。

```powershell
# コンテナ内に入る
docker exec -it docker-node-c1 /bin/bash

# 以下のような表示になる。
root@c34ee48d4afb:/app# 

# トランスパイルを実行する。
# nodemonが変更を検知し、node.jsサーバが再起動します。
root@c34ee48d4afb:/app# npm run build
```

ブラウザをリロードし、表示が"hogehoge."になることを確認します。

### コンテナ再起動後も修正が保持されているかの実験
docker compose upを実行したターミナルでctrl+cを押下し、コンテナを停止します。
ブラウザでlocalhost:3000にアクセスすると、
"このサイトにアクセスできません"
等の表示になります。

再度、docker compose upを実行し、コンテナを起動します。
ブラウザでlocalhost:3000にアクセスすると、"hogehoge."と表示され、コンテナにコードの修正が保持されていることが確認できます。

以上です。


## 奮闘記
ああでもない、こうでもないと、試した記録です。
### Dockerfileを使用する
ホストに完成package.json、ソースコードを持ち、それらをコンテ内にコピーしたイメージを考えました。
##### フォルダ構成

```
(root)
  |
  |--src/ (←このフォルダ以下をデータコンテナにマウントする。)
  |   |--dist/(※distフォルダ以下は、typescriptをトランスパイルした際に作成される。初めは存在しない。)
  |   |   |--server.js
  |   |
  |   |--nodo_modules(※npm install実行時にpackage.jsonをもとに展開される。初めは存在しない。)
  |   |
  |   |--src/
  |   |   |-server.ts
  |   |
  |   |--nodemon.json
  |   |--package.json
  |   |--tsconfig.json
  |   |--webpack.config.json
  |
  |--Dockerfile
```

#### Dockerfile

```:Dockerfile
FROM node:14

# コンテナ内の作業フォルダはapp
WORKDIR /app

# ./srcフォルダをコンテナの/appにコピーする。
# 依存関係が記録済みのpackage.json、その他ソースコードをコピーされる。
COPY ./src ./

# コンテナ内でコピーされたpackage.jsonを元に、パッケージがインストールされる。
RUN npm install 

# typescriptをトランスパイルする。
RUN npm run build

# nodemonでnode.jsサーバを起動する。
CMD npm run dev
```

このDockerfileをもとにイメージ"docker-node-image1"を作成します。
Dockerfileがあるディレクトリで以下のコマンドを実行します。

```powershell
docker build . -t docker-node-image1
```

イメージ"docker-node-image1"をもとにコンテナ"docker-node-c1"を作成します。

```powershell
docker run --name docker-node-c1 -p 3000:3000 docker-node-image1

# コンテナの生成、パッケージのインストール、タイプスクリプトのビルド、nodemonの起動まで行われます。
# 当方のターミナルでは以下のような表示になります。
> app@1.0.0 dev /app
> nodemon -L

[nodemon] 2.0.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): dist/**/*
[nodemon] watching extensions: ts,js,json
[nodemon] starting `node ./dist/server.js`
app running on port 3000.
```

ブラウザでlocalhost:3000にアクセスし、コンテナが起動していることを確認します。

続いてコンテナ内に入り、ファイルが展開されているかを確認します。
別のターミナルを開き、以下のコマンドを実行します。

```powershell
docker exec -it docker-node-c1 /bin/bash
```

```bash
# 以下のような表示になります。
root@932173c904c6:/app#

# dirコマンドでディレクトリを確認します。
root@932173c904c6:/app#dir

# 結果
dist          nodemon.json       package.json  tsconfig.json
node_modules  package-lock.json  src           webpack.config.js   
```

コンテナ内にホストからコピーしたファイルが展開されていることが確認できました。
確認ができたので、このコンテナは削除します。

```powershell
docker rm docker-node-c1
```

ここで私は、「よし、あとはコンテナ内のappフォルダにホストのフォルダをマウントすれば成功だ！」と思い、以下のコマンドを実行しました。

```powershell
# -vオプションの"C:\hoge\fuga"の部分はプロジェクトフォルダのルートです。
# ※-vオプション部分のホスト側のパス部分、フルパスでないと期待した挙動になりませんでした(/srcというような相対パスでは不可でした)。
docker run -p 3000:3000 -v C:\hoge\fuga\src:/app --name docker-node-c1 docker-node-image1
```

成功を確信していましたが結果はエラーでした。

```powershell
# 結果
> app@1.0.0 dev /app
> nodemon -L

sh: 1: nodemon: not found
npm ERR! code ELIFECYCLE
npm ERR! syscall spawn
npm ERR! file sh
npm ERR! errno ENOENT
npm ERR! app@1.0.0 dev: `nodemon -L`
npm ERR! spawn ENOENT
npm ERR!
npm ERR! Failed at the app@1.0.0 dev script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
npm WARN Local package.json exists, but node_modules missing, did you mean to install?

npm ERR! A complete log of this run can be found in:
npm ERR!     /root/.npm/_logs/2021-07-01T15_54_06_909Z-debug.log
```

エラーメッセージの
**Local package.json exists, but node_modules missing, did you mean to install?**
をGoogle翻訳にかけると、
>ローカルのpackage.jsonは存在しますが、node_modulesがありません。インストールするつもりでしたか？

とのことです。Dockerfileで「RUN npm install」を指定してたのになぜ？と思いました。
おそらくコンテナ生成時、以下のようなことが起こったのではないかと想像しています。
- イメージdocker-node-image1からコンテナを生成すると、/appディレクトリ内にはnode_modues、distフォルダが存在する。
- docker runコマンドの-v オプションにより、ホストの./srcフォルダの内容がコンテ内の/appに上書きされる。この時、ホスト側の./srcフォルダにはnode_modules、distフォルダはないので、コンテナ内の/appディレクトリのnode_moduels、distも消える。

エラーは発生しましたが、dokcer ps -aコマンドを確認すると、コンテナは生成されていました。
後のために削除します。

```
# コンテナ一覧確認
docker ps -a

# 結果 エラーは発生したが、コンテナ"docker-node-c1"は生成されている。
CONTAINER ID   IMAGE                                              COMMAND                  CREATED          STATUS                      PORTS     NAMES
5429c0746eb9   docker-node-image1                                 "docker-entrypoint.s…"   21 minutes ago   Exited (1) 21 minutes ago             docker-node-c1   
# 削除する。
docker rm docker-node-c1
```

次は、コンテナ生成後、bashを起動し、そこでnpm install等を試しました。

```powershell
docker run -it -p 3000:3000 -v C:\hoge\fuga\src:/app --name docker-node-c1 docker-node-image1 /bin/bash

# コンテナ生成後、コンテナ内に入り、以下のような表示になります。
root@2cde3c6232ad:/app# 

# ここでnpmコマンドを実行します。
root@2cde3c6232ad:/app# npm install
root@2cde3c6232ad:/app# npm run build
root@2cde3c6232ad:/app# npm run dev
```
ホスト側の./srcフォルダにnode_modules、distが現れ、node.jsサーバも立ち上がり、localhost:3000にアクセスすることができました。

コンテナ停止、再起動、を確認。

```powershell
# コンテナ停止
docker stop docker-node-c1

# コンテナ再起動
docker start docker-node-c1

# コンテナ内に入り、node.jsサーバを起動
docker exec -it docker-node-c1 /bin/bahs

# 以下はコンテナ内
# nodemonでnode.jsサーバを起動。
root@932173c904c6:/app#npm run dev
```
期待した挙動になりました。
実は、この部分記事の下書きを書きながら気づきました。これで、当初やりたかったことができてしまいました。

以上を振り返ると、
1. Dockerイメージにpackage.json、初期コードを持たせている。
2. Docerfileの"RUN npm install"でコンテナ生成時にnpm installを行っている。
3. しかし、docker run -vオプションで、ホストのsrcを上書きしている。
ということは、この例では、Dockerfileでソースのコピー等不要でした。
もっと言うと、Dockerfile用いてイメージの作成は不要でした。
Dockerhubに上がっているNode.jsのイメージを用いてコンテナを生成し、ホストのsrcフォルダをマウントすれば、やりたいことができました。

```powershell
# 先の例(再掲)
# doker run -it -p 3000:3000 -v C:\hoge\fuga\src:/app --name docker-node-c1 docker-node-image1 /bin/bash

# イメージでdocker-node-image1ではなくDockerhub上のイメージ"node:14"を用いる。
doker run -it -p 3000:3000 -v C:\hoge\fuga\src:/app --name docker-node-c1 node:14 /bin/bash

# コンテナ生成後、コンテナ内のbashが起動し、以下のような表示になります。
root@638e25fd74f5:/# 

# appディレクトリに移動します。
root@638e25fd74f5:/# cd app

# パッケージをインストールします。
root@638e25fd74f5:/app# npm install

# 以下トランスパイルや、npm start等の実行…省略
```

```powershell
# コンテナの削除
docker rm docker-node-c1
```

当例ではDockerfile用いる必要ありませんでしたが、個人的には実際にDockerfile記述し、挙動を確認、体験してみるという意味では、大変意義がありました。

 
## 感想
何度も、コンテナの生成、破棄をコマンドを実行し、行いました。
自分は、ドキュメントを読んで理屈を理解するよりも、実際に実行してみて、感覚を掴むほうが理解が進みました。いや、完全に理解はしていないと思いますが、dockerいいなと思いました。