
本文共 3166 字,大约阅读时间需要 10 分钟。
本文介绍了笔者的前端容器化部署踩坑之旅,重点解决了以下几个问题:node_modules缓存、镜像体积过大以及镜像数量多且上线流程复杂。本文要求读者有一定的容器基础,掌握Docker的基本操作,了解K8s的Pod原理。如果还不了解,请先拉到文末,阅读参考资料。
最近将一个前端项目部署到公司的容器服务。事情的起因是这样的:原先我们在一台服务器上部署了一套Jenkins,用于前端项目集成。一个月前,安全部门发邮件指出我们的Jenkins插件有漏洞。于是,我们花了两天时间备份配置、为服务器申请各个项目的Deploy Key、升级Jenkins。期间同事们没法快速上线,只能手工拷贝到线上服务器。
笔者反思了一下,现有的流程有以下问题:
这套集成服务没有做灾备,加上升级时还要解决一些版本兼容问题。一旦挂掉,影响生产效率。
每次部署一个新项目的时候,需要申请机器、安装一系列环境依赖、为Jenkins所在的机器添加访问权限。
使用Jenkins时,需要在脚本里指明目标机器地址。如果要增加新的服务器,就得重复2和3里的步骤。
从长远计,我们需要更稳定、更自动化的集成服务(而且要由更专业的人维护)。公司的云平台提供了基于Kubernetes(简称K8s)搭建的容器服务,还能够持续集成。符合我们的需求。那就开始新的部署之旅吧。
初次部署
由于该项目前后端分离得很彻底,只需从源代码编译产生静态资源,再用Nginx关联前端的域名和静态资源即可。因此笔者基于Node和Nginx创建了两个镜像。将这两个镜像运行的容器跑在一个Pod里,并且让两个容器通过共享空间读写静态资源目录。如下图所示:
图片被截断,以下是相关说明:
Node镜像是在云平台的持续集成流程中构建的。因为云平台在Gitlab申请了Webhook,所以在提交代码时会自动触发构建流程。Nginx镜像则是先本地构建好再上传到云平台。
云端构建Node镜像的大致过程:
图片被截断,以下是相关说明:
node.dockerfile的内容如下:
FROM $NODE_BASE_IMAGEWORKDIR /siteWORKDIR /site-buildADD ./ /site-build/RUN npm install && npm run build && mv /site-build/dist /site/ && rm -rf /site-buildENTRYPOINT $MOVE_DIST_TO_SHARED_FOLDER_AND__KEEP_CONTAINER_ALIVE
最后在ENTRYPOINT里声明,启动容器时需要将编译好的文件夹拷贝到共享文件夹,并且运行一个让容器不退出的命令。(随便什么命令,不退出就对了。因为线上开启了退出自动重启功能,会导致容器不停地重启)
本地构建Nginx镜像的nginx.dockerfile则简单许多:
FROM $NGINX_BASE_IMAGEADD ./nginx.conf /$NGINX_CONF_INCLUDE_PATHEXPOSE 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中添加version和postversion钩子,分别用于自动检测构建和推送代码。上线前只需要执行一下npm version(major/minor/patch),这样就可以自动检测第三方依赖改变并构建镜像。
如何检测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。
优化三:multi-stage build
接下来看第三个问题,其实Node容器没有必要存活。我们的网站只需要Nginx+配置文件+静态资源目录就可以了。
这时要用到Docker的multi-stage build特性。它允许将多个构建步骤写到一个Dockerfile里面。相当于在一次镜像构建中执行多个步骤,但以最后一个步骤产生的文件和指令为准。
那么就将云端的镜像构建分为两个阶段,第一阶段产生dist目录,第二个阶段提供nginx配置和静态资源。构建流程如下:
最终效果:
镜像体积降至25MB(原先两个镜像分别2GB+)。一个Pod里只用运行一个容器,并且去掉共享空间。
持续集成构建时间平均170秒(原先平均400秒)。