本文分析了用docker搭建一個(gè)Web全棧項(xiàng)目(vue+nginx+node+mongodb)運(yùn)行環(huán)境時(shí)碰到的問(wèn)題,以一個(gè)開(kāi)箱即用的項(xiàng)目為例,整理了制作應(yīng)用docker鏡像的基礎(chǔ)模板和一些使用技巧。
現(xiàn)在越來(lái)越多的項(xiàng)目采用vue+nginx+node+mongodb的組合,這樣一個(gè)JS全棧工程師就可以獨(dú)立搞定一個(gè)完整的應(yīng)用。要達(dá)到這個(gè)目標(biāo),只會(huì)敲代碼是不夠的,還要能搞定運(yùn)行環(huán)境。通過(guò)使用docker可以極大簡(jiǎn)化運(yùn)行環(huán)境的搭建,并且給開(kāi)發(fā)和運(yùn)維的銜接工作帶來(lái)便利。
典型的全棧項(xiàng)目包括3個(gè)部分:前端(vue+nginx),后端(node)和數(shù)據(jù)庫(kù)(mongodb)。從部署的角度看,每個(gè)部分又可以分為3個(gè)部分:應(yīng)用(代碼+配置)、中間件和主機(jī)。docker解決了中間件和主機(jī)的組合問(wèn)題,我們用到的各種中間件都有標(biāo)準(zhǔn)docker鏡像,通過(guò)docker,它們的安裝和運(yùn)行都實(shí)現(xiàn)了標(biāo)準(zhǔn)化。我們真正要干的活是,如何以中間件鏡像為基礎(chǔ),加上應(yīng)用代碼和環(huán)境配置,制作項(xiàng)目的應(yīng)用鏡像。有了應(yīng)用鏡像,后面的工作就可以由運(yùn)維接手了。
因此,對(duì)于全棧工程師來(lái)說(shuō),需要掌握一項(xiàng)新技能:制作應(yīng)用鏡像。
下面,通過(guò)一個(gè)實(shí)際項(xiàng)目,描述一種制作應(yīng)用鏡像的通用方法。
項(xiàng)目概況
tms-finder項(xiàng)目是一個(gè)在線文檔管理系統(tǒng),back目錄下是用node實(shí)現(xiàn)的后端服務(wù),ue目錄下是用Vue實(shí)現(xiàn)的用戶端應(yīng)用,build后部署到nginx。上傳文件時(shí)用戶可以輸入文件的描述信息(可配置),文件會(huì)存在放在服務(wù)端指定的本地硬盤(pán)上(可配置),描述信息會(huì)保存在指定的mongodb中(可配置)。
這個(gè)項(xiàng)目是開(kāi)箱即用的,在安裝好docker和docker-compose的機(jī)器上,從github拉取代碼,執(zhí)行docker-compose up -d命令就可以把整個(gè)應(yīng)用運(yùn)行起來(lái)。
這個(gè)項(xiàng)目是環(huán)境友好的,制作的默認(rèn)鏡像可以靈活部署在不同的環(huán)境中(通過(guò)設(shè)置環(huán)境變量),也可以根據(jù)環(huán)境的要求制作新的鏡像(通過(guò)設(shè)置構(gòu)建參數(shù))。
這個(gè)項(xiàng)目是編碼友好的,程序員可以有選擇地使用docker,前后端都可以在容器外運(yùn)行,方便調(diào)試代碼。
關(guān)鍵概念
node環(huán)境變量
后臺(tái)服務(wù)是node應(yīng)用,Vue本質(zhì)上也是node應(yīng)用,所以應(yīng)該知道node的使用環(huán)境變量的方式。node中通過(guò)process.env這個(gè)對(duì)象訪問(wèn)環(huán)境變量,該對(duì)象可以修改,但是只會(huì)在應(yīng)用內(nèi)有效。
進(jìn)入容器后,可以通過(guò)下面的命令查看可用的環(huán)境變量:
node -e "console.log(process.env)"
需要注意的是,使用vue-service-cli命令時(shí),Vue對(duì)process.env做了處理,并不直接傳遞所有環(huán)境變量,而是要通過(guò).env傳遞,且必須以VUE_APP_開(kāi)頭。這會(huì)對(duì)制作鏡像產(chǎn)生影響。
docker環(huán)境變量
docker-compose中和環(huán)境變量設(shè)置相關(guān)的指令主要是enviroment和env_file,另外,多配置文件也會(huì)影響影響環(huán)境變量設(shè)置,詳細(xì)信息請(qǐng)看在線文檔。tms-finder項(xiàng)目中需要知道的是,在compose配置文件中設(shè)置的變量會(huì)傳遞給容器(process.env),docker-compose.override.yml中的內(nèi)容會(huì)覆蓋docker-compose.yml中的內(nèi)容。(后面會(huì)用到)
我采用在docker-compose.yml定義環(huán)境變量的默認(rèn)值(在版本庫(kù)),如果需要修改就通過(guò)docker-compose.override.yml覆蓋(不在版本庫(kù))。
在tms-finder中包含了這兩個(gè)文件,docker-compose.yml用于指定基礎(chǔ)設(shè)置,docker-compose.override.yml用于指定和運(yùn)行環(huán)境相關(guān)的設(shè)置,例如:端口號(hào)等。如果有更多的配置要求,可以通過(guò)docker-compose -f解決。
注意:端口(ports)不能通過(guò)覆蓋,環(huán)境變量(environment)和文件卷(volumes)可以,所以端口沒(méi)有寫(xiě)在
docker-compose.yml文件中,這樣有利于復(fù)用。另外,只能是“覆蓋”并不能“清除”,例如:volumns只能從一個(gè)設(shè)置改成另一個(gè),而不能清除掉。
參考:https://docs.docker.com/compose/environment-variables/
參考:https://docs.docker.com/compose/extends/
強(qiáng)調(diào)一個(gè)概念,環(huán)境變量是作用于容器的,在構(gòu)造階段是無(wú)效的。
docker參數(shù)(ARG)
Dockerfile有個(gè)ARG指令,用來(lái)定義在構(gòu)造鏡像時(shí),從外部接收的參數(shù)。在docker-compose中和ARG對(duì)應(yīng)的是build/args。
通過(guò)ARG在鏡像構(gòu)造階段傳遞參數(shù)。
docker網(wǎng)絡(luò)
docker網(wǎng)絡(luò)涉及很多內(nèi)容,現(xiàn)在只需要記住一點(diǎn),docker-compose.yml中定義的服務(wù)會(huì)被自動(dòng)添加到一個(gè)默認(rèn)docker的網(wǎng)絡(luò)(tms-finder_default)中,服務(wù)之間可以將服務(wù)名用作主機(jī)名相互訪問(wèn)。
參考:https://docs.docker.com/compose/networking/
數(shù)據(jù)庫(kù)(mongodb)
mongodb的標(biāo)準(zhǔn)鏡像是以ubuntu為基礎(chǔ),需要調(diào)整時(shí)區(qū),編寫(xiě)mongodb/Dockerfile文件。
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
分別在docker-compose.yml和docker-compose.override.yml中添加服務(wù)。
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'
存儲(chǔ)數(shù)據(jù)文件
這里需要注意volumes部分。通常,數(shù)據(jù)庫(kù)中間件應(yīng)該將數(shù)據(jù)目錄掛載在主機(jī)的路徑上,這樣每次重啟容器數(shù)據(jù)還在(docker-compose.override.yml中的設(shè)置)。但是,有時(shí)候可能并不需要持久化數(shù)據(jù)(在windows環(huán)境下不能掛載宿主機(jī)的目錄),例如:測(cè)試,這時(shí)可以把數(shù)據(jù)放在容器內(nèi)部,重啟容器就可以清理數(shù)據(jù)了。
啟動(dòng)服務(wù)
編碼階段如果為了方便調(diào)試,可以單獨(dú)啟動(dòng)mongodb服務(wù),命令如下:
docker-compose up mongodb
后端(node)
編寫(xiě)back/Dockerfile文件,將后端代碼放在標(biāo)準(zhǔn)node鏡像中形成新鏡像。
FROM node:alpine
# 設(shè)置時(shí)區(qū)
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的官方標(biāo)準(zhǔn)鏡像node:alpine。用npm和yarn安裝依賴包都會(huì)失敗,所以在鏡像中安裝并使用cnpm。這里提個(gè)醒,應(yīng)該避免從操作系統(tǒng)的鏡像開(kāi)始制作自己的鏡像,優(yōu)先從hub.docker.com上找中間件官方鏡像,并且按照在線文檔進(jìn)行相應(yīng)的設(shè)置,這樣省很多事。
在docker-compose.yml中添加服務(wù)。
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 # 指定上傳文件的外部存儲(chǔ)位置
command: ['./wait-for.sh', 'mongodb:27017', '-t', '300', '--', 'node', 'app']
在docker-compose.override.yml中添加服務(wù)。
back:
ports:
- '3000:3000'
設(shè)置環(huán)境變量
后端服務(wù)中使用了多個(gè)配置文件,包括:連接mongodb的config/mongodb.js,設(shè)置文件上傳服務(wù)的config/fs.js等。因?yàn)殓R像只是一個(gè)“模板”,實(shí)際部署時(shí)運(yùn)行環(huán)境不是確定的,例如:可能在生產(chǎn)環(huán)境中提供了獨(dú)立的mongodb服務(wù),需要通過(guò)配置將應(yīng)用指向這個(gè)服務(wù)。我們通過(guò)設(shè)置環(huán)境變量解決這個(gè)問(wèn)題。
下面以連接mongodb服務(wù)的配置文件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指令部分定義了環(huán)境變量TMS_FINDER_MONGODB_HOST和TMS_FINDER_MONGODB_PORT的值,容器啟動(dòng)后,node中可以通過(guò)process.env訪問(wèn)這些環(huán)境變量。注意這里的TMS_FINDER_MONGODB_HOST=mongodb,其中的mongodb是服務(wù)名,前面提到可以將服務(wù)名作為主機(jī)名進(jìn)行訪問(wèn)。
服務(wù)啟動(dòng)順序
通過(guò)docker-compose.yml同時(shí)啟動(dòng)多個(gè)服務(wù)時(shí)存在啟動(dòng)順序的問(wèn)題:后端服務(wù)back要連接mongodb,但是,如果mongodb啟動(dòng)需要很長(zhǎng)時(shí)間(例如:數(shù)據(jù)文件放在宿主機(jī)上),back服務(wù)就有可能因連接超時(shí)啟動(dòng)失敗。為了解決這個(gè)問(wèn)題,我找到了一個(gè)腳本wait_for。通過(guò)這個(gè)腳本可以測(cè)試指定的端口是否已經(jīng)可用,如果在指定時(shí)間內(nèi)確定可用,就執(zhí)行后面的命令。
參考:https://docs.docker.com/v17.12/compose/startup-order/
啟動(dòng)指定服務(wù)
如果為了調(diào)試前端代碼,只需要同時(shí)啟動(dòng)mongodb和back,可以執(zhí)行如下命令:
docker-compose up mongodb back
容器外運(yùn)行
不用容器啟動(dòng)時(shí)node服務(wù)時(shí),用npm run pm2啟動(dòng)。pm2支持設(shè)置環(huán)境變量,在ecosystem.config.js文件中進(jìn)行設(shè)置。
env: {
NODE_ENV: 'development',
TMS_FINDER_MONGODB_HOST: 'localhost',
TMS_FINDER_MONGODB_PORT: 27017
}
容器外運(yùn)行主要是為了方便調(diào)試代碼,通過(guò)容器啟動(dòng)的mongodb就在本機(jī),所以主機(jī)地址設(shè)置為localhost。
參考:https://pm2.keymetrics.io/docs/usage/environment/
前端鏡像(vue+nginx)
Vue項(xiàng)目要部署的內(nèi)容是通過(guò)yarn build命令生成的靜態(tài)代碼,這些代碼可以部署到任何WebServer中,例如:nginx。
編寫(xiě)ue/Dockerfile文件。
# 標(biāo)準(zhǔn)基礎(chǔ)鏡像(構(gòu)建階段)
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
# 安裝依賴包,構(gòu)建代碼
RUN cnpm i && yarn build
# 標(biāo)準(zhǔn)基礎(chǔ)鏡像(部署階段)
FROM nginx:alpine
# 設(shè)置時(shí)區(qū)
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
# 將構(gòu)建階段代碼放在指定位置
COPY --from=0 /home/node/app/dist /usr/share/nginx/html
CMD ["start_nginx.sh"]
在docker-compose.yml中添加服務(wù)。
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中添加服務(wù)。
ue:
ports:
- '8080:80'
多階段構(gòu)建
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.
上面兩段來(lái)自docker的官方文檔,簡(jiǎn)單說(shuō)就是在一個(gè)Dockerfile中,可以有多個(gè)FROM,每個(gè)構(gòu)成一個(gè)階段,后面的階段可使用前面階段生成的內(nèi)容,最終生成的鏡像只包含最后一個(gè)FROM的內(nèi)容。
這個(gè)特性恰好滿足了制作Vue前端代碼鏡像的需求,因?yàn)?code>build要依賴node環(huán)境,但是最終運(yùn)行環(huán)境只需要nginx和代碼。所以ue/Dockerfile分為了構(gòu)建和部署兩個(gè)階段,構(gòu)建階段生成代碼,然后在部署階段放到nginx的指定目錄。
關(guān)于路由(VueRouter)
通常,復(fù)雜一些的Vue項(xiàng)目中都會(huì)用到Router,如果采用html5的history模式,需要在nginx.conf文件中進(jìn)行設(shè)置。
參考: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的指令,功能是查找指定的文件,找到了就返回,如果找不到就轉(zhuǎn)發(fā)最后1個(gè)url。在配置路由的Vue項(xiàng)目中,如果url找不到對(duì)應(yīng)的文件就返回index.html,由前端代碼解決路由問(wèn)題。
$NGINX_WEB_BASE_URL是環(huán)境變量,下面講。
關(guān)于基礎(chǔ)路徑(publicPath和BASE_URI)
Vue項(xiàng)目的vue.config.js文件中有個(gè)publicPath設(shè)置,默認(rèn)值是'/',其作用如下:
默認(rèn)情況下,Vue CLI 會(huì)假設(shè)你的應(yīng)用是被部署在一個(gè)域名的根路徑上,例如
https://www.my-app.com/。如果應(yīng)用被部署在一個(gè)子路徑上,你就需要用這個(gè)選項(xiàng)指定這個(gè)子路徑。例如,如果你的應(yīng)用被部署在https://www.my-app.com/my-app/,則設(shè)置publicPath為 /my-app/。
采用默認(rèn)值,build生成的index.html文件中引用生成的js文件路徑如下:
<script src="/js/chunk-vendors.4aa92667.js"></script>
<script src="/web/js/app.b474d9b9.js"></script>
如果publicPath設(shè)置為‘/my-app’,引用的js文件路徑如下:
<script src="/my-app/js/chunk-vendors.4aa92667.js"></script>
<script src="/my-app/web/js/app.b474d9b9.js"></script>
雖然nginx可以通過(guò)rewrite或proxy_pass改變url指向的內(nèi)容,但是,如果build時(shí)沒(méi)有指定publicPath,而是通過(guò)nginx將https://www.my-app.com/my-app/指向index.html,那么即使瀏覽器可以正確獲得html文件,可是它引用的js文件地址為https://www.my-app.com/js/chunk-vendors.4aa92667.js,還是無(wú)法找到這個(gè)文件。
如果編碼階段就確定publicPath,直接修改相關(guān)配置就好了。但是,我們不希望代碼與環(huán)境硬綁定,基礎(chǔ)路徑和代碼自身的業(yè)務(wù)邏輯無(wú)關(guān),是一個(gè)部署需求,因此通過(guò)環(huán)境變量進(jìn)行設(shè)置。
這時(shí)又會(huì)用到Vue的另外一個(gè)參數(shù)outputDir,默認(rèn)值為dist,其作用如下:
當(dāng)運(yùn)行 vue-cli-service build 時(shí)生成的生產(chǎn)環(huán)境構(gòu)建文件的目錄。
ue下新建vue.config.js文件中進(jìn)行這兩個(gè)參數(shù)的設(shè)置。
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為結(jié)尾和這篇文章的主題無(wú)關(guān),可忽略。
調(diào)用后臺(tái)API
前端代碼中需要確定后端服務(wù)API的地址,而且在構(gòu)建階段就要確定。按照代碼不應(yīng)該與環(huán)境硬綁定的原則,也需要通過(guò)環(huán)境變量進(jìn)行指定,下面以2個(gè)文件為例。
程序文件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文件
本以為環(huán)境變量可以直接傳遞到vue的build過(guò)程中,但是因?yàn)関ue對(duì)process.env做了處理,環(huán)境變量只能通過(guò).env文件傳遞。所以,我在Dockerfile中直接通過(guò)傳遞的ARG生成.env文件。
生成nginx.conf文件
生成nginx.conf文件復(fù)雜一些,需要用到envsubst命令,這個(gè)命令在nginx:alpine中已經(jīng)包含,它的作用是替換文件中的環(huán)境變量并生成新文件。編寫(xiě)start_nginx.sh這個(gè)shell腳本,實(shí)現(xiàn)替換nginx.conf.template中的環(huán)境變量,生成新的nginx.conf文件,并啟動(dòng)nginx。
注意:
.env文件不能用這種模板文件的方式生成,因?yàn)榄h(huán)境變量只作用于容器內(nèi),多階段構(gòu)建中,前面的階段并不會(huì)創(chuàng)建容器,所以環(huán)境變量用不上。
容器外運(yùn)行
Vue在編碼階段并不需要docker,通過(guò)yarn serve命令就可以運(yùn)行。
Vue設(shè)置環(huán)境變量的官方方法是用.env文件。
參考:https://cli.vuejs.org/zh/guide/mode-and-env.html
在ue目錄下編寫(xiě).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目錄編寫(xiě).env.local(不包含在版本中)進(jìn)行覆蓋,例如:
VUE_APP_BASE_URL=
需要注意的是不要把.env或者.env.local不要放入docker容器,應(yīng)該用.dockerignore文件忽略掉。
總結(jié)和其它
docker極大降低了運(yùn)行環(huán)境搭建的門(mén)檻,只要掌握基本用法,程序員完成可以搞定一個(gè)應(yīng)用的基本運(yùn)行環(huán)境。
為了讓?xiě)?yīng)用更容易部署,應(yīng)該盡量減少代碼對(duì)運(yùn)行環(huán)境的硬依賴,將這些依賴轉(zhuǎn)化為可在部署時(shí)指定的環(huán)境變量。
本項(xiàng)目總結(jié)了不少使用docker的實(shí)用方法,這些方法可以用到同類(lèi)型的項(xiàng)目中,這樣可以更有效地搭建全棧項(xiàng)目。
本系列其他文章
用Docker簡(jiǎn)化Nodejs開(kāi)發(fā)1——開(kāi)發(fā)環(huán)境
用Docker簡(jiǎn)化Nodejs開(kāi)發(fā)2——開(kāi)發(fā)環(huán)境到測(cè)試環(huán)境
用Docker簡(jiǎn)化Nodejs開(kāi)發(fā)3——用webhook實(shí)現(xiàn)自動(dòng)更新