这段时间在开发一个HTML动态服务,所有类别的腾讯文档通用。为了方便各类访问的生成和部署,同时也顺应云迁移的趋势,考虑使用Docker。固定服务内容,统一管理产品版本。本文将分享我在服务Docker过程中积累的优化经验,供大家参考。先举个例子,刚接触Docker的同学应该是这样写项目的Dockerfile,如下:FROMnode:14WORKDIR/appCOPY。.#安装npm依赖RUNnpminstall#ExposeportEXPOSE8000CMD["npm","start"]一次构建、打包、上传。然后查看图像的状态。妈的,一个简单的nodeweb服务的体积已经达到了惊人的1.3G,而且图片传输和构建的速度也很慢:如果这个图片只需要部署一个实例就好了,但是这个服务有提供给所有开发同学高频集成部署环境(高频集成实现方案见我之前的文章)。首先,如果镜像尺寸过大,势必影响镜像的拉取和更新速度,集成体验变差。其次,项目上线后,可能会有上万个测试环境实例同时在线。这样的容器内存使用成本是任何项目都无法接受的。必须找到优化的解决方案。发现问题后,我开始研究Docker的优化方案,准备对我的镜像进行操作。node项目生产环境的优化,首先从优化代码本身的体积开始,这当然是前端最熟悉的领域。之前开发项目的时候用的是Typescript。项目为了省事,直接用tsc打包生成es5,然后直接运行。这里有两个主要的体积问题。一是开发环境的ts源码没有经过处理,生产环境使用的js代码没有压缩。另一个是引用的node_modules过于臃肿。它还包含了很多开发调试环境的npm包,比如ts-node,typescript等。既然打包成了js,这些依赖自然要去掉。一般来说,因为服务端代码不像前端代码那样暴露,所以运行在物理机上的服务更关心稳定性,不关心体积更大,所以这些地方一般不做处理。但Docker化后,由于部署规模较大,这些问题非常明显,需要在生产环境中进行优化。其实这两点的优化方法我们都非常熟悉,不是本文的重点。第一点,使用Webpack+babel对Typescript源码进行降级压缩。如果担心排错,可以加sourcemap,不过对于dockerimages来说有点多余,后面再说。第二点,梳理npm包的依赖和devDependencies,去掉运行时不需要存在的依赖,这样生产环境就可以使用npminstall--production来安装依赖了。优化项目图像的大小使用尽可能简单的基本图像。我们知道,容器技术提供了操作系统层面的进程隔离。Docker容器本身就是一个运行在独立操作系统下的进程。也就是说,Docker镜像需要打包的是一个能够独立运行的操作系统级环境。由此可见,决定镜像大小的一个重要因素是:打包到镜像中的Linux操作系统的大小。一般来说,减小依赖操作系统的大小主要需要考虑两个方面。一是尽可能去掉Linux下不需要的各种工具库,如python、cmake、telnet等,二是选择更轻量级的Linux发行版系统。正式的官方镜像应该根据以上两个因素为每个发行版提供阉割版本。以node官方提供的node:14版本为例。在默认版本中,它的基本运行环境是Ubuntu,这是一个庞大而全面的Linux发行版,以确保最大的兼容性。去掉无用工具库的依赖版本称为node:14-slim版本。最小的镜像分布称为node:14-alpine。Linuxalpine是一个高度精简的轻量级Linux发行版,仅包含基本工具。其自带的Docker镜像只有4-5M大小,非常适合制作最小版本的Docker镜像。在我们的服务中,由于运行服务的依赖是一定的,为了尽可能的减小基础镜像的体积,我们选择alpine版本作为生产环境的基础镜像。分层构建这时候,我们又遇到了一个新的问题。由于alpine的基础工具库过于简单,而且webpack等打包工具背后可能用到的插件库较多,所以项目搭建时对环境的依赖性比较大。而且这些工具库只在编译时需要,运行时可以去掉。对于这种情况,我们可以利用Docker的分层构建特性来解决这个问题。首先,我们可以在完整版镜像下进行依赖安装,并为任务设置别名(这里是build)。#安装完整的依赖项并构建产品FROMnode:14ASbuildWORKDIR/appCOPYpackage*.json/app/RUN["npm","install"]COPY./app/RUNnpmrunbuild之后我们可以再启动一个镜像任务来运行生产环境,生产环境的基础镜像可以替换成alpine版本。编译后的源码可以通过--from参数获取构建任务中的文件,并移动到该任务中。FROMnode:14-alpineASreleaseWORKDIR/releaseCOPYpackage*.json/RUN["npm","install","--registry=http://r.tnpm.oa.com","--production"]#移入依赖和源码COPYpublic/release/publicCOPY--from=build/app/dist/release/dist#startserviceEXPOSE8000CMD["node","./dist/index.js"]Docker的生成规则image是,生成图像的结果只是基于上一个图像任务。因此,前面的任务不会占用最终图像的体积,完美解决了这个问题。当然,随着项目越来越复杂,在运行时仍然可能会遇到工具库错误。如果暴露问题的工具库需要的依赖不多,我们可以自行补充需要的依赖。这样的图像体积还是可以保持很小的。最常见的问题之一是对node-gyp和node-sass库的引用。由于这个库是用来将其他语言编写的模块翻译成node模块的,所以我们需要手动添加g++makepython的三个依赖。#安装生产环境依赖(alpine需要修改以兼容node-gyp需要的环境)FROMnode:14-alpineASdependenciesRUNapkadd--no-cachepythonmakeg++COPYpackage*.json/RUN["npm","install","--registry=http://r.tnpm.oa.com","--production"]RUNapkdel.gyp详情可见:https://github.com/nodejs/docker-node/issues/282合理规划DockerLayer构建速度优化我们知道Docker使用Layer的概念来创建和组织镜像,Dockerfile中的每条指令都会生成一个新的文件层,而每一层包含命令执行前后状态之间的图像文件系统变化,文件层数越多,图像尺寸越大。另一方面,Docker使用缓存来提高构建速度。如果Dockerfile中某一层的语句和依赖没有变化,重建该层时可以直接复用本地缓存。如下图,如果日志中出现Usingcache字样,则说明缓存已经生效,本层将不再进行计算,直接使用原来的缓存作为本层的输出结果。Step2/3:npminstall--->Usingcache--->efvbf79sd1eb研究了Docker的缓存算法,发现在Docker构建过程中,如果一个层不能被缓存,后续依赖这一步的层就无法从中加载缓存。比如下面这个例子:COPY..RUNnpminstall这时候,如果我们更改仓库中的任何一个文件,因为npminstall层的上层依赖发生了变化,即使依赖没有发生变化,缓存不会被重复使用。因此,如果我们想尽可能的使用npminstall层缓存,我们可以将Dockerfile改成这样:COPYpackage*.json.RUNnpminstallCOPYsrc。这样在只改动源码的情况下,仍然可以使用node_modules的依赖缓存。.由此得到优化原则:尽量减少对变更文件的处理,只变更下一步需要的文件,尽量减少构建过程中的缓存失效。对于处理文件变化的ADD命令和COPY命令,尽量延迟执行。建筑体积优化在保证速度的前提下,体积优化也是我们需要考虑的。这里需要考虑三点:Docker以层为单位上传镜像仓库,这样也可以最大限度的利用缓存能力。因此,需要将执行结果很少变化的命令单独抽取出来分层。比如上面提到的npminstall的例子,也是采用了这种思路。镜像层数越少,总上传量越小。因此,当命令处于执行链的末尾,即不会影响其他层的缓存时,尽可能合并命令以减小缓存大小。例如,设置环境变量和清理无用文件的指令的输出将不会被使用,因此这些命令可以组合成一个RUN命令。RUNsetENV=prod&&rm-rf./trashDocker缓存的下载也是通过分层缓存,所以为了减少镜像的传输和下载时间,我们最好使用固定的物理机进行构建。例如,在管道中指定专用主机可以大大减少镜像的准备时间。当然,时间和空间的优化从来都不是两全其美的。这就需要我们在设计Dockerfile的时候,在DockerLayers的数量上做出取舍。例如,为了时间优化,需要对文件进行拆分、复制等操作,这会导致层数增加,空间略有增加。我这里的建议是优先保证构建时间,其次在不影响时间的前提下,尽量减小构建缓存的大小。以Docker思维管理服务,避免使用进程守护进程我们在编写传统后台服务时,总是使用进程守护进程,如pm2、forever等,以保证服务在意外崩溃时能够被监控并自动重启。但这在Docker下不仅没有用,还会带来额外的不稳定性。首先,Docker本身是一个进程管理器,因此进程daemon提供的死机重启、日志记录等,可以由Docker自身或基于Docker的编排程序(如kubernetes)提供,无需使用额外的应用实现。另外,由于daemon进程的特性,必然会对以下几种情况产生影响:增加进程daemon会增加占用的内存,镜像大小也会相应增加。由于daemon进程一直在正常运行,当服务出现故障时,Docker自身的重启策略不会生效,崩溃信息也不会记录在Docker日志中,给排查带来困难。由于增加了更多的进程,Docker提供的CPU、内存等监控指标会变得不准确。因此,虽然像pm2这样的processdaemon提供了可以适配Docker的版本:pm2-runtime,但是我还是不建议大家使用processdaemon。其实这一点其实是我们固有思维所犯的错误。在服务上云的过程中,难点不仅在于文字和结构的调整,更重要的是开发思路的转变,这一点我们在上云的过程中会更加深刻地体会到。云。日志的持久化存储无论是故障排除还是审计,后台服务总是需要日志能力。按照之前的思路,我们把日志分门别类之后,就可以写入到某个目录下的日志文件中了。但在Docker中,任何本地文件都不是持久化的,会随着容器生命周期的结束而被销毁。因此,我们需要将日志存出容器。最简单的方法是使用DockerManagerVolume,这种特性可以绕过容器自身的文件系统,直接将数据写入宿主物理机。具体用法如下:dockerrun-d-it--name=app-v/app/log:/usr/share/logapp运行docker时,使用-v参数将volume绑定到容器,和/主机上的应用/log目录(如果不存在则自动创建)挂载在容器的/usr/share/log中。这样,当服务将日志写入文件夹时,可以持久保存在宿主机上,不会在docker销毁时丢失。当然,当部署集群数量增多时,物理主机上的日志会变得难以管理。此时就需要一个服务编排系统来统一管理。从单纯管理日志的角度,我们可以向网络上报,托管到云日志服务(如腾讯云CLS)。或者干脆对容器进行批量管理,比如Kubernetes这样的容器编排系统,这样日志作为一个模块自然就可以妥善保存。这样的方法还有很多,这里不再赘述。除了k8s服务控制器的镜像优化选择,服务编排和部署负载形式的控制对性能也有很大的影响。这里我们以两种最流行的Kubernetes控制器(Controller):Deployment和StatefulSet为例,简单对比一下这两种组织形式,帮助大家选择最适合服务的Controller。StatefulSet是K8S在1.5版本之后引入的一个Controller。它的主要特点是:可以实现Pod之间的有序部署、更新和销毁。那么我们的产品是否需要使用StatefulSet来进行pod管理呢?官方总结一句话简单概括:Deployment用于部署无状态服务,StatefulSet用于部署有状态服务。这句话很精确,但不容易理解。那么,什么是无国籍状态?在我看来,StatefulSet的特点可以从以下几个步骤来理解:StatefulSet管理的多个Pod之间的部署、更新、删除可以按照固定的顺序进行。适用于多个服务之间存在依赖关系的情况,比如先启动数据库服务,再启动查询服务。由于Pod之间存在依赖关系,每个Pod提供的服务必然是不同的,所以StatefulSet管理的Pod之间没有负载均衡能力。并且由于pod提供不同的服务,每个pod都会有自己独立的存储空间,pod之间不共享。为了保证poddeployment更新的顺序,pod名称必须是固定的,所以不像Deployment,生成的pod名称后面会跟一串随机数。由于pod名称是固定的,连接StatefulSet的服务可以直接使用pod名称作为访问域名,无需提供ClusterIP,所以连接StatefulSet的服务称为headlessservice。通过这里,我们应该明白,如果在k8s上部署单个服务,或者多个服务之间没有依赖关系,那么Deployment一定是简单有效的选择,自动调度,自动负载均衡。但是,如果服务的启停必须满足一定的顺序,或者每个pod挂载的数据卷需要销毁后仍然存在,那么建议选择StatefulSet。基于非必要不添加实体的原则,强烈建议所有运行单个服务的工作负载都使用Deployment作为Controller。写在最后,经过一番研究,差点忘记当初的目标,赶紧重新构建Docker看看优化结果。可以看出对镜像卷的优化效果还是不错的,达到了10倍左右。当然,如果项目中不需要这么高版本的node支持,镜像大小还可以进一步缩小一半左右。之后镜像仓库会对存放的镜像文件进行压缩,最终将node14打包的镜像版本压缩到50M以内。当然,除了可见的体量数据,更重要的优化还在于架构设计层面从面向物理机的服务向容器化云服务的转变。容器化已经是一个看得见的未来。作为一名开发者,必须时刻保持对前沿技术的敏感,并积极实践,才能将技术转化为生产力,为项目的演进贡献力量。参考:《Kubernetes in action》--MarkoLuk?a优化Docker镜像
