在Docker中創建應用
【下面內容是在基于Docker,用node.js開發和部署網絡應用過程中獲得的經驗和教訓。】
本例中,將從頭開始開發一個基于Docker的socket.io聊天例子,一直到可以實用,因此希望可以從這些教訓中學到什么,例如:
使用Docker開始一個節點應用
不要做“root”敢死隊成員
用binds使得test-edit-reload流程更短
在容器內管理node_modules使得重建更快(有一個竅門)
使用npm shrinkwrap確保可重復使用
開發和生產團隊共享同一個Dockerfile
本文默認讀者已經對Docker和node.js有一定熟悉程度。如果需要了解Docker,可以參見這篇介紹文章。
開始
我們會從頭開始,最終的代碼可以從github獲得, 其中從頭到尾都有每一步的標簽。這里是第一步的代碼,如果感興趣可以訪問。
如果沒有Docker,就需要在節點上安裝系統以及其他依賴包,并且運行npm init來創建新包,這種方法并非不好,但是如果我們使用Docker的話,可以學習更多東西,(當然,使用Docker最重要的原因在于不需要在節點上安裝所有東西)。我們會以生成“bootstrapping container”作為開始,然后為應用設置npm包。
需要生成兩個文件,Dockerfile和docker-compose.yml,后續我們會加入更多內容。bootstrapping 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
這個文件很短,但是都很重要:
- 第一行,從最新long term support(LTS)官方發行版docker image開始。我傾向于指定一個特定版本,而不是一個浮動的標簽,例如node:argon 或者 node:latest,因此如果別人在一臺不同設備生成此image時,會得到相同版本,而不是因為版本升級帶來版本變化。
- 創建一個普通用戶,起名叫app,在容器中運行此app。如果不這樣,容器內進程將會以root運行,違反了安全最佳實踐。需要Docker指南為了簡單跳過這一步,雖然我們會做一些額外的工作,但是卻很重要。
- 安裝最新版本npm,最近npm變化很大,特別是npm shrinkwrap,以后shrinkwrap將會變化很大。同時,最好在Dockerfile中指定版本,以避免升級帶來的麻煩。
- 最后,我們在一個RUN內部嵌入了兩條命令,減少了生成映像的層數。本例中,并不很明顯,但是不使用太多層是一個好習慣,可以減少占用磁盤空間,節省下載時間;另外下載,解壓縮,創建,安裝以及清理時都可以一步完成,而不需要每一步都保存每層的增量部分。
下一步生成bootstrapping compose文件: docker-compose.yml:
chat:
build: .
command: echo 'ready'
volumes:
- .:/home/app/chat
文件定義了一個簡單服務,從Dockerfile創建。到這一步只是echo “ready”然后就退出。volume這一行:/home/app/chat,告訴docker掛載應用目錄,將主機上的 . 目錄掛載到容器內部/home/app/chat目錄,因此任何主機上變化會自動在容器內部出現,反之亦然。對于保證開發test-edit-reload周期盡量短非常重要。然而當npm安裝依賴包時候會出現一些問題,我們后面會談到。
現在,我們可以繼續了。運行docker-compose時,docker會生成如Dockerfile中指定的映像,用此映像啟動一個容器,運行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
啟動此映像生成一個容器,在其中運行交互式shell,運行初始包文件:
$ 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
在主機上可以看到如下結構,可以commit此版本內容:
$ tree
.
├── Dockerfile
├── docker-compose.yml
├── npm-shrinkwrap.json
└── package.json
可以訪問Github上的代碼 。
安裝依賴包
下一步是安裝應用的依賴包。我們希望通過Dockerfile在容器內部安裝,因此當第一次運行docker-compose up時,應用就是可用狀態。
為實現此功能,需要在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/*
++RUN chown -R app:app $HOME/*
+
USER app
WORKDIR $HOME/chat
+RUN npm install
同樣,很小的改變,但要點如下:
不僅是打包文件,還可以COPY主機上所有應用目錄到$HOME/chat,后面我們將會看到此時只拷貝必要文件將會在docker build時節省時間,其它可以在運行完npm install之后copy,這其實是更好利用docker build的分層cache功能。
通過COPY命令進入容器內的文件在容器內屬主是root,也就是說普通用戶app不能讀寫他們,因此使用chown改變文件屬性(當然如果可以在USER app之后做copy操作,而使得文件屬主是app用戶是最佳方案,但是現在還不是時候。)
最后,在運行npm instsall之前多加了一步,使得以用戶app權限運行,安裝所有依賴包到容器的$HOME/chat/node_modules目錄下。(另外,可以添加npm cache clean移除安裝時下載的tar文件;然而在重建image時并沒有幫助,只是增加空間而已。)
最有一點,當開發使用此映像時會引起一些麻煩,因為綁定了在容器內部$HOME/chat到主機的應用目錄。不幸的是,node_modules目錄在主機上并不存在,這一綁定實際上“隱藏”了我們安裝的node modules。
node_modules Volume 竅門
有幾個方法可以解決此問題,但是最優雅的解決辦法應該是使用一個內置包含node_modules的卷。為了實現此目的,需要在docker compose文件末尾加一行如下:
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盡管一點點變化,但是牽涉和后臺很大變化:
build過程中,npm install安裝依賴包(下一節中添加)到映像內的$HOME/chat/node_modules 目錄下,我們在影像中用藍色標識出來:
~/chat$ tree # in image
.
├── node_modules
│ ├── abbrev
...
│ └── xmlhttprequest
├── npm-shrinkwrap.json
└── package.json
當使用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目錄。
然而,通過上面的改變,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
我們所期望的都實現了:主機上的源文件都被綁定到容器,使得更快改變內容;容器內運行應用的依賴包也可用。(備注:這些卷中依賴包到底存放在哪里呢?簡短說,存放在主機上由docker管理的獨立目錄中,參見docker文檔中關于volume部分)
打包安裝和Shrinkwrap
重新生成映像,生成安裝包。
$ docker-compose build
... builds and runs npm install (with no packages yet)...
chat 應用需要特定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中持有下次build運行時的依賴包名稱。
使用npm shrinkwrap的原因如下:盡管可以在package.json中更新直接依賴包的版本,但是并不能更新一些松散依賴包的版本,這就意味著未來生成映像時,(如果不使用shrinkwrap)并不能保證拉下來的是同一版本的依賴包,使得應用出錯。這種問題經常出現,因此提倡使用shrinkwrap,如果熟悉ruby的dependency manager,npm-shrinkwrap.json功能跟Gemfile.locl是類似的。
最后,并不耗費任何多余的東西,因為容器就是在最后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/bashapp@912d123f3cea:~/chat$ ls node_modules/accepts cookie-signature depd ...
...
app@912d123f3cea:~/chat$ exit
可以訪問Github上的代碼
運行應用
最后終于可以安裝有應用了,生成index.js 和 index.html,如前所示,運行npm install --save安裝socket.io包:
在Dockerfile中,可以告訴docker當使用映像啟動容器時運行什么命令,例如node index.js,從docker compose文件中移除占位命令(dummy command),docker會從Dockerfile中運行這條命令。最后,告訴docker compose在容器中暴露3000端口給主機,以從瀏覽器中訪問:
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
最后需要重新build一次,就可以運行docker-compose了
$ 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上,需要做一些端口轉發工作將端口3000數據從boot2docker虛機轉發到主機)可以通過瀏覽器訪問:
可以訪問Github上的代碼
DevProd環境下的Docker
現在,開發環境應用運行在docker compose下,很酷的事情,下面看看其他可行的步驟:
如果想將應用部署到生產環境,很明顯需要將應用源碼生成到映像中,實現此功能,需要在執行完npm install后將應用目錄拷貝到容器中,這樣只有當package.json或者npm-shrinkwrap.json發生改變,docker將只重新運行npm install,而改變源文件并不會重新運行。注意,我們還需要解決以root權限拷貝文件的問題:
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文件,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狀態,當源文件發生改變時,容器自動重裝(reload)(注意,如果運行在Mac上,可能不會工作的很好,因為virtualbox共享目錄和inotify集成的不太好)。容器內運行npm install --save-dev nodemon,重新生成(rebuilding),可以覆蓋默認生產命令: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全路徑,因為nodemon作為npm依賴包被安裝;可以配置npm腳本運行nodemon,但是我運行時有一些問題。容器運行npm腳本會花大約10秒鐘shutdown(默認超時),因為npm并不從docker轉發TERM信號給實際進程。所以最好直接運行命令行(這個問題應該在npm3.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和映像用在不同環境中,盡管不是最節省空間的,因為我們可能在生產環境中安裝開發依賴包,但是我想這是dev-prod開發模式中性價比最好的方法。就如哲人所說“‘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.
(提示:運行npm 時帶--silent參數可以不輸出多余部分)
可以訪問Github上的代碼
結論
本文中我們使一個應用運行在基于docker的開發和生產系統中,真酷!
我們跳過了主機安裝的一些步驟這樣直接進入節點環境配置,希望不會造成困難,因為這只需要做一次。
npm將依賴包安裝到掛載卷子目錄下使得我們的方案實現相對麻煩一些,(其它解決方案,例如ruby的bundler,將依賴包安裝到其它路徑下)但是我們可以通過內置卷的技巧解決這個問題。
這只是一個很簡單的應用,后續會有很多基于此應用的討論,其思路將涵蓋:
架構項目與多服務基礎之上,例如一個API,一個worker和一個靜態前端。一個大repo看起來比管理每個服務各自的repo更容易,但是卻引入了其它的復雜性。
使用 npm link
減少服務間共享代碼包
使用docker代替其它生產環境下日志管理和進程監控
狀態和配置管理,包括數據庫遷移