关于javascript:NodeJS-服务-Docker-镜像极致优化指北

33次阅读

共计 6292 个字符,预计需要花费 16 分钟才能阅读完成。

这段时间在开发一个腾讯文档全品类通用的 HTML 动静服务,为了不便各品类接入的生成与部署,也适应上云的趋势,思考应用 Docker 的形式来固定服务内容,对立进行制品版本的治理。本篇文章就将我在服务 Docker 化的过程中积攒起来的优化教训分享进去,供大家参考。

以一个例子结尾,大部分刚接触 Docker 的同学应该都会这样编写我的项目的 Dockerfile,如下所示:

FROM node:14
WORKDIR /app

COPY . .
# 装置 npm 依赖
RUN npm install

# 裸露端口
EXPOSE 8000

CMD ["npm", "start"]

构建,打包,上传,零打碎敲。而后看下镜像状态,卧槽,一个简略的 node web 服务体积竟然达到了惊人的 1.3 个 G,并且镜像传输与构建速度也很慢:

要是这个镜像只须要部署一个实例也就算了,然而这个服务得提供给所有开发同学进行高频集成并部署环境的(实现高频集成的计划可参见我的 上一篇文章)。首先,镜像体积过大必然会对镜像的拉取和更新速度造成影响,集成体验会变差。其次,我的项目上线后,同时在线的测试环境实例可能成千上万,这样的容器内存占用老本对于任何一个我的项目都是无奈承受的。必须找到优化的方法解决。

发现问题后,我就开始钻研 Docker 的优化计划,筹备给我的镜像动手术了。

node 我的项目生产环境优化

首先开刀的是当然是前端最为相熟的畛域,对代码自身体积进行优化。之前开发我的项目时应用了 Typescript,为了图省事,我的项目间接应用 tsc 打包生成 es5 后就间接运行起来了。这里的体积问题次要有两个,一个是开发环境 ts 源码并未解决,并且用于生产环境的 js 代码也未经压缩。

另一个是援用的 node_modules 过于臃肿。依然蕴含了许多开发调试环境中的 npm 包,如 ts-node,typescript 等等。既然打包成 js 了,这些依赖天然就该去除。

一般来说,因为服务端代码不会像前端代码一样裸露进来,运行在物理机上的服务更多思考的是稳定性,也不在乎多一些体积,因而这些中央个别也不会做解决。然而 Docker 化后,因为部署规模变大,这些问题就非常明显了,在生产环境下须要优化的。

对于这两点的优化的形式其实咱们前端十分相熟了,不是本文的重点就粗略带过了。对于第一点,应用 Webpack + babel 降级并压缩 Typescript 源码,如果放心谬误排查能够加上 sourcemap,不过对于 docker 镜像来说有点多余,一会儿会说到。对于第二点,梳理 npm 包的 dependencies 与 devDependencies 依赖,去除不是必要存在于运行时的依赖,不便生产环境应用 npm install --production 装置依赖。

优化我的项目镜像体积

应用尽量精简的根底镜像

咱们晓得,容器技术提供的是操作系统级别的过程隔离,Docker 容器自身是一个运行在独立操作系统下的过程,也就是说,Docker 镜像须要打包的是一个可能独立运行的操作系统级环境。因而,决定镜像体积的一个重要因素就不言而喻了:打包进镜像的 Linux 操作系统的体积。

一般来说,减小依赖的操作系统的大小次要须要思考从两个方面下手,第一个是尽可能去除 Linux 下不须要的各类工具库,如 python,cmake, telnet 等。第二个是选取更轻量级的 Linux 发行版零碎。正规的官网镜像应该会根据上述两个因素对每个发行版提供阉割版本。

以 node 官网提供的版本 node:14 为例,默认版本中,它的运行根底环境是 Ubuntu,是一个大而全的 Linux 发行版,以保障最大的兼容性。去除了无用工具库的依赖版本称为 node:14-slim 版本。而最小的镜像发行版称为 node:14-alpine。Linux alpine 是一个高度精简,仅蕴含根本工具的轻量级 Linux 发行版,自身的 Docker 镜像只有 4~5M 大小,因而非常适合制作最小版本的 Docker 镜像。

在咱们的服务中,因为运行该服务的依赖是确定的,因而为了尽可能的缩减根底镜像的体积,咱们抉择 alpine 版本作为生产环境的根底镜像。

分级构建

这时候,咱们遇到了新的问题。因为 alpine 的根本工具库过于简陋,而像 webpack 这样的打包工具背地可能应用的插件库极多,构建我的项目时对环境的依赖较大。并且这些工具库只有编译时须要用到,在运行时是能够去除的。对于这种状况,咱们能够利用 Docker 的 分级构建 的个性来解决这一问题。

首先,咱们能够在完整版镜像下进行依赖装置,并给该工作设立一个别名(此处为build)。

# 装置残缺依赖并构建产物
FROM node:14 AS build
WORKDIR /app

COPY package*.json /app/
RUN ["npm", "install"]
COPY . /app/

RUN npm run build

之后咱们能够启用另一个镜像工作来运行生产环境,生产的根底镜像就能够换成 alpine 版本了。其中编译实现后的源码能够通过 --from 参数获取到处于 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-gypnode-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

详情可见:https://github.com/nodejs/docker-node/issues/282

正当布局 Docker Layer

构建速度优化

咱们晓得,Docker 应用 Layer 概念来创立与组织镜像,Dockerfile 的每条指令都会产生一个新的文件层,每层都蕴含执行命令前后的状态之间镜像的文件系统更改,文件层越多,镜像体积就越大。而 Docker 应用缓存形式实现了构建速度的晋升。若 Dockerfile 中某层的语句及依赖未更改,则该层重建时能够间接复用本地缓存

如下所示,如果 log 中呈现 Using cache 字样时,阐明缓存失效了,该层将不会执行运算,间接拿原缓存作为该层的输入后果。

Step 2/3 : npm install
 ---> Using cache
 ---> efvbf79sd1eb

通过钻研 Docker 缓存算法,发现在 Docker 构建过程中,如果某层无奈利用缓存,则依赖此步的后续层都不能从缓存加载。例如上面这个例子:

COPY . .
RUN npm install

此时如果咱们更改了仓库的任意一个文件,此时因为 npm install 层的下层依赖变更了,哪怕依赖没有进行任何变动,缓存也不会被复用。

因而,若想尽可能的利用上 npm install 层缓存,咱们能够把 Dockerfile 改成这样:

COPY package*.json .
RUN npm install
COPY src .

这样在仅变更源码时,node_modules的依赖缓存依然能被利用上了。

由此,咱们失去了优化准则:

  1. 最小化解决变更文件,仅变更下一步所需的文件,以尽可能减少构建过程中的缓存生效。
  2. 对于解决文件变更的 ADD 命令、COPY 命令,尽量提早执行。

构建体积优化

在保障速度的前提下,体积优化也是咱们须要去思考的。这里咱们须要思考的有三点:

  1. Docker 是以层为单位上传镜像仓库的,这样也能最大化的利用缓存的能力。因而,执行后果很少变动的命令须要抽出来独自成层 ,如下面提到的npm install 的例子里,也用到了这方面的思维。
  2. 如果镜像层数越少,总上传体积就越小。因而,在命令处于执行链尾部,即不会对其余层缓存产生影响的状况下,尽量合并命令,从而缩小缓存体积。例如,设置环境变量和清理无用文件的指令,它们的输入都是不会被应用的,因而能够将这些命令合并为一行 RUN 命令。
RUN set ENV=prod && rm -rf ./trash
  1. Docker cache 的下载也是通过层缓存的形式,因而为了缩小镜像的传输下载工夫,咱们最好应用 固定的物理机器 来进行构建。例如在流水线中指定专用宿主机,能是的镜像的筹备工夫大大减少。

当然,工夫和空间的优化素来就没有两败俱伤的方法,这一点须要咱们在设计 Dockerfile 时,对 Docker Layer 层数做出衡量。例如为了工夫优化,须要咱们拆分文件的复制等操作,而这一点会导致层数增多,稍微减少空间。

这里我的倡议是,优先保障构建工夫,其次在不影响工夫的状况下,尽可能的放大构建缓存体积。

以 Docker 的思维治理服务

防止应用过程守护

咱们编写传统的后盾服务时,总是会应用例如 pm2、forever 等等过程守护程序,以保障服务在意外解体时能被监测到并主动重启。但这一点在 Docker 下非但没有好处,还带来了额定的不稳固因素。

首先,Docker 自身就是一个流程管理器,因而,过程守护程序提供的解体重启,日志记录等等工作 Docker 自身或是基于 Docker 的编排程序(如 kubernetes)就能提供了,无需应用额定利用实现。除此之外,因为守护过程的个性,将不可避免的对于以下的状况产生影响:

  1. 减少过程守护程序会使得占用的内存增多,镜像体积也会相应增大。
  2. 因为守护过程始终能失常运行,服务产生故障时,Docker 本身的重启策略将不会失效,Docker 日志里将不会记录解体信息,排障溯源艰难。
  3. 因为多了个过程的退出,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 参数为容器绑定 volumes,将宿主机上的 /app/log 目录(如果没有会主动创立)挂载到容器的 /usr/share/log 中。这样服务在将日志写入该文件夹时,就能长久化存储在宿主机上,不随着 docker 的销毁而失落了。

当然,当部署集群变多后,物理宿主机上的日志也会变得难以治理。此时就须要一个服务编排零碎来对立治理了。从单纯治理日志的角度登程,咱们能够进行网络上报,给到云日志服务(如腾讯云 CLS)托管。或者罗唆将容器进行批量治理,例如 Kubernetes 这样的容器编排零碎,这样日志作为其中的一个模块天然也能失去妥善保存了。这样的办法很多,就不多加赘述了。

k8s 服务控制器的抉择

镜像优化之外,服务编排以及管制部署的负载模式对性能的影响也很大。这里以最风行的 Kubernetes 的两种控制器(Controller):DeploymentStatefulSet 为例,简要比拟一下这两类组织模式,帮忙抉择出最适宜服务的 Controller。

StatefulSet是 K8S 在 1.5 版本后引入的 Controller,次要特点为:可能实现 pod 间的有序部署、更新和销毁。那么咱们的制品是否须要应用 StatefulSet 做 pod 治理呢?官网简要概括为一句话:

Deployment 用于部署无状态服务,StatefulSet 用来部署有状态服务。

这句话非常准确,但不易于了解。那么,什么是无状态呢?在我看来,StatefulSet的特点能够从如下几个步骤进行了解:

  1. StatefulSet治理的多个 pod 之间进行部署,更新,删除操作时可能依照固定程序顺次进行。实用于多服务之间有依赖的状况,如先启动数据库服务再开启查问服务。
  2. 因为 pod 之间有依赖关系,因而每个 pod 提供的服务必然不同,所以 StatefulSet 治理的 pod 之间没有负载平衡的能力。
  3. 又因为 pod 提供的服务不同,所以每个 pod 都会有本人独立的存储空间,pod 间不共享。
  4. 为了保障 pod 部署更新时程序,必须固定 pod 的名称,因而不像 Deployment 那样生成的 pod 名称后会带一串随机数。
  5. 而因为 pod 名称固定,因而跟 StatefulSet 对接的 Service 中能够间接以 pod 名称作为拜访域名,而不须要提供Cluster IP,因而跟 StatefulSet 对接的 Service 被称为 Headless Service

通过这里咱们就应该明确,如果在 k8s 上部署的是单个服务,或是多服务间没有依赖关系,那么 Deployment 肯定是简略而又成果最佳的抉择,主动调度,主动负载平衡。而如果服务的启停必须满足肯定程序,或者每一个 pod 所挂载的数据 volume 须要在销毁后仍然存在,那么倡议抉择 StatefulSet

本着如无必要,勿增实体的准则,强烈建议所有运行单个服务工作负载采纳 Deployment 作为 Controller。

写在结尾

一通钻研下来,差点把一开始的指标忘了,连忙将 Docker 从新构建一遍,看看优化成绩。

能够看到,对于镜像体积的优化成果还是不错的,达到了 10 倍左右。当然,如果我的项目中不须要如此高版本的 node 反对,还能进一步放大大概一半的镜像体积。

之后镜像仓库会对寄存的镜像文件做一次压缩,以 node14 打包的镜像版本最终被压缩到了 50M 以内。

当然,除了看失去的体积数据之外,更重要的优化其实在于,从面向物理机的服务向容器化云服务在架构设计层面上的转变。

容器化曾经是看得见的将来,作为一名开发人员,要时刻放弃对前沿技术的敏感,踊跃实际,能力将技术转化为生产力,为我的项目的进化做出奉献。

参考资料:

  1. 《Kubernetes in action》–Marko Lukša
  2. Optimizing Docker Images

正文完
 0