DockerでのNodeアプリ構築で学んだこと

以下に紹介するのは、Dockerを使ってnode.js用のWebアプリケーションを開発、およびデプロイする際に、私が四苦八苦しながら学んだ秘訣やコツです。

このチュートリアル記事では、Dockerでsocket.ioのチャットサンプルを白紙の状態から本番状態へとセットアップしていきます。このプロセスを通じて、そうした秘訣などを簡単に習得していただければ幸いです。特に、以下のような内容について見ていきます。

  • 実際にDockerでNodeアプリケーションを起動する。
  • すべてをrootとして実行させない(悪いやり方です)。
  • 開発時のテスト-編集-リロードサイクルを短くするため、バインドを使用する。
  • 再構築を高速にするため、node_modulesをコンテナで管理する(これには秘訣があります)。
  • npm shrinkwrapで、ビルドを反復可能にする。
  • 開発環境と本番環境でDockerfileをシェアする。

このチュートリアルは、読者の皆さんがDockerやnode.jsにある程度、慣れ親しんでいることを前提としています。「Dockerとは何ぞや」という方は、私が以前に作成したDockerについてのスライドHacker Newsでの議論はこちら)を見ていただいてもいいですし、その他にも、探せば多くの解説記事が見つかるはずです。

それでは、始めましょう

今回は、ゼロからの作業です。最終的なコードはgithub上で閲覧可能で、ステップごとにタグが付けられています。最初のステップのコードがこちらにありますので、参考にされたい方はご覧ください。

Dockerがなければ、まずはNodeやその他のdependenciesをホストにインストールし、npm initを実行して新規パッケージを作成することから始めるでしょう。この作業は避けては通れませんが、最初からDockerを使えば話は変わります(これらのものをホストにインストールしなくていいというのは、Dockerを使う大きなポイントです)。その場合、始めにNodeがインストールされた「起動コンテナ」を作成し、それを使ってアプリ用のnpmパッケージをセットアップします。

ここで用意する必要があるのはDockerfiledocker-compose.ymlの2ファイルで、これらには後からもっと手を加えます。まず、起動用のDockerfileから見てみましょう。

FROM node:4.3.2

RUN useradd --user-group --create-home --shell /bin/false app &&\
  npm install --global npm@3.7.5

ENV HOME=/home/app

USER app
WORKDIR $HOME/chat

このファイルは比較的、短いですが、いくつか重要なポイントがあります。

  1. ここで使うのは、記事を書いている時点で最新の長期サポート(LTS)リリースに対応した公式のDockerイメージです。バージョンに関しては、node:argonnode:latestといった「流動的」なタグを使うよりは、具体的なバージョンを指定することをお勧めします。そうすれば、他の人が別のマシンでこのイメージを構築する際にも同じバージョンを使うことができますし、うかつにアップグレードをして頭を抱えるといったリスクも避けることができます。
  2. コンテナ内でアプリを実行するために、非特権ユーザ(事務的にappと名付けます)を作成します。これをしなければ、コンテナ内のプロセスがrootとして実行してしまい、セキュリティのベストプラクティスと原則に反することになってしまいます。Dockerの多くのチュートリアルでは、内容をシンプルにするため、このステップを飛ばしていますが、事をうまく運ぶには余分な作業が必要で、このことは非常に重要だと思います。
  3. より新しいバージョンのnpmをインストールします。これは必須というわけではありませんが、npmは近年、頻繁に改善されていますし、特にnpm shrinkwrapのサポートについては、新しいshrinkwrapほど充実しています。なお、繰り返しになりますが、以降のビルドへの予期しないアップグレードを防ぐためにも、Dockerfile内に具体的なバージョンを明示するのが、私はベストだと思います。
  4. 最後に、単一のRUNコマンドにおいて2つのシェルコマンドをチェーンするという点に注意してください。こうすることで、結果として得られるイメージでレイヤ数を減らすことができます。このことは今回の例では大きな影響はありませんが、いずれにしても必要以上にレイヤを使わないよう習慣付けるのは望ましいことです。例えばですが、パッケージのダウンロード、解凍、ビルド、インストール、クリーンアップなどは、ステップごとに中間ファイルを有するレイヤを保存するよりも、1ステップでした方がディスクスペースやダウンロード時間を大幅に節約できます。

では次に、起動構成ファイル、docker-compose.ymlに移ります。

chat:
  build: .
  command: echo 'ready'
  volumes:
    - .:/home/app/chat

これはDockerfileから構築される単一のサービスを定義したもので、現時点の動作はreadyをechoしexitするのみです。volumeの行の.:/home/app/chatは、ホスト上のアプリケーションフォルダ.をコンテナ内の/home/app/chatフォルダにマウントするようDockerに指示しているため、ホストのソースファイルに加えられた変更はコンテナ内部に自動で反映されますし、逆もまたしかりです。このことは開発時のテスト-編集-リロードサイクルを短くするのに非常に重要となりますが、npmがdependenciesをインストールする際にある問題を生みます。それについては後ほど触れます。

ここまでは非常に順調です。docker-compose upを実行すると、Dockerは指定されたとおりにDockerfile内でnodeをセットアップしてイメージを作成し、そのイメージを有するコンテナを立ち上げてechoコマンドを実行します。すべてが問題なく設定されていることが分かりますね。

$ docker-compose up
Building chat
Step 1 : FROM node:4.3.2
 ---> 3538b8c69182
...
lots of build output
...
Successfully built 1aaca0ac5d19
Creating dockerchatdemo_chat_1
Attaching to dockerchatdemo_chat_1
chat_1 | ready
dockerchatdemo_chat_1 exited with code 0

これで、同じイメージから作成されたコンテナ内で対話型シェルを実行し、それを使用して最初のパッケージファイルをセットアップすることができるようになります。

$ docker-compose run --rm chat /bin/bash
app@e93024da77fb:~/chat$ npm init --yes
... writes package.json ...
app@e93024da77fb:~/chat$ npm shrinkwrap
... writes npm-shrinkwrap.json ...
app@e93024da77fb:~/chat$ exit

ファイルはホスト上にあるため、バージョン管理を行えます。

$ tree
.
├── Dockerfile
├── docker-compose.yml
├── npm-shrinkwrap.json
└── package.json

ここまでの結果のコードをgithubで確認してみてください。

dependenciesのインストール

次のステップは、アプリのdependenciesのインストールです。docker-compose upを最初に実行した時にアプリの使用準備が整うよう、dependenciesはDockerfileを経由してコンテナ内にインストールされるようにします。

これを行うにはDockerfilenpm installを実行する必要がありますが、その前に、それがイメージに読み込むpackage.jsonnpm-shrinkwrap.jsonファイルを取得しなければなりません。変更は以下のようになります。

diff --git a/Dockerfile b/Dockerfile
index c2afee0..9cfe17c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,5 +5,9 @@ RUN useradd --user-group --create-home --shell /bin/false app &&\

 ENV HOME=/home/app

+COPY package.json npm-shrinkwrap.json $HOME/chat/
+RUN chown -R app:app $HOME/*
+
 USER app
 WORKDIR $HOME/chat
+RUN npm install

変更点はわずかですが、ここでも重要なポイントがいくつかあります。

  1. ここでは単にパッケージファイルだけでなく、ホスト上のすべてのアプリケーションフォルダを$HOME/chatCOPYすることができますが、後で見るように、この時点で必要なものだけをコピーし、残りはnpm installの実行後にコピーした方がDockerの構築においては時間を節約できます。これはdocker buildのレイヤキャッシングを活用することによるものです。
  2. COPYコマンドでコンテナにコピーされたファイルは結果的にコンテナのrootによって所有されます。これはつまり、非特権のappユーザはそれらを読み書きできないということで好ましくありません。そこで、それらをコピーした後、単純にappchownします(もしUSER appステップの後にCOPYを移動することが可能で、appユーザとしてファイルをコピーできればいいのですが、(まだ)そうはいきません)。
  3. 最後に、npm installを実行するためのステップを最後尾に追加します。これはappユーザとして実行され、コンテナ内の$HOME/chat/node_modulesにdependenciesをインストールします(補足事項:インストール時、npmがダウンロードするtarファイルを消去するためにnpm cache cleanを追加しましょう(tarファイルはイメージが再構築されると役に立たなくなり、スペースを取るだけです))。

最後のポイントは、コンテナの$HOME/chatをホストのアプリケーションフォルダにバインドしているため、開発中のイメージを使う時にはいくつかの問題を引き起こします。残念ながらnode_modulesフォルダがホスト上に存在しないため、このバインドがインストールしたnode_modulesを見事に「非表示」にしてしまうのです。

node_modulesのボリュームトリック

この問題にはいくつか解決策がありますが、一番スマートだと思うのはnode_modulesを含むようバインド内でボリュームを使う方法です。これをするには、docker-composeファイルの最後尾に1行を付け加えればいいだけです。

diff --git a/docker-compose.yml b/docker-compose.yml
index 9e0b012..9ac21d6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,3 +3,4 @@ chat:
   command: echo 'ready'
   volumes:
     - .:/home/app/chat
+    - /home/app/chat/node_modules

作業としては単純ですが、裏では複雑なことが行われています。

1. ビルド中、npm installがイメージ内の$HOME/chat/node_modulesにdependencies(次のセクションで追加します)をインストールします。イメージ由来のファイルには青で色付けしています。

~/chat$ tree # in image
.
├── node_modules
│   ├── abbrev
...
│   └── xmlhttprequest
├── npm-shrinkwrap.json
└── package.json

2. 後でcomposeファイルを使ってイメージからコンテナを立ち上げる際、Dockerはまず$HOME/chat以下にあるコンテナ内のホストからアプリケーションフォルダをバインドします。ホスト由来のファイルには赤で色付けしています。

~/chat$ tree # in container without node_modules volume
.
├── Dockerfile
├── docker-compose.yml
├── node_modules
├── npm-shrinkwrap.json
└── package.json

厄介なのは、イメージのnode_modulesはバインドによって非表示となっているため、コンテナ内においてホスト上で見えるのは空のnode_modulesフォルダのみだということです。

3. ただし、まだ終わりではありません。Dockerは次に$HOME/chat/node_modulesのコピーを含むボリュームをイメージ内に作成し、コンテナにマウントします。これによりホスト上では、バインドからnode_modulesが隠された状態になり影響を受けなくなります。

~/chat$ tree # in container with node_modules volume
.
├── Dockerfile
├── docker-compose.yml
├── node_modules
│   ├── abbrev
...
│   └── xmlhttprequest
├── npm-shrinkwrap.json
└── package.json

これで希望どおりになりました。ホスト上のソースファイルはコンテナ内部にバインドされているため、高速な変更が可能です。また、コンテナ内部でdependenciesが利用できるため、それらを使ってアプリケーションを実行することもできます。

(補足事項:これらの依存ファイルが、実際にボリューム内のどこに保存されるのか不思議に思われるかもしれません。端的に言えば、Dockerが管理するホスト上の別のディレクトリに保存されています。詳しくはボリュームに関するDockerの文書をご覧ください。)

パッケージのインストールとshrinkwrap

それではイメージを再構築して、パッケージをインストールしましょう。

$ docker-compose build
... builds and runs npm install (with no packages yet)...

チャットアプリにはexpress@4.10.2が必要なので、それをnpm installし、--savepackage.jsonに依存関係を保存します。必要に応じてnpm-shrinkwrap.jsonをアップデートしてください。

$ docker-compose run --rm chat /bin/bash
app@9d800b7e3f6f:~/chat$ npm install --save express@4.10.2
app@9d800b7e3f6f:~/chat$ exit

なお、ここでは具体的なバージョンを指定する必要はなく、npm install --save expressを実行して最新のバージョンを取得するだけで構いません。package.jsonとshrinkwrapは、次にビルドが実行された時に、そのバージョンで依存関係を保持します。

npmのshrinkwrap機能を使う理由は、package.jsonでは直接の依存関係があるバージョンの修正はできるものの、あやふやに指定された可能性のある依存関係のバージョンについては修正ができないからです。もし、あなたや他の誰かが将来的にイメージを再構築する場合、(shrinkwrapを使わない限り)それが間接的な依存関係の異なるバージョンをプルダウンし、アプリケーションを破壊しないという保証はありません。経験上、これは皆さんが想像するよりもはるかに頻繁に起こることのように思えるため、私はshrinkwrapの使用を推奨しています。Rubyには優れた依存管理マネージャのBundlerがありますが、比較すると、npm-shrinkwrap.jsonGemfile.lockのようなものかと思います。

最後に注目したいのは、ここでは1回限りのdocker-compose runとしてコンテナを実行しているため、インストールした実際のモジュールは消滅します。しかし次にdocker buildを行うと、Dockerはpackage.jsonとshrinkwrapに変更があること、そしてnpm installの再実行が必要であることを検出します。これはとても重要なことで、必要なパッケージはその後、イメージにインストールされます。

$ docker-compose build
... lots of npm install output
$ docker-compose run --rm chat /bin/bash
app@912d123f3cea:~/chat$ ls node_modules/
accepts              cookie-signature  depd ...
...
app@912d123f3cea:~/chat$ exit

ここまでの結果のコードはgithubをご覧ください。

アプリの実行

やっとアプリケーションをインストールする準備ができました。まずは残りのソースファイル、つまりindex.jsindex.htmlをコピーします。それから、前のセクションでやったようにnpm install --saveを使ってsocket.ioのパッケージをインストールしてください。

これでDockerfileでは、node index.jsのイメージを使ってコンテナを起動した時に、どのコマンドを実行するのかをDockerに伝えることができるようになりました。なお、DockerfileからのコマンドをDockerが実行するように、ここでdocker-composeファイルからダミーのコマンドを削除しておいてください。最後に、ホスト上のコンテナの3000番ポートを開放するようdocker-composeに指示すれば、ブラウザでアクセスできるようになります。

diff --git a/Dockerfile b/Dockerfile
index 9cfe17c..e2abdfc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,3 +11,5 @@ RUN chown -R app:app $HOME/*
 USER app
 WORKDIR $HOME/chat
 RUN npm install
+
+CMD ["node", "index.js"]
diff --git a/docker-compose.yml b/docker-compose.yml
index 9ac21d6..e7bd11e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,7 @@
 chat:
   build: .
-  command: echo 'ready'
+  ports:
+    - '3000:3000'
   volumes:
     - .:/home/app/chat
     - /home/app/chat/node_modules

最後のビルドをすれば、docker-compose upによる実行の準備が完了です。

$ docker-compose build
... lots of build output
$ docker-compose up
Recreating dockerchatdemo_chat_1
Attaching to dockerchatdemo_chat_1
chat_1 | listening on *:3000

これで(Macの場合は、boot2docker–vmからホストに3000番ポートを転送させるために少しばかりいじる必要があります)、http://localhost:3000で実行しているのが確認できるはずです。

Docker chat demo working!

ここまでの結果のコードはgithubをご覧ください。

開発、および本番向けのDocker

やっとアプリをdocker-composeの開発環境下で実行させることができました。素晴らしいですね。では、次なるステップは何かを見ていきましょう。

本番環境にアプリケーションイメージをデプロイしたい場合、アプリケーションソースを前述のイメージにビルドすることになります。これを行うには、npm installの後、コンテナにアプリケーションフォルダをコピーすればいいだけです。これでDockerは、ソースファイルに変更があった時ではなく、package.jsonまたはnpm-shrinkwrap.jsonに変更があった場合のみnpm installを再実行します。なお、rootとしてファイルをコピーするCOPYの問題については、ここでも回避が必要です。

diff --git a/Dockerfile b/Dockerfile
index e2abdfc..68d0ad2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,4 +12,9 @@ USER app
 WORKDIR $HOME/chat
 RUN npm install

+USER root
+COPY . $HOME/chat
+RUN chown -R app:app $HOME/*
+USER app
+
 CMD ["node", "index.js"]

これで、ホストからのボリュームなしに、スタンドアロンでコンテナを実行できるようになります。docker-composeは、composeファイル内でコードの重複を避けるため、複数のcomposeファイルを構成できますが、今回のアプリケーションは非常にシンプルなので、単純に2つめのcomposeファイル、docker-compose.prod.ymlを追加して、本番環境でアプリケーションを実行させます。

chat:
  build: .
  environment:
    NODE_ENV: production
  ports:
    - '3000:3000'

以下により、「本番モード」でアプリケーションが実行できます。

$ docker-compose -f docker-compose.prod.yml up
Recreating dockerchatdemo_chat_1
Attaching to dockerchatdemo_chat_1
chat_1 | listening on *:3000

同様に、例えばソースファイルに変更があった時にコンテナ内を自動リロードするnodemonの下でアプリケーションを実行させれば、コンテナを開発用に特化できます(ただしDocker Machineを使ったMacでは、virtualboxのsharedフォルダがinotifyでは動かないため、完全に動作するとは言えません。この状況が早く改善されるといいのですが)。また、コンテナでnpm install --save-dev nodemonを実行し再構築すれば、開発用途としてより適切な設定のコンテナ内でデフォルトのproductionコマンド、node index.jsをオーバーライドすることが可能です。

diff --git a/docker-compose.yml b/docker-compose.yml
index e7bd11e..d031130 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,8 @@
 chat:
   build: .
+  command: node_modules/.bin/nodemon index.js
+  environment:
+    NODE_ENV: development
   ports:
     - '3000:3000'
   volumes:

なお、nodemonに関しては、パス上ではなくnpmの依存関係としてインストールされるので、フルパスを与える必要があります。npmスクリプトをセットアップしてnodemonを実行することもできますが、私自身はそのアプローチで問題に遭遇しました。npmスクリプトを実行しているコンテナでは、npmTERMシグナルをDockerから実際のプロセスに転送しないため、シャットダウンに10秒間ほどかかる傾向があります(デフォルトのタイムアウト)。したがって直接コマンドを実行させた方が、より良い結果が得られるようです。(追記:この問題はnpm 3.8.1以上で修正されたようです。これで、npmスクリプトをコンテナで使えるようになりますね!)

$ docker-compose up
Removing dockerchatdemo_chat_1
Recreating 3aec328ebc_dockerchatdemo_chat_1
Attaching to dockerchatdemo_chat_1
chat_1 | [nodemon] 1.9.1
chat_1 | [nodemon] to restart at any time, enter `rs`
chat_1 | [nodemon] watching: *.*
chat_1 | [nodemon] starting `node index.js`
chat_1 | listening on *:3000

docker-composeファイルを特化すれば、同一のDockerfileやイメージを複数の環境にわたって使用できるようになります。本番環境にdevDependenciesをインストールするため、スペース的には最も効率的とは言えませんが、開発環境と本番環境のより良い等価性にとっては、わずかな犠牲ではないかと思います。ある賢人がかつて言ったように、「test as you fly, fly as you test(飛ぶようにテストし、テストするように飛ぶ)」ということです。そういえば今回はテストをしていませんが、実行するのは簡単です。

$ docker-compose run --rm chat /bin/bash -c 'npm test'
npm info it worked if it ends with ok
npm info using npm@3.7.5
npm info using node@v4.3.2
npm info lifecycle chat@1.0.0~pretest: chat@1.0.0
npm info lifecycle chat@1.0.0~test: chat@1.0.0

> chat@1.0.0 test /home/app/chat
> echo "Error: no test specified" && exit 1

Error: no test specified
npm info lifecycle chat@1.0.0~test: Failed to exec test script
npm ERR! Test failed.  See above for more details.

(補足事項:--silentを指定してnpmと実行すると、余分な出力が取り除かれます。)

github上の最終的なコードはこちらです。

まとめ

  • Dockerでアプリを作成し、開発環境と本番環境で完全に実行させました。素晴らしい!
  • ホスト上に何もインストールすることなしにNode環境を起動する試練がありましたが、それが皆さんにとって何らかの糧になれば幸いです。なお、それをするのは1回限りで十分です。
  • サブフォルダにNodeやnpmのdependenciesを置くのは、例えばRubyのBundlerのようにdependenciesを別の場所にインストールするような他のソリューションに比べると、少しばかり複雑な方法と言えます。しかし、「ボリュームを入れ子」にするトリックで、その問題は簡単に解決できました。
  • 今回のアプリケーションは非常にシンプルなので、以下に挙げるように、できることはまだまだあります。
    • 例えばAPI、Service Worker、静的なフロントエンドといった複数のサービスを持つプロジェクトを構築する。単一の大規模なレポジトリの方が、サービスをそれぞれのレポジトリに分割するよりも管理が簡単に思えますが、それ固有の複雑さもあります。
    • npm linkを使用して、サービス間で共有されるパッケージでコードを再利用する。
    • Dockerを使用して、本番環境で他のログ管理ツールとプロセス監視ツールに交換する。
    • データベースの移行などで状態および構成の管理をする。

ここまで読んでくださった方は、ぜひ私のtwitterもフォローしていただければ幸いです。また、Overleafでは求人も募集していますよ:)

原稿のチェックをしてくれたMichael MazourJohn Hammersleyに感謝します。