杨晨 艺康集团数字化部门交付管理工程师
你好,我是杨晨。
今天我们来聊一聊如何让多环境部署更加高效和稳定,重点就是解决背景中所描述的如何使用单一代码库实现多环境部署问题。
通常情况下,我们会有这样一个解决方案:通过 CI/CD 流水线来管理这个流程。接下来我们就先来看看这种方案的效果怎么样。
我们以通过 Azure DevOps 为一个 Vue 工程搭建流水线为例来讲解,使用其他 DevOps 工具和框架处理的思路都是类似的。
不考虑环境时,一次编译任务是这样的:
- script: |
npm install
npm run build
displayName: 'npm install and build'
# 再通过 CopyFiles 和 PublishBuildArtifacts 任务发布部署文件
当你需要通过 CD 流水线部署至测试和生产两个环境时,就需要分别针对两个环境做编译。
首先,添加两个配置文件到项目文件夹:
// .env.test
NODE_ENV = "production"
BASE_URL = "{your API URL of test environment}"
// .env.prod
NODE_ENV = "production"
BASE_URL = "{your API URL of prod environment}"
接着,编译任务需要扩展为:
- script: |
npm install
npm run build -mode test
displayName: 'npm install and build for test'
# 再通过 CopyFiles 和 PublishBuildArtifacts 任务发布测试环境部署文件
- script: |
npm install
npm run build -mode prod
displayName: 'npm install and build for prod'
# 再通过 CopyFiles 和 PublishBuildArtifacts 任务发布生产环境部署文件
如果这套解决方案销售给了三个客户,就要分别为他们搭建测试和生产环境,算上现有的环境,那我们总共要准备八个配置文件,然后把编译任务在流水线里重复八次。
通过CI/CD配置自动运行的流水线,确实避免了手动操作可能出现的错误,但要做很多重复性的工作,而且编译一次需要很久。
你或许已经了解,Azure DevOps 中的流水线支持封装公共任务到一个Template来简化脚本编写,也支持把多组任务包成一个个Job 来实现并行处理。并行处理确实可以提高编译的效率,但无法避免每多一个客户就要修改一次流水线的问题,而且流水线的并行运行能力也是需要付费获得的,我们需要权衡效率和成本。
那有没有更好的办法呢?
我们先回想一下为什么要为不同的环境准备不同的配置文件?因为后端应用要访问不同的数据库,前端应用要访问不同的 API,这些信息都需要以字符串的方式记录下来,才能让应用读取并使用,但这些信息和我们的代码其实并没有什么关系。
所以,对比使用CI/CD 流水线配置,更好的方法是不再把这些信息也签入到源代码管理工具中,而是记录在一个仅和环境自身有关的地方,也就是使用环境变量。
将应用的配置储存在环境变量中,我们可以实现代码和配置的分离,这样操作的好处有很多:
在把应用的配置储存在环境变量中后,不同的应用在运行时读取环境变量的支持和方法不同。因为部署后再读取,需要应用支持在运行时读取环境变量。
对于 .NET 和 Java 等主流应用来说,运行时读取环境变量只需要调用框架自带的方法:
// C# 程序从环境变量中读取 BASE_URL 的值
string baseUrl = Environment.GetEnvironmentVariable("BASE_URL");
而 Vue 等单页应用是不支持读取系统环境变量的,接下来我就给你提供三个思路,讲解如何实现单页应用运行时的读取。
刚刚我们讲了要将应用的配置存储在环境变量中,那非系统的环境变量不可吗?并不是,只要能够分离代码与配置也可以实现目的,例如用一个文件记录这些变量放在环境里,让应用部署后去读取。
第一种思路适用于直接部署单页应用到服务器的场景。
首先,在 public 文件夹(Vue 的静态文件目录)添加一个js文件来记录变量:
// env-config.js
export const envConfig = {
"BASE_URL": "{your API URL}",
};
在你要使用 BASE_URL 变量的地方,通过 import 这个 js 文件来读取变量:
// import 语句
import * as ConfigHelper from '../../public/env-config.js'
// 读取语句
const baseUrl = ConfigHelper.envConfig.BASE_URL;
var baseUrl = ConfigHelper.envConfig.BASE_URL;
由于这个 js 文件是放在静态文件目录的,不会被 Webpack 等构建工具自动压缩,而是会原样放在打包目录(默认为 dist 文件夹)的根目录下:
接着,在各个环境的服务器中找一个固定位置,把这个 js 文件复制过去,并把变量值修改为适合当前环境的值,注意不要修改 js 文件的名字。
最后,在 CD 流水线的部署任务之后,加一个 SSH 到目标服务器执行以下指令的任务,就能在每次部署后自动记录当前环境信息的文件,替换掉网站根目录下的文件:
# 用服务器上存储的 js 文件替换部署
# /home/webadmin 为服务器上存储 js 文件的目录
# /usr/share/nginx/html 为 nginx 部署网站的默认目录
cp -f /home/webadmin/env-config.js /usr/share/nginx/html/env-config.js
这样,在网站运行的时候,代码读取到的就是当前环境的变量值了。
第二种思路适用于把单页应用打包到镜像再部署的场景。
首先,在需要使用 BASE_URL 变量的地方,通过读取 标签属性来获取:
const baseUrl = document.querySelector("html").getAttribute("BASE_URL");
接着,在 Dockerfile 中添加 CMD 命令,通过字符串替换实现在镜像运行时注入属性:
# 在 Dockerfile 中添加启动命令
CMD ["/bin/bash", "-c", "sed -i \"s@<html@<html BASE_URL=\"$baseUrl\"@\" /usr/share/nginx/html/index.html;"]
最后,在 Docker run 时通过 -e 传入参数(等同于 Docker compose 和 K8s 中为镜像配置的环境变量)。Dockerfile 中的 CMD 命令是在 Docker run 时执行的,执行时 baseUrl 参数已经是传入的值,执行后标签属性就成功地添加上了:
# 启动时传入变量值
docker run -d \
-p xx:xx \
-e baseUrl='{your api url}' \
{imageName}
最终< html> 标签被修改为了 <html BASE_URL={your API URL}>
这种方法会导致代码在本地调试时没有属性可以读取,可以在读取时做判断,若没有该属性就使用默认值,若有就读取。
第三种思路是把上面的两个思路结合起来,把第一种思路中的应用打包到镜像,然后在 Docker run 时通过 -v 用本地文件替换镜像中的文件:
docker run -d \
-p xx:xx \
-v /home/webadmin/env-config.js:/usr/share/nginx/html/env-config.js \
{imageName}
这样,我们就做到了让单页应用也能在运行时读取环境变量,实现了一次编译多次部署。
好了,最后总结一下,这节课我们讲解了如何高效稳定地实现单一代码库的多环境部署。
在多环境部署中,环境数量的增加带来配置文件的增加。如果手动编译部署会带来更多不确定性,不仅维护工作量大、容易出错,也拉长了编译所需的时间。如果单纯使用 CI/CD 流水线只能降低不确定性问题,但对编译时间的缩短是有限的,还无法避免对流水线的修改。
通过对配置文件作用的分析,我们发现环境变量更适合用来存储和代码无关而和环境有关的信息,所以把数据库、API 地址等信息存在环境变量里就是更好的选择。代码与配置分离,使得应用只需要编译一次,而且具有更高的部署灵活性。
针对无法在运行时读取系统环境变量的单页应用,我们也讲解了三种思路去实现它对广义的“环境变量”的读取,让单页应用也可以做到一次编译多次部署。
当然,在生产应用中还有很多可改善的方面,例如通过密钥管理器管理机密信息、通过统一的配置中心为环境下发配置等等。
我的分享仅作为抛砖引玉,希望你认真体会,实践摸索出自己工作的经验。
今天的讲解到这里就结束了。我是杨晨,希望我的分享可以帮助到你,也欢迎你在评论区留下你的思考,和我一起讨论。