以下に紹介するのは、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パッケージをセットアップします。
ここで用意する必要があるのはDockerfile
とdocker-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
このファイルは比較的、短いですが、いくつか重要なポイントがあります。
- ここで使うのは、記事を書いている時点で最新の長期サポート(LTS)リリースに対応した公式のDockerイメージです。バージョンに関しては、
node:argon
やnode:latest
といった「流動的」なタグを使うよりは、具体的なバージョンを指定することをお勧めします。そうすれば、他の人が別のマシンでこのイメージを構築する際にも同じバージョンを使うことができますし、うかつにアップグレードをして頭を抱えるといったリスクも避けることができます。 - コンテナ内でアプリを実行するために、非特権ユーザ(事務的に
app
と名付けます)を作成します。これをしなければ、コンテナ内のプロセスがrootとして実行してしまい、セキュリティのベストプラクティスと原則に反することになってしまいます。Dockerの多くのチュートリアルでは、内容をシンプルにするため、このステップを飛ばしていますが、事をうまく運ぶには余分な作業が必要で、このことは非常に重要だと思います。 - より新しいバージョンのnpmをインストールします。これは必須というわけではありませんが、npmは近年、頻繁に改善されていますし、特に
npm shrinkwrap
のサポートについては、新しいshrinkwrapほど充実しています。なお、繰り返しになりますが、以降のビルドへの予期しないアップグレードを防ぐためにも、Dockerfile内に具体的なバージョンを明示するのが、私はベストだと思います。 - 最後に、単一の
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
を経由してコンテナ内にインストールされるようにします。
これを行うにはDockerfile
でnpm install
を実行する必要がありますが、その前に、それがイメージに読み込むpackage.json
とnpm-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
変更点はわずかですが、ここでも重要なポイントがいくつかあります。
- ここでは単にパッケージファイルだけでなく、ホスト上のすべてのアプリケーションフォルダを
$HOME/chat
にCOPY
することができますが、後で見るように、この時点で必要なものだけをコピーし、残りはnpm install
の実行後にコピーした方がDockerの構築においては時間を節約できます。これはdocker build
のレイヤキャッシングを活用することによるものです。 COPY
コマンドでコンテナにコピーされたファイルは結果的にコンテナのrootによって所有されます。これはつまり、非特権のapp
ユーザはそれらを読み書きできないということで好ましくありません。そこで、それらをコピーした後、単純にapp
にchown
します(もしUSER app
ステップの後にCOPY
を移動することが可能で、app
ユーザとしてファイルをコピーできればいいのですが、(まだ)そうはいきません)。- 最後に、
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
し、--save
でpackage.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.json
はGemfile.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.js
とindex.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
で実行しているのが確認できるはずです。
ここまでの結果のコードは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スクリプトを実行しているコンテナでは、npm
がTERM
シグナルを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 MazourとJohn Hammersleyに感謝します。