为了方便各类访问的生成和部署,并顺应走向云的趋势,我们考虑使用Docker来固定服务内容,统一产品版本管理。在本文中,我将分享我在服务Dockerization过程中积累的优化经验,供您参考。
首先举一个例子,大多数刚接触Docker的学生应该按照以下方式编写项目Dockerfile:
FROM node:14 WORKDIR /app COPY . . # 安装 npm 依赖 RUN npm install # 暴露端口 EXPOSE 8000 CMD ["npm", "start"]
构建、打包、上传,一气呵成。然后看看图像的状态,见鬼,一个简单的节点web服务的容量实际上达到了惊人的1.3G,图像传输和构建速度也非常慢。
如果映像只需要部署一个实例,则可以,但必须向所有开发学生提供此服务,以便对环境进行高频集成和部署。首先,图像尺寸过大肯定会影响图像的拉取和更新速度,集成体验会变得更差。其次,在项目上线后,可能会有数千个测试环境实例同时在线,这样的容器内存使用成本对于任何项目来说都是不可接受的。必须找到优化解决方案。
发现问题后,我开始研究Docker的优化解决方案,并准备对我的图像进行操作。
节点项目生产环境优化
第一步当然是最熟悉的前端领域,优化代码本身的容量。该项目是使用Typescript开发的,为了节省时间,该项目在tsc打包生成es5后直接运行。这里有两个主要的卷问题,一个是开发环境ts源代码没有处理,生产环境的js代码没有压缩。
另一个原因是引用的node_modules过于臃肿。它仍然包含来自开发和调试环境的许多npm包,例如ts节点、typescript等。现在它被打包为js,很自然地可以删除这些依赖项。
一般来说,由于服务器端代码不像前端代码那样公开,因此在物理机器上运行的服务更关心稳定性,而不关心更大的容量,因此通常不处理这些区域。但Dockerization之后,随着部署规模的扩大,这些问题变得非常明显,需要在生产环境中进行优化。
对于这两点,我们实际上非常熟悉优化前端的方法,因此如果它不是本文的重点,我们将跳过它。首先,使用Webpack+babel来降级和压缩Typescript源代码,如果您担心错误检查,可以添加sourcemap,但对于docker图像来说有点多余,我们稍后会讨论。对于第二点,整理npm包的依赖项和devDependencies,删除运行时不需要的依赖项,并使用npm install--production在生产环境中安装依赖项。
优化项目图像大小
尽量使用没有浪费的基本图像
众所周知,容器技术实现了操作系统级的进程隔离,而Docker容器本身是在另一个操作系统下运行的进程,因此Docker映像需要打包独立运行的操作系统级环境。因此,很明显,决定映像大小的一个重要因素是映像中嵌入的Linux操作系统的大小。
一般来说,减小依赖操作系统的大小主要有两种方法。一是尽量删除Linux库,如python、cmake和telnet。另一种是选择更轻的Linux分发。合适的官方图片应根据这两个因素提供每个分销的去势版。
例如,以node:14提供的node的官方版本为例,默认情况下,它在Ubuntu上运行,Ubuntu是一个大型、全面的Linux分发,以便最大限度地兼容。去除无用工具库依赖关系的版本称为node:14-slim版本。最小映像分配称为node:14-alpine,Linux alpine是一个非常合理、轻量级的Linux分配,仅包含基本工具,其自己的Docker映像很小,只有4-5M,是创建最小版本Docker映像的理想尺寸。
由于此服务定义了用于移动服务的依赖关系,因此为了尽可能减小基本映像的大小,我们选择了阿尔派版作为生产环境的基本映像。
升级的构建
此时,出现了新的问题。alpine的基本工具库太小了,像webpack这样的打包工具在其背后考虑了庞大数量的插件库,因此项目构建时的环境依赖性变大了。这些库仅在编译时需要,在运行时可以删除。在这种情况下,利用Docker的分层构建功能可以解决这个问题。
首先,在完整版本的映像下安装依赖关系,并为任务提供别名(在这种情况下,构建)。
# 安装完整依赖并构建产物
FROM node:14 AS build
WORKDIR /app
COPY package*.json /app/
RUN ["npm", "install"]
COPY . /app/
RUN npm run build
然后,通过启用其他映像任务来运行生产环境,可以将生产的基本映像替换为高山版。要将编译后的源代码移动到构建任务,请从-从参数获取构建任务中的文件。
FROM node:14-alpine AS release
WORKDIR /release
COPY package*.json /
RUN ["npm", "install", "--registry=http://r.tnpm.oa.com", "--production"]
# 移入依赖与源码
COPY public /release/public
COPY --from=build /app/dist /release/dist
# 启动服务
EXPOSE 8000
CMD ["node", "./dist/index.js"]
Docker映像生成规则是生成的映像的结果仅基于最后一个映像任务。因此,上一个任务不会占用最终图像的大小,它完美地解决了这个问题。
当然,如果项目变得复杂,也会在运行时遇到基于工具的错误,如果该工具库不需要很多依赖关系,即使自己添加必要的依赖关系,也可以将图像大小控制得很小。
常见问题之一是node-gyp和node-sass库引用。此库用于将其他语言编写的模块转换为node模块,因此需要手动添加三个依赖关系g++make python。
# 安装生产环境依赖(为兼容 node-gyp 所需环境需要对 alpine 进行改造)
FROM node:14-alpine AS dependencies
RUN apk add --no-cache python make g++
COPY package*.json /
RUN ["npm", "install", "--registry=http://r.tnpm.oa.com", "--production"]
RUN apk del .gyp
简化Docker层
构建速度优化
正如您所知,Docker使用层概念创建和组织映像。Dockerfile中的每个命令创建一个新的文件层,每个层包含命令执行前和执行后图像对文件系统的更改。Docker使用缓存来提高构建速度。如果Dockerfile中的层的语句或从属关系没有更改,重建该层时,可以直接重用本地缓存。
如下所示,如果日志中出现Using cache字符,则缓存有效,层不进行运算,将原始缓存作为层的输出。
Step 2/3 : npm install
---> Using cache
---> efvbf79sd1eb
通过研究Docker的缓存算法,我们发现如果不能将缓存应用于Docker构建中的层,则任何后续的依赖于该步骤的层都不能从缓存中加载。例如:
COPY . .
RUN npm install
更改存储库中的文件时,即使依赖关系没有更改,上层npm install layer的依赖关系也已更改,因此高速缓存将不再被重用。
因此,要使用npm install缓存,请更改Dockerfile:
COPY package*.json .
RUN npm install
COPY src .
因此,即使在仅改变源代码的情况下,node_可以使用模块相关性缓存。
的优化原理。
通过最小化已修改文件的处理,并仅更改下一步所需的文件,最小化构建过程中的缓存禁用。
尽可能推迟处理文件变更的ADD命令和COPY命令的执行。
构建卷优化
为了确保速度,还必须考虑音量的优化。这里有三件事需要考虑。
由于Docker按层次上传映像存储库,因此高速缓存也能发挥最大的威力。因此,如上述npm install的例子那样,执行结果变化少的命令需要分离成不同的层。
镜像层的数量越少,总上载大小就越小。因此,在不影响执行链的末端,即其他层的高速缓存的情况下,尽可能组合命令来减小高速缓存的大小是很重要的。例如,设置环境变量的命令和整理无用文件的命令,因为有不被使用的输出,所以可以将它们总结为一行RUN命令。
RUN set ENV=prod && rm -rf ./trash
因为Docker的高速缓存也是由层高速缓存下载的,所以为了缩短映像的传输时间,固定用于构建的物理机器比较好。通过为管线指定专用主机等,可以大幅缩短图像准备时间。
当然,时间和空间优化有二律背反,为此,在设计Dockerfile时必须权衡Docker层的层数。例如,为了优化时间,为了复制等需要分割文件,这样图层就会增加,容量也会增加一些。
这里我想建议的是优先构建时间,然后尽量减少构建缓存而不牺牲时间。
使用Docker的思想集管理服务
避免进程守护进程
在编写传统的后台服务时,始终使用进程守护进程,如pm2或forever,以便在服务受到监视并发生意外崩溃时自动重新启动。然而,这对Docker并不有益,导致进一步的不稳定性。
首先,由于Docker本身是进程管理器,进程守护进程提供崩溃重启和事件记录等。Docker本身或基于Docker的管弦器(例如kubernetes)可以在没有附加应用程序实现的情况下提供它。另外,由于守护进程的性质,在以下情况下肯定会产生影响。
添加进程守护进程后,内存使用量会增加,图像大小也会随之增加。
由于守护程序始终处于运行状态,因此即使发生服务故障,Docker也不会应用自己的重启策略,Docker日志也不会记录崩溃,因此故障排除会变得困难。
如果添加了额外的进程,则Docker提供的监控度量(如CPU或内存)将变得不正确。
因此,诸如pm2的进程守护进程提供了Docker兼容版:pm2-runtime,但不建议使用进程守护进程。
其实,这是我们本来就有的想法造成的错误。在将服务迁移到云的过程中,不仅仅是架构的记述和对应,对开发的想法也很难改变,关于那个,在迁移到云的过程中学习。
永久保存日志
后端服务始终需要记录功能来进行故障诊断和审计。以前,只是把日志按类别分类,写入目录中的日志文件。但是,在Docker中,本地文件不是永久的,而是在容器生命周期结束时销毁。因此,必须将日志保存在容器外部。
最简单的方法是使用Docker Manager Volume,它可以绕过容器本身的文件系统,直接将数据写入主机的物理计算机。具体的使用方法如下。
docker run -d -it --name=app -v /app/log:/usr/share/log app
在docker运行时,使用-v参数将卷绑定到容器,并将主机上的/app/log目录(如果不存在,则自动创建)装载到容器的/usr/share/log中。这样,当服务将日志写入该文件夹时,它将永久保存在主机上,并且即使docker被破坏也不会丢失。
当然,随着部署集群的增加,管理物理主机上的日志可能变得困难。在这种情况下,需要集中管理的服务管理系统。从简单地管理日志的角度来看,可以考虑将其上传到云日志服务(例如Tencent Cloud CLS)并托管日志。或者,通过简单地统一管理容器(如Kubernetes),您可以将日志作为模块之一保留下来。因为有各种各样的做法,所以不啰嗦地说明。
选择服务控制器
除了优化镜像之外,控制服务的约束和部署的负载形式可能会对性能产生巨大影响。在这里,我们将简单地比较Kubernetes的代表控制器Deployment和StatefulSet,以便选择最适合服务的控制器。
StatefulSet是K8S版本1.5以后导入的控制器,其最大特点是可以整齐地进行Pod的部署、更新、废弃。那么,在我们的产品中,需要使用StatefulSet进行Pod管理吗?如果你用一句话概括官方的话
部署用于部署无状态服务,而状态集用于部署全状态服务。
这是非常准确的表达,但很难理解。那么,什么是无国籍呢。我个人认为,StatefulSet的特性可以通过以下步骤来理解。
可以按照一定的顺序进行由StatefulSet管理的多个Pod之间的展开、更新、删除操作。适用于多个服务之间存在依赖关系的情况,例如在启动查询服务之前启动数据库服务。
由于Pod之间存在依赖关系,因此每个Pod提供的服务必须不同,并且不执行由StatefulSet管理的Pod之间的负载分配。
由于Pod提供的服务不同,因此每个Pod具有独立的存储区域,并且不在Pod之间共享。
为了在部署和更新时遵守Pod顺序,需要固定Pod的名称,因此与部署不同,生成的Pod名称后面会有随机数字字符串。
另外,由于固定的播客名称,与StatefulSet协作的服务可以直接将播客名称用作访问域,而不是提供群集IP,与StatefulSet协作的服务被称为Headless服务。
这意味着,如果在k8s部署单个服务,或者多个服务之间不存在依赖关系,则具有自动调度和负载平衡的部署必须是简单的最佳选择。如果需要按一定顺序启动和停止服务,或者需要确保装载在每个端口上的数据卷不会被破坏,则建议选择“状态集”。
强烈建议将部署用作控制器,以遵循在不需要时不添加实体的原则,执行单个服务的所有工作负载。