前端项目容器化之旅

来自:奇舞周刊,作者:黄小璐 ,奇舞团前端开发工程师,同时也是W3C性能工作组成员。

本文介绍了笔者的前端容器化部署踩坑之旅。并解决了以下几个问题:

  1. node modules缓存

  2. 镜像体积过大

  3. 镜像数量多且上线流程复杂

本文要求读者有一定的容器基础,掌握Docker的基本操作,了解K8s的Pod原理。如果还不了解,请先拉到文末,阅读参考资料。

最近将一个前端项目部署到公司的容器服务。

事情的起因是这样的:原先我们在一台服务器上部署了一套Jenkins,用于前端项目集成。一个月前,安全部门发邮件指出我们的Jenkins插件有漏洞。于是,我们花了两天时间备份配置、为服务器申请各个项目的Deploy Key、升级Jenkins。期间同事们没法快速上线,只能手工拷贝到线上服务器。

笔者反思了一下,现有的流程有以下问题:

  1. 这套集成服务没有做灾备,加上升级时还要解决一些版本兼容问题。一旦挂掉,影响生产效率

  2. 每次部署一个新项目的时候,需要申请机器、安装一系列环境依赖、为Jenkins所在的机器添加访问权限

  3. 使用Jenkins时,需要在脚本里指明目标机器地址。如果要增加新的服务器,就得重复2和3里的步骤

从长远计,我们需要更稳定、更自动化的集成服务(而且要由更专业的人维护)。公司的云平台提供了基于Kubernetes(简称K8s)搭建的容器服务,还能够持续集成。符合我们的需求。那就开始新的部署之旅吧。

初次部署

由于该项目前后端分离得很彻底,只需从源代码编译产生静态资源,再用Nginx关联前端的域名和静态资源即可。因此笔者基于Node和Nginx创建了两个镜像。将这两个镜像运行的容器跑在一个Pod里,并且让两个容器通过共享空间读写静态资源目录。如下图所示:

其中,Node镜像是在云平台的持续集成流程中构建的。因为云平台在Gitlab申请了Webhook,所以在提交代码时会自动触发构建流程。Nginx镜像则是先本地构建好再上传到云平台。

云端构建Node镜像的大致过程:

对应node.dockerfile的内容:

FROM $NODE_BASE_IMAGE


WORKDIR /site

WORKDIR /site-build

ADD ./ /site-build/

RUN npm install && \

    npm run build && \

    mv /site-build/dist /site/ && \

    rm -rf /site-build


ENTRYPOINT $MOVE_DIST_TO_SHARED_FOLDER_AND__KEEP_CONTAINER_ALIVE

首先用FROM指定一个Node的基础镜像(大约2G+,之后换成更小的基础镜像了),指定工作目录,将项目文件添加到镜像,执行npm installnpm run build,将结果拷贝到/site目录,并把/site-build删除(只留下编译后的文件,镜像的体积会小一些)。

最后在ENTRYPOINT里声明,启动容器时需要将编译好的文件夹拷贝到共享文件夹,并且运行一个让容器不退出的命令。(随便什么命令,不退出就对了。因为线上开启了退出自动重启功能,会导致容器不停地重启)

本地构建Nginx镜像的nginx.dockerfile则简单许多:

FROM $NGINX_BASE_IMAGE


ADD ./nginx.conf /$NGINX_CONF_INCLUDE_PATH


EXPOSE 80

以上配置先指定基础镜像,再将本地的Nginx配置文件添加到镜像里对应的Nginx的include目录。 因为Nginx镜像在本地构建,且默认开启缓存,所以只要ADD的文件不变,并且各种命令不变,构建过程就会特别快。

完成镜像的构建和上传后,在云平台配置好容器的共享文件夹和nginx日志的挂载路径。选择这两个镜像并发布。初次部署完成了。

但是有几个很明显的问题:

  • 云平台的持续集成没有缓存。这意味着,每次构建Node镜像时都要执行一遍npm install显然这是没有必要的。如果能够将node_modules缓存起来,在第三方依赖不变的情况下,就不必npm install了。

  • 镜像太大。两个2GB+的基础镜像。上传慢而且占用了不必要的磁盘空间。

  • 运行Node的容器必须保持存活。虽然这个容器只需要在启动时给共享空间写入dist目录就完成了使命。

优化一:缓存node_modules

解决方案是将node_modules打包到基础镜像中,只有当本地的node_modules改变时才重新构建。然后以此为基础构建第二个镜像(用于提供编译后的dist目录)。需要解决以下问题:

  • 自动检测到第三方依赖变化后自动构建新的基础镜像,并将该镜像上传到云平台。

  • 自动将新的基础镜像地址写入到node.dockerfile的FROM字段。

  • 自动提交更新后的node.dockerfile文件,交给持续集成在线上构建新镜像。

因为要实现自动化,所以自然要用到git钩子。又因为只有上线前才需要更新镜像,所以采用npm version的钩子(其他钩子都不合适)。在package.json的scripts中添加versionpostversion钩子,分别用于自动检测构建和推送代码。上线前只需要执行一下npm version(major/minor/patch),这样就可以自动检测第三方依赖改变并构建镜像。

"scripts": {
    "version""sh auto-build-node-base-image.sh",
    "postversion""git push && git push --tags"
}

怎么检测node_modules变化?

第一个尝试的是检测package.json的变化(通过git的commit日志记录对比上次修改时间)。因为第三方依赖跟package.json的dependencies对应,只要package.json有新的提交记录,八成是dependencies改变了。但是每次执行npm version会修改package.json中的version字段,也会提交package.json,因此不准确。

第二个想到package-lock.json。第三方依赖改变时,也会反映到package-lock.json中。观察了一下,package-lock.json也包含了version字段。作罢。

最后想到了yarn。观察一下yarn.lock,不包含package.json中的version信息。只有当依赖改变时,yarn.lock才会变化。完美。索性弃npm投yarn了。

这一次优化后的流程大致如下:

原先云端构建需要平均400秒,优化后,平均210秒,节省了一半时间。

优化二:缩小镜像体积

原先Node镜像和Nginx镜像体积分别是2GB+,可以说很臃肿了。而真正有用的静态文件(dist目录)加起来不到10MB。所以需要替换成更小的基础镜像。

最初使用的基础镜像是CentOS操作系统,本身体积较大。在容器时代最受欢迎的Linux版本是什么?当然是Alpine了。原因就是它足够小。小到什么程度呢?大概5MB吧。

于是在做第一步优化时,顺便将本地的Node基础镜像换成了Alpine,构建镜像时再安装yarn(指令:RUN apk add --no-cache yarn)。

原先本地构建出来的Node镜像(已安装node_modules)是2.53GB,替换基础镜像后缩小到668MB。

基于本地构建的Node镜像,在云端编译产生dist目录后,再将node_modules目录删除,体积就更小了。

优化三:multi-stage build

接下来看第三个问题,其实Node容器没有必要存活。我们的网站只需要Nginx+配置文件+静态资源目录就可以了。

这时要用到Docker的multi-stage build特性。它允许将多个构建步骤写到一个Dockerfile里面。相当于在一次镜像构建中执行多个步骤,但以最后一个步骤产生的文件和指令为准。

那么就将云端的镜像构建分为两个阶段,第一阶段产生dist目录,第二个阶段提供nginx配置和静态资源。构建流程如下:

最终效果:

  • 镜像体积降至25MB(原先两个镜像分别2GB+)

  • 一个Pod里只用运行一个容器,并且去掉共享空间。

  • 持续集成构建时间平均170秒(原先平均400秒)

总体来说,这次优化效果比较明显。希望能对大家折腾容器化提供一些参考。

参考文档

推荐↓↓↓
前端开发
上一篇:Web与传感器:Generic Sensor API 下一篇:Node 最古老的 npm 包 request 将被废弃