本文分析了用docker搭建一个Web全栈项目(vue+nginx+node+mongodb)运行环境时碰到的问题,以一个开箱即用的项目为例,整理了制作应用docker镜像的基础模板和一些使用技巧。
现在越来越多的项目采用vue+nginx+node+mongodb
的组合,这样一个JS全栈工程师就可以独立搞定一个完整的应用。要达到这个目标,只会敲代码是不够的,还要能搞定运行环境。通过使用docker可以极大简化运行环境的搭建,并且给开发和运维的衔接工作带来便利。
典型的全栈项目包括3个部分:前端(vue+nginx
),后端(node
)和数据库(mongodb
)。从部署的角度看,每个部分又可以分为3个部分:应用(代码+配置)、中间件和主机。docker
解决了中间件和主机的组合问题,我们用到的各种中间件都有标准docker镜像,通过docker,它们的安装和运行都实现了标准化。我们真正要干的活是,如何以中间件镜像为基础,加上应用代码和环境配置,制作项目的应用镜像。有了应用镜像,后面的工作就可以由运维接手了。
因此,对于全栈工程师来说,需要掌握一项新技能:制作应用镜像。
下面,通过一个实际项目,描述一种制作应用镜像的通用方法。
项目概况
https://github.com/jasony62/tms-finder
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=
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文件路径如下:
如果publicPath
设置为‘/my-app’
,引用的js文件路径如下:
虽然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的实用方法,这些方法可以用到同类型的项目中,这样可以更有效地搭建全栈项目。
本系列其他文章
用Docker简化Nodejs开发1——开发环境
用Docker简化Nodejs开发2——开发环境到测试环境
用Docker简化Nodejs开发3——用webhook实现自动更新