ホームページ > 記事 > ウェブフロントエンド > NodeサービスのDockerミラーリングを行うにはどうすればよいですか?極限最適化の詳しい説明
この期間中、私は Tencent ドキュメントのすべてのカテゴリに共通する HTML 動的サービスを開発してきました。これは、さまざまなカテゴリへのアクセスの生成と展開を容易にするためであり、クラウド化の流れに合わせてDockerの利用を検討していますが、サービス内容の固定化や製品のバージョン管理を一元管理する方法を採用しています。この記事では、私が Docker を提供するプロセスで蓄積した最適化の経験を参考として共有します。 [関連チュートリアルの推奨事項: nodejs ビデオ チュートリアル ]
例から始めます。Docker を初めて使用するほとんどの学生は、以下に示すように、プロジェクトの Dockerfile を作成する必要があります。
FROM node:14 WORKDIR /app COPY . . # 安装 npm 依赖 RUN npm install # 暴露端口 EXPOSE 8000 CMD ["npm", "start"]ビルド、パッケージ化、アップロードをすべて一度に行います。次に、イメージのステータスを見てみましょう。なんと、単純なノード Web サービスの容量は驚くべき 1.3 G に達しており、イメージの送信と構築の速度も非常に遅いです。 ## このイメージを 1 つのインスタンスでデプロイする必要があるだけであれば問題ありませんが、このサービスは高頻度の統合とデプロイメント環境のためにすべての開発学生に提供する必要があります (高頻度の統合を実現するためのソリューションについては、以前の記事を参照してください) 。まず、イメージ サイズが大きすぎると、必然的にイメージのプルと更新の速度に影響が生じ、統合エクスペリエンスが低下します。第二に、プロジェクトの開始後、同時に数万のテスト環境インスタンスがオンラインになる可能性があり、そのようなコンテナー メモリの消費コストは、どのプロジェクトにとっても許容できないものです。最適化されたソリューションを見つける必要があります。 問題を発見した後、私は Docker の最適化計画を研究し始め、イメージに手術を実行する準備をしました。
ノード プロジェクトの運用環境の最適化
最初に行うことは、もちろんフロントエンドの最も馴染みのある領域であり、コード自体のサイズを最適化することです。以前のプロジェクト開発時は Typescript を使用していましたが、手間を省くために tsc でプロジェクトを直接パッケージ化して es5 を生成し、直接実行しました。ここには主に 2 つのボリュームの問題があり、1 つは開発環境の ts ソース コードが処理されていないことと、運用環境で使用される js コードが圧縮されていないことです。
もう 1 つは、参照される node_modules が肥大化しすぎることです。 ts-node、typescript など、開発およびデバッグ環境用の多くの npm パッケージが引き続き含まれています。 js にパッケージ化されたので、これらの依存関係は自然に削除されるはずです。 一般的に、サーバー側のコードはフロントエンド コードのように公開されないため、物理マシン上で実行されるサービスは安定性をより重視し、ボリュームの増加を気にしません。そのため、これらの場所は通常、加工を行います。ただし、Docker 化後、デプロイメントの規模が大きくなるにつれて、これらの問題が非常に顕著になるため、実稼働環境で最適化する必要があります。実際、フロントエンドのこれら 2 つのポイントの最適化方法についてはよく知っていますが、この記事の焦点ではない場合は、簡単に説明します。 1 つ目の点として、Webpack babel を使用して Typescript のソース コードをダウングレードし、圧縮します。エラーのトラブルシューティングが心配な場合は、ソースマップを追加できますが、後で説明する Docker イメージにとっては少し冗長です。 2 番目のポイントとして、npm パッケージの依存関係と devDependency を整理し、本番環境での使用を容易にするために実行時に不要な依存関係を削除します。
プロジェクト イメージのボリュームを最適化する
できるだけ合理化された基本イメージを使用する
コンテナ テクノロジーがオペレーティング システム レベルと Docker コンテナ自体でプロセスを分離できることを私たちは知っています。独立したオペレーティング システムの下で A プロセス上で実行されるプロセスです。つまり、Docker イメージは、独立して実行できるオペレーティング システム レベルの環境にパッケージ化する必要があります。したがって、イメージ サイズを決定する際の重要な要素は、イメージにパッケージ化されている Linux オペレーティング システムのサイズであることが明らかになります。 一般的に、依存するオペレーティング システムのサイズを減らすには 2 つの側面を考慮する必要があります。1 つ目は、Python、cmake、telnet wait などの Linux 上で不要なツール ライブラリをできるだけ削除することです。 2 つ目は、より軽量な Linux ディストリビューション システムを選択することです。通常の公式イメージは、上記 2 つの要素に基づいて各リリースの去勢バージョンを提供する必要があります。
这时候,我们遇到了新的问题。由于 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-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
详情可见:https://github.com/nodejs/docker-node/issues/282
我们知道,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
的依赖缓存仍然能被利用上了。
由此,我们得到了优化原则:
最小化处理变更文件,仅变更下一步所需的文件,以尽可能减少构建过程中的缓存失效。
对于处理文件变更的 ADD 命令、COPY 命令,尽量延迟执行。
在保证速度的前提下,体积优化也是我们需要去考虑的。这里我们需要考虑的有三点:
Docker 是以层为单位上传镜像仓库的,这样也能最大化的利用缓存的能力。因此,执行结果很少变化的命令需要抽出来单独成层,如上面提到的npm install
的例子里,也用到了这方面的思想。
如果镜像层数越少,总上传体积就越小。因此,在命令处于执行链尾部,即不会对其他层缓存产生影响的情况下,尽量合并命令,从而减少缓存体积。例如,设置环境变量和清理无用文件的指令,它们的输出都是不会被使用的,因此可以将这些命令合并为一行 RUN 命令。
RUN set ENV=prod && rm -rf ./trash
当然,时间和空间的优化从来就没有两全其美的办法,这一点需要我们在设计 Dockerfile 时,对 Docker Layer 层数做出权衡。例如为了时间优化,需要我们拆分文件的复制等操作,而这一点会导致层数增多,略微增加空间。
这里我的建议是,优先保证构建时间,其次在不影响时间的情况下,尽可能的缩小构建缓存体积。
我们编写传统的后台服务时,总是会使用例如 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 参数为容器绑定 volumes,将宿主机上的 /app/log
目录(如果没有会自动创建)挂载到容器的 /usr/share/log
中。这样服务在将日志写入该文件夹时,就能持久化存储在宿主机上,不随着 docker 的销毁而丢失了。
当然,当部署集群变多后,物理宿主机上的日志也会变得难以管理。此时就需要一个服务编排系统来统一管理了。从单纯管理日志的角度出发,我们可以进行网络上报,给到云日志服务(如腾讯云 CLS)托管。或者干脆将容器进行批量管理,例如Kubernetes
这样的容器编排系统,这样日志作为其中的一个模块自然也能得到妥善保管了。这样的方法很多,就不多加赘述了。
镜像优化之外,服务编排以及控制部署的负载形式对性能的影响也很大。这里以最流行的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 间不共享。
为了保证 pod 部署更新时顺序,必须固定 pod 的名称,因此不像 Deployment
那样生成的 pod 名称后会带一串随机数。
ポッド名が固定されているため、StatefulSet
に接続されている Service
は、 クラスターを提供せずに、ポッド名をアクセス ドメイン名として直接使用できます。 IP
であるため、StatefulSet
に接続されている Service
は Headless Service
と呼ばれます。
これにより、単一のサービスが k8s にデプロイされている場合、または複数のサービス間に依存関係がない場合、デプロイメント
はシンプルかつ最良でなければならないことが理解できるはずです。選択、自動スケジュール、自動負荷分散。サービスの開始と停止が特定のシーケンスを満たす必要がある場合、または各ポッドにマウントされたデータ ボリュームが破棄後も存在する必要がある場合は、StatefulSet
を選択することをお勧めします。
必要な場合以外はエンティティを追加しないという原則に基づき、単一のサービスを実行するすべてのワークロードで Deployment
をコントローラーとして使用することを強くお勧めします。
勉強してみたら当初の目的を忘れそうになったので、最適化結果を見るために急遽Dockerを再構築してみました。
#ミラー ボリュームに対する最適化効果は依然として良好で、約 10 倍に達していることがわかります。もちろん、プロジェクトがそれほど高いバージョンのノード サポートを必要としない場合は、イメージ サイズをさらに約半分に減らすことができます。
その後、イメージ ウェアハウスは保存されたイメージ ファイルを圧縮し、node14 にパッケージ化されたイメージ バージョンは最終的に 50M 未満に圧縮されます。
もちろん、目に見えるボリューム データに加えて、より重要な最適化は実際には、物理マシン指向のサービスからコンテナ化されたクラウド サービスへのアーキテクチャ設計の変更にあります。
コンテナ化はすでに目に見える未来です。開発者は、テクノロジーを生産性に変えてプロジェクトの進化に貢献するために、常に最先端のテクノロジーに敏感であり、積極的に実践する必要があります。
ノード関連の知識の詳細については、nodejs チュートリアル を参照してください。
以上がNodeサービスのDockerミラーリングを行うにはどうすればよいですか?極限最適化の詳しい説明の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。