本文分析了用docker搭建一個Web全棧項目(vue+nginx+node+mongodb)運行環境時碰到的問題,以一個開箱即用的項目為例,整理了制作應用docker鏡像的基礎模板和一些使用技巧。
現在越來越多的項目采用vue+nginx+node+mongodb
的組合,這樣一個JS全棧工程師就可以獨立搞定一個完整的應用。要達到這個目標,只會敲代碼是不夠的,還要能搞定運行環境。通過使用docker可以極大簡化運行環境的搭建,并且給開發和運維的銜接工作帶來便利。
典型的全棧項目包括3個部分:前端(vue+nginx
),后端(node
)和數據庫(mongodb
)。從部署的角度看,每個部分又可以分為3個部分:應用(代碼+配置)、中間件和主機。docker
解決了中間件和主機的組合問題,我們用到的各種中間件都有標準docker鏡像,通過docker,它們的安裝和運行都實現了標準化。我們真正要干的活是,如何以中間件鏡像為基礎,加上應用代碼和環境配置,制作項目的應用鏡像。有了應用鏡像,后面的工作就可以由運維接手了。
因此,對于全棧工程師來說,需要掌握一項新技能:制作應用鏡像。
下面,通過一個實際項目,描述一種制作應用鏡像的通用方法。
項目概況
tms-finder
項目是一個在線文檔管理系統,back
目錄下是用node
實現的后端服務,ue
目錄下是用Vue
實現的用戶端應用,build
后部署到nginx
。上傳文件時用戶可以輸入文件的描述信息(可配置),文件會存在放在服務端指定的本地硬盤上(可配置),描述信息會保存在指定的mongodb
中(可配置)。
這個項目是開箱即用的,在安裝好docker
和docker-compose
的機器上,從github
拉取代碼,執行docker-compose up -d
命令就可以把整個應用運行起來。
這個項目是環境友好的,制作的默認鏡像可以靈活部署在不同的環境中(通過設置環境變量),也可以根據環境的要求制作新的鏡像(通過設置構建參數)。
這個項目是編碼友好的,程序員可以有選擇地使用docker,前后端都可以在容器外運行,方便調試代碼。
關鍵概念
node環境變量
后臺服務是node
應用,Vue
本質上也是node
應用,所以應該知道node
的使用環境變量的方式。node
中通過process.env
這個對象訪問環境變量,該對象可以修改,但是只會在應用內有效。
進入容器后,可以通過下面的命令查看可用的環境變量:
node -e "console.log(process.env)"
需要注意的是,使用vue-service-cli
命令時,Vue對process.env
做了處理,并不直接傳遞所有環境變量,而是要通過.env
傳遞,且必須以VUE_APP_
開頭。這會對制作鏡像產生影響。
docker環境變量
docker-compose
中和環境變量設置相關的指令主要是enviroment
和env_file
,另外,多配置文件也會影響影響環境變量設置,詳細信息請看在線文檔。tms-finder
項目中需要知道的是,在compose配置文件中設置的變量會傳遞給容器(process.env),docker-compose.override.yml
中的內容會覆蓋docker-compose.yml
中的內容。(后面會用到)
我采用在docker-compose.yml
定義環境變量的默認值(在版本庫),如果需要修改就通過docker-compose.override.yml
覆蓋(不在版本庫)。
在tms-finder
中包含了這兩個文件,docker-compose.yml
用于指定基礎設置,docker-compose.override.yml
用于指定和運行環境相關的設置,例如:端口號等。如果有更多的配置要求,可以通過docker-compose -f
解決。
注意:端口(ports)不能通過覆蓋,環境變量(environment)和文件卷(volumes)可以,所以端口沒有寫在
docker-compose.yml
文件中,這樣有利于復用。另外,只能是“覆蓋”并不能“清除”,例如:volumns
只能從一個設置改成另一個,而不能清除掉。
參考:https://docs.docker.com/compose/environment-variables/
參考:https://docs.docker.com/compose/extends/
強調一個概念,環境變量是作用于容器的,在構造階段是無效的。
docker參數(ARG)
Dockerfile
有個ARG
指令,用來定義在構造鏡像時,從外部接收的參數。在docker-compose
中和ARG
對應的是build/args
。
通過ARG在鏡像構造階段傳遞參數。
docker網絡
docker網絡涉及很多內容,現在只需要記住一點,docker-compose.yml
中定義的服務會被自動添加到一個默認docker的網絡(tms-finder_default)中,服務之間可以將服務名用作主機名相互訪問。
參考:https://docs.docker.com/compose/networking/
數據庫(mongodb)
mongodb
的標準鏡像是以ubuntu
為基礎,需要調整時區,編寫mongodb/Dockerfile
文件。
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
分別在docker-compose.yml
和docker-compose.override.yml
中添加服務。
mongodb:
build: ./mongodb
image: tms-finder/mongo:latest
container_name: tms-finder-mongo
mongodb:
volumes:
- ./mongodb/data:/data/db
ports:
- '27017:27017'
# logging:
# driver: 'none'
存儲數據文件
這里需要注意volumes
部分。通常,數據庫中間件應該將數據目錄掛載在主機的路徑上,這樣每次重啟容器數據還在(docker-compose.override.yml中的設置)。但是,有時候可能并不需要持久化數據(在windows環境下不能掛載宿主機的目錄),例如:測試,這時可以把數據放在容器內部,重啟容器就可以清理數據了。
啟動服務
編碼階段如果為了方便調試,可以單獨啟動mongodb服務,命令如下:
docker-compose up mongodb
后端(node)
編寫back/Dockerfile
文件,將后端代碼放在標準node
鏡像中形成新鏡像。
FROM node:alpine
# 設置時區
RUN sed -i 's?http://dl-cdn.alpinelinux.org/?https://mirrors.aliyun.com/?' /etc/apk/repositories && \
apk add -U tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
apk del tzdata
RUN npm install cnpm -g
WORKDIR /home/node/app
COPY . .
RUN cnpm i
CMD [ "node", "app" ]
選擇node
的官方標準鏡像node:alpine
。用npm
和yarn
安裝依賴包都會失敗,所以在鏡像中安裝并使用cnpm
。這里提個醒,應該避免從操作系統的鏡像開始制作自己的鏡像,優先從hub.docker.com
上找中間件官方鏡像,并且按照在線文檔進行相應的設置,這樣省很多事。
在docker-compose.yml
中添加服務。
back:
build: ./back
image: tms-finder/back:latest
container_name: tms-finder-back
# ports:
# - '3000:3000'
environment:
- NODE_ENV=production
- TMS_FINDER_MONGODB_HOST=mongodb
- TMS_FINDER_MONGODB_PORT=27017
- TMS_FINDER_FS_ROOTDIR=/home/storage
volumes:
- ./back/storage/upload:/home/storage/upload # 指定上傳文件的外部存儲位置
command: ['./wait-for.sh', 'mongodb:27017', '-t', '300', '--', 'node', 'app']
在docker-compose.override.yml
中添加服務。
back:
ports:
- '3000:3000'
設置環境變量
后端服務中使用了多個配置文件,包括:連接mongodb
的config/mongodb.js
,設置文件上傳服務的config/fs.js
等。因為鏡像只是一個“模板”,實際部署時運行環境不是確定的,例如:可能在生產環境中提供了獨立的mongodb服務,需要通過配置將應用指向這個服務。我們通過設置環境變量解決這個問題。
下面以連接mongodb服務的配置文件config/mongodb.js
為例:
module.exports = {
master: {
host: process.env.TMS_FINDER_MONGODB_HOST,
port: parseInt(process.env.TMS_FINDER_MONGODB_PORT)
}
}
docker-compose.yml
文件中enviroment
指令部分定義了環境變量TMS_FINDER_MONGODB_HOST
和TMS_FINDER_MONGODB_PORT
的值,容器啟動后,node
中可以通過process.env
訪問這些環境變量。注意這里的TMS_FINDER_MONGODB_HOST=mongodb
,其中的mongodb
是服務名,前面提到可以將服務名作為主機名進行訪問。
服務啟動順序
通過docker-compose.yml
同時啟動多個服務時存在啟動順序的問題:后端服務back
要連接mongodb
,但是,如果mongodb
啟動需要很長時間(例如:數據文件放在宿主機上),back
服務就有可能因連接超時啟動失敗。為了解決這個問題,我找到了一個腳本wait_for
。通過這個腳本可以測試指定的端口是否已經可用,如果在指定時間內確定可用,就執行后面的命令。
參考:https://docs.docker.com/v17.12/compose/startup-order/
啟動指定服務
如果為了調試前端代碼,只需要同時啟動mongodb
和back
,可以執行如下命令:
docker-compose up mongodb back
容器外運行
不用容器啟動時node
服務時,用npm run pm2
啟動。pm2
支持設置環境變量,在ecosystem.config.js
文件中進行設置。
env: {
NODE_ENV: 'development',
TMS_FINDER_MONGODB_HOST: 'localhost',
TMS_FINDER_MONGODB_PORT: 27017
}
容器外運行主要是為了方便調試代碼,通過容器啟動的mongodb
就在本機,所以主機地址設置為localhost
。
參考:https://pm2.keymetrics.io/docs/usage/environment/
前端鏡像(vue+nginx)
Vue項目要部署的內容是通過yarn build
命令生成的靜態代碼,這些代碼可以部署到任何WebServer中,例如:nginx。
編寫ue/Dockerfile
文件。
# 標準基礎鏡像(構建階段)
FROM node:alpine
RUN npm install cnpm -g
WORKDIR /home/node/app
COPY . .
# 生成.env文件
ARG vue_app_base_url
ARG vue_app_auth_server
ARG vue_app_login_key_username=username
ARG vue_app_login_key_password=password
ARG vue_app_login_key_pin=pin
ARG vue_app_api_server
RUN echo VUE_APP_BASE_URL=$vue_app_base_url > .env && \
echo VUE_APP_AUTH_SERVER=$vue_app_auth_server >> .env && \
echo VUE_APP_LOGIN_KEY_USERNAME=$vue_app_login_key_username >> .env && \
echo VUE_APP_LOGIN_KEY_PASSWORD=$vue_app_login_key_password >> .env && \
echo VUE_APP_LOGIN_KEY_PIN=$vue_app_login_key_pin >> .env && \
echo VUE_APP_API_SERVER=$vue_app_api_server >> .env
# 安裝依賴包,構建代碼
RUN cnpm i && yarn build
# 標準基礎鏡像(部署階段)
FROM nginx:alpine
# 設置時區
RUN sed -i 's?http://dl-cdn.alpinelinux.org/?https://mirrors.aliyun.com/?' /etc/apk/repositories && \
apk add -U tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
apk del tzdata
# 修改配置文件
ADD ./nginx.conf.template /etc/nginx/nginx.conf.template
ADD ./start_nginx.sh /usr/local/bin/start_nginx.sh
RUN chmod +x /usr/local/bin/start_nginx.sh
# 將構建階段代碼放在指定位置
COPY --from=0 /home/node/app/dist /usr/share/nginx/html
CMD ["start_nginx.sh"]
在docker-compose.yml
中添加服務。
ue:
build:
context: ./ue
args:
vue_app_base_url: /finder_ue
vue_app_auth_server: http://localhost:3000
vue_app_login_key_username: username
vue_app_login_key_password: password
vue_app_login_key_pin: pin
vue_app_api_server: http://localhost:3000
image: tms-finder/ue:latest
container_name: tms-finder-ue
environment:
- NGINX_WEB_BASE_URL=/finder_ue
- NGINX_ACCESS_CONTROL_ALLOW_ORIGIN=*
# ports:
# - '8080:80'
在docker-compose.override.yml
中添加服務。
ue:
ports:
- '8080:80'
多階段構建
FROM can appear multiple times within a single Dockerfile to create multiple images or use one build stage as a dependency for another. Simply make a note of the last image ID output by the commit before each new FROM instruction. Each FROM instruction clears any state created by previous instructions.
Optionally a name can be given to a new build stage by adding AS name to the FROM instruction. The name can be used in subsequent FROM and COPY --from=<name|index> instructions to refer to the image built in this stage.
上面兩段來自docker的官方文檔,簡單說就是在一個Dockerfile
中,可以有多個FROM
,每個構成一個階段,后面的階段可使用前面階段生成的內容,最終生成的鏡像只包含最后一個FROM
的內容。
這個特性恰好滿足了制作Vue前端代碼鏡像的需求,因為build
要依賴node
環境,但是最終運行環境只需要nginx
和代碼。所以ue/Dockerfile
分為了構建和部署兩個階段,構建階段生成代碼,然后在部署階段放到nginx
的指定目錄。
關于路由(VueRouter)
通常,復雜一些的Vue項目中都會用到Router
,如果采用html5的history
模式,需要在nginx.conf
文件中進行設置。
參考:https://router.vuejs.org/zh/guide/essentials/history-mode.html
在ue
目錄下編制nginx.conf.template
文件。
location $NGINX_WEB_BASE_URL/web {
root /usr/share/nginx/html;
try_files $uri $uri/index.html $NGINX_WEB_BASE_URL/web/index.html;
}
try_files
是nginx
的指令,功能是查找指定的文件,找到了就返回,如果找不到就轉發最后1個url。在配置路由的Vue項目中,如果url找不到對應的文件就返回index.html
,由前端代碼解決路由問題。
$NGINX_WEB_BASE_URL
是環境變量,下面講。
關于基礎路徑(publicPath和BASE_URI)
Vue項目的vue.config.js
文件中有個publicPath
設置,默認值是'/'
,其作用如下:
默認情況下,Vue CLI 會假設你的應用是被部署在一個域名的根路徑上,例如
https://www.my-app.com/
。如果應用被部署在一個子路徑上,你就需要用這個選項指定這個子路徑。例如,如果你的應用被部署在https://www.my-app.com/my-app/
,則設置publicPath
為 /my-app/。
采用默認值,build
生成的index.html
文件中引用生成的js文件路徑如下:
<script src="/js/chunk-vendors.4aa92667.js"></script>
<script src="/web/js/app.b474d9b9.js"></script>
如果publicPath
設置為‘/my-app’
,引用的js文件路徑如下:
<script src="/my-app/js/chunk-vendors.4aa92667.js"></script>
<script src="/my-app/web/js/app.b474d9b9.js"></script>
雖然nginx
可以通過rewrite
或proxy_pass
改變url指向的內容,但是,如果build
時沒有指定publicPath
,而是通過nginx
將https://www.my-app.com/my-app/
指向index.html,那么即使瀏覽器可以正確獲得html文件,可是它引用的js文件地址為https://www.my-app.com/js/chunk-vendors.4aa92667.js
,還是無法找到這個文件。
如果編碼階段就確定publicPath
,直接修改相關配置就好了。但是,我們不希望代碼與環境硬綁定,基礎路徑和代碼自身的業務邏輯無關,是一個部署需求,因此通過環境變量進行設置。
這時又會用到Vue的另外一個參數outputDir
,默認值為dist
,其作用如下:
當運行 vue-cli-service build 時生成的生產環境構建文件的目錄。
ue
下新建vue.config.js
文件中進行這兩個參數的設置。
const VUE_APP_BASE_URL = process.env.VUE_APP_BASE_URL ? process.env.VUE_APP_BASE_URL : ''
module.exports = {
publicPath: `${VUE_APP_BASE_URL}/web`,
outputDir: `dist${VUE_APP_BASE_URL}/web`
}
注:為什么以web
為結尾和這篇文章的主題無關,可忽略。
調用后臺API
前端代碼中需要確定后端服務API的地址,而且在構建階段就要確定。按照代碼不應該與環境硬綁定的原則,也需要通過環境變量進行指定,下面以2個文件為例。
程序文件ue/src/apis/auth.js
const baseAuth = (process.env.VUE_APP_AUTH_SERVER || '') + '/auth'
程序文件ue/src/apis/file/browse.js
const baseApi = (process.env.VUE_APP_API_SERVER || '') + '/file/browse'
生成.env文件
本以為環境變量可以直接傳遞到vue
的build
過程中,但是因為vue對process.env
做了處理,環境變量只能通過.env
文件傳遞。所以,我在Dockerfile
中直接通過傳遞的ARG
生成.env
文件。
生成nginx.conf文件
生成nginx.conf
文件復雜一些,需要用到envsubst
命令,這個命令在nginx:alpine
中已經包含,它的作用是替換文件中的環境變量并生成新文件。編寫start_nginx.sh
這個shell腳本,實現替換nginx.conf.template
中的環境變量,生成新的nginx.conf
文件,并啟動nginx。
注意:
.env
文件不能用這種模板文件的方式生成,因為環境變量只作用于容器內,多階段構建中,前面的階段并不會創建容器,所以環境變量用不上。
容器外運行
Vue在編碼階段并不需要docker,通過yarn serve
命令就可以運行。
Vue設置環境變量的官方方法是用.env
文件。
參考:https://cli.vuejs.org/zh/guide/mode-and-env.html
在ue
目錄下編寫.env
文件(包含在版本中)
VUE_APP_BASE_URL=/finder_ue
VUE_APP_AUTH_SERVER=http://localhost:3000
VUE_APP_LOGIN_KEY_USERNAME=username
VUE_APP_LOGIN_KEY_PASSWORD=password
VUE_APP_LOGIN_KEY_PIN=pin
VUE_APP_API_SERVER=http://localhost:3000
如果需要修改定義的值,可以在ue
目錄編寫.env.local
(不包含在版本中)進行覆蓋,例如:
VUE_APP_BASE_URL=
需要注意的是不要把.env
或者.env.local
不要放入docker容器,應該用.dockerignore
文件忽略掉。
總結和其它
docker極大降低了運行環境搭建的門檻,只要掌握基本用法,程序員完成可以搞定一個應用的基本運行環境。
為了讓應用更容易部署,應該盡量減少代碼對運行環境的硬依賴,將這些依賴轉化為可在部署時指定的環境變量。
本項目總結了不少使用docker的實用方法,這些方法可以用到同類型的項目中,這樣可以更有效地搭建全棧項目。