把大象放进冰箱需要几步?把一个 web 应用塞进集装箱呢?
随着几次浏览器大战的硝烟散尽和 Flash 的背影远去,当下的 web 应用开发经过十余年的发展,在工程化、测试、持续集成等方面都已经汇入了软件开发的快车道。
然而虽然新概念、新特性层出不穷,细分领域愈加专业化,但其究极奥义始终未变 -- 不管你怎么折腾,生成出来的交付物仍是 HTML/CSS/JS 老三样等静态资源,加上若干动态请求 的形式。从直接把文件拖放到 FTP 软件中手动上传的刀耕火种时代,到如今 Docker 镜像成为一种常见的部署格式 ,研发团队和运维团队的交互也在发生变化。
本文将在个人经验的基础上,尝试以一个前端项目为案例,浅谈其面向部署时的一些固有问题,以及与 Docker 相关的部分实践。
拥抱 Docker 时的麻烦
在此之前,要部署一个前端项目,运维人员需要做什么呢?
- 安装完整的 node 环境并保持其更新
- 阅读前端项目中 README 中的相关说明并更改相关文件中的设置项
- 用 npm 安装一些全局依赖项
- 保证 npm run build 流程的正确运行
- 和前端开发同事协作解决由于打包机器不同可能带来的问题
等搞定这么一全套的“份外”工作,才能得到打包后的目标文件并开始部署;这不但是多么痛的一种领悟,也是工作流层面一系列莫大的耦合。
"All problems in computer science can be solved by another level of indirection." -- David John Wheeler
面对代码封装中出现的耦合类问题,即便不了解 SOLID 原则、DRY 原则等等,以上这句 “啥都不叫事,抽象就对了!” 也算得上应该谨记的万金油了,是解耦的根本所在。
Docker 镜像就作为这样一种优良的抽象层,为研发团队和运维团队更好地解耦提供了可能。
然而在实际开发和部署中,囿于旧有经验和认知水平,可能会存在一些新问题:
利用不同的环境变量分别编译
严格来说这不算遇到 Docker 后才有的问题,可以说绝大部分前端项目一直都是默认这么做的。
根据 BUILD_ENV 环境变量,分别对开发、测试、预发、生产环境等区分编译不同的 API 的访问前缀 -- 比如对 GET /api/shops
数据接口的访问地址被分别编译成 http://test.com/api/shops
、https://api.stage.com/shops
等,虽然在传统的物理主机/虚拟主机工作流中这是无可指摘的标准做法;但在 Docker 语境中,这会导致分多次生成几个不同的镜像,从理论上难以保证“所测试的就是所部署的”这一理念。
此外,无法控制团队中的开发人员会利用这一特性添加什么其它的变量,甚至因为线上 bug 在本地难以重现而加以滥用作出特殊处理的也并不鲜见,这些都会对项目部署造成未知的干扰。
所以对于环境变量,或许我们应该稍稍反思并保证最小化使用,从而探索更适于 Docker 的新经验。
在镜像外独立构建等
无论对于分发还是部署,镜像越小越好,这是面对 Docker 时的一条普遍共识。对于构建过程中常见的优化方式有:
- 选用 alpine 版本的基础镜像
- 用
&&
操作符来实现链式的 RUN 等指令以减少分层 - 在容器中使用 nginx 而非 node 来伺服静态文件(服务器软件本身至少能减少 70M+)
另外,编译过程中的依赖文件 也是没有必要包含在最终镜像中的,一般的处理如:
- 在 Dockerfile 中编译然后用指令语句删除一些文件
- 分为可复用的依赖镜像和最终打包镜像
- 利用 Docker 的多阶段构建,在一个 Dockerfile 中解决问题;后面会有介绍
比较糟糕的一种做法可能是,每次让运维人员利用类似 npm run build && docker build ...
的命令,在服务器上构建项目再打包到 Docker 镜像中。这样做既增加了运维团队的负担,使其和传统模式一样深陷在环境依赖和繁复流程中;又无法保证其手动调整项目配置项等代码后整体的正确性;且 npm 打包环境异于开发者,有较高的不确定性。
构建参数
--build-arg
本身是个很方便的属性,能在 docker build
时传入必要的参数。但和项目中的环境变量类似,如果应用不当也会造成不同环境下镜像不一致的问题。因此交由运维人员或者自动化执行的 docker build
命令最好没有构建参数。
SASS 依赖
不同于其它依赖项,npm 安装 node-sass 包时,会从 github.com 上下载 .node 文件等。由于网络环境的问题,这个下载时间通常会很长,甚至导致超时失败。这往往成为了运维人员一个意料之外的痛点。
一般的解决办法是在 Dockerfile 中用 ENV 指令指定淘宝源:
ENV SASS_BINARY_SITE https://npm.taobao.org/mirrors/node-sass/
而有些项目的构建环境更加极端,出于安全等考虑无法访问外网,其它依赖从公司内部的私有 npm 源上获取。这时针对 node-sass 问题,处理起来就要更特殊一些:
- 访问 github.com/sass/node-s… .node 文件
- 将
npm i node-sass --sass_binary_path=<下载的.node文件>
语句整合进 Dockerfile
让镜像更易于交付
汇总之前分析的种种细节,来相对完整地看看如何配置镜像:
Dockerfile 多阶段构建
Docker 多阶段构建 是 17.05 版本开始后才有的一个特性。多阶段构建允许我们将多个 FROM 语句放在同一个 Dockerfile 中。
每条 FROM 指令都可以使用各自不同的基础镜像。每个 FROM 语句也都标记了 Docker 构建过程中一个新阶段的开始。我们可以拷贝一个阶段的产出物到另一个阶段,也可以抛弃不需要的部分。
这是个非常有用的特性,能避免最终镜像中存在编译过程中的依赖文件,也就是镜像会变得更小了 。
# stage 0 FROM node:10-alpine as build-stage WORKDIR /app COPY package.json ./ ENV SASS_BINARY_SITE https://npm.taobao.org/mirrors/node-sass/ RUN npm install --registry=https://registry.npm.taobao.org COPY . . RUN npm run build-prod --silent # stage 1 (nginx) FROM nginx:1.17-alpine COPY config/nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build-stage /app/dist /usr/share/nginx/html EXPOSE 8081 CMD ["nginx", "-g", "daemon off;"]
注意我们通过 –from=
引用了 构建阶段 stage 0
,并从构建阶段的工作目录拷贝了项目代码。
用数据卷覆盖镜像内配置
既然说了 npm 项目构建阶段用环境变量写入 API 请求地址等行为破坏 Docker 镜像的一致性,那到底如何请求到正确的端点呢?总要有个类似变量的东西传进去呀 ?!
但由于一来浏览器中无法用 process
感知环境,二来 Nginx 又不似 Node.js 应用一样可以直接传入参数;我们只好稍费周章,想办法 写入一些 Nginx 可以伺服的文件作为变量来源。
采用的技术正是 Docker 中的数据卷(volume),也就是在 docker run
时加载指定的目录或文件,用以在容器内创建或覆盖某些路径。单以写入 API 请求地址的需求为例,具体做法如下:
- 在服务器上创建一个
endpoint.json
文件,内容为:
{ "ENDPOINT": "http://api.app.com:5678" }
- 在 ``docker run
时加入参数
-v`:
docker run -p 48081:8081 -v <JSON文件绝对地址>:/usr/share/nginx/html/endpoint.json:ro -d <镜像名>
这样就在容器中的项目根目录下楔入了一个我们可以随意配置的文件。
项目局部的异步改造
配置文件很轻松的就解决了,那么有了 endpoint.json
配置文件,如何在 runtime 将其应用于每一次异步请求呢?思路似乎也颇为简单:
- 项目启动时先异步读取配置文件中的 ENDPOINT 属性
- 将读取到的属性放入项目中 fetch/ajax 框架的构造函数中,完成统一注入
注:某些构建糟糕的项目可能要多费些事了,需要将原本分散写在各处的请求前缀收敛为由统一的 fetch/ajax 框架处理
但或许麻烦就来自于异步请求这里 -- 由于一些状态管理工具的 store 里也存在异步请求,甚至 router 等处也会引用到 store,就很有可能造成 其异步调用早于 fetch/ajax 框架的构造函数 执行,,从而造成一些请求的失败;我们要做的就是对这些部分改为延迟加载。
以 vue 项目为例,在 main.js 中:
- 删除原有的 import 语句:
// import router from './router'; // import store from './store';
- 改为延迟加载:
const init = async () => { const store = await import('./store'); const router = await import('./router'); return new Vue({ i18n, router: router.default, store: store.default, render: h => h(App), }).$mount('#app'); };
- 保证顺序的初始化:
fetch('/endpoint.json').then(res => res.json()).then(cfg => { window.API_ENDPOINT = cfg.ENDPOINT; init(); });
以及,在 fetch 框架中的引用:
const FetchWrapper = function(option) { const r = new QuickFetch(mergeWith({ endpoint: window.API_ENDPOINT, baseURL: '/api' }, option)); }
总结
面向以 Docker 镜像为交付物的前端开发,代码层面所需的调整其实不是很多,主要是观念上是否敢于从传统舒适的工作模式稍微跳脱出来。
另外在团队中多换位思考,让开发链条中处于下游的运维小伙伴更乐于对接你的工作,共同提升开发部署效率和质量,也是很重要的。